1use crate::model::NormalizedSbom;
7use serde::{Deserialize, Serialize};
8
9use super::compliance::{ComplianceChecker, ComplianceLevel, ComplianceResult};
10use super::metrics::{
11 CompletenessMetrics, CompletenessWeights, DependencyMetrics, IdentifierMetrics, LicenseMetrics,
12 VulnerabilityMetrics,
13};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17pub enum ScoringProfile {
18 Minimal,
20 Standard,
22 Security,
24 LicenseCompliance,
26 Cra,
28 Comprehensive,
30}
31
32impl ScoringProfile {
33 pub fn compliance_level(&self) -> ComplianceLevel {
35 match self {
36 Self::Minimal => ComplianceLevel::Minimum,
37 Self::Standard => ComplianceLevel::Standard,
38 Self::Security => ComplianceLevel::NtiaMinimum,
39 Self::LicenseCompliance => ComplianceLevel::Standard,
40 Self::Cra => ComplianceLevel::CraPhase2,
41 Self::Comprehensive => ComplianceLevel::Comprehensive,
42 }
43 }
44
45 fn weights(&self) -> ScoringWeights {
47 match self {
48 Self::Minimal => ScoringWeights {
49 completeness: 0.5,
50 identifiers: 0.2,
51 licenses: 0.1,
52 vulnerabilities: 0.1,
53 dependencies: 0.1,
54 },
55 Self::Standard => ScoringWeights {
56 completeness: 0.35,
57 identifiers: 0.25,
58 licenses: 0.15,
59 vulnerabilities: 0.1,
60 dependencies: 0.15,
61 },
62 Self::Security => ScoringWeights {
63 completeness: 0.2,
64 identifiers: 0.25,
65 licenses: 0.1,
66 vulnerabilities: 0.3,
67 dependencies: 0.15,
68 },
69 Self::LicenseCompliance => ScoringWeights {
70 completeness: 0.2,
71 identifiers: 0.15,
72 licenses: 0.4,
73 vulnerabilities: 0.1,
74 dependencies: 0.15,
75 },
76 Self::Cra => ScoringWeights {
77 completeness: 0.2, identifiers: 0.25, licenses: 0.1, vulnerabilities: 0.25, dependencies: 0.2, },
83 Self::Comprehensive => ScoringWeights {
84 completeness: 0.25,
85 identifiers: 0.2,
86 licenses: 0.2,
87 vulnerabilities: 0.15,
88 dependencies: 0.2,
89 },
90 }
91 }
92}
93
94#[derive(Debug, Clone)]
96struct ScoringWeights {
97 completeness: f32,
98 identifiers: f32,
99 licenses: f32,
100 vulnerabilities: f32,
101 dependencies: f32,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106pub enum QualityGrade {
107 A,
109 B,
111 C,
113 D,
115 F,
117}
118
119impl QualityGrade {
120 pub fn from_score(score: f32) -> Self {
122 match score as u32 {
123 90..=100 => Self::A,
124 80..=89 => Self::B,
125 70..=79 => Self::C,
126 60..=69 => Self::D,
127 _ => Self::F,
128 }
129 }
130
131 pub fn letter(&self) -> &'static str {
133 match self {
134 Self::A => "A",
135 Self::B => "B",
136 Self::C => "C",
137 Self::D => "D",
138 Self::F => "F",
139 }
140 }
141
142 pub fn description(&self) -> &'static str {
144 match self {
145 Self::A => "Excellent",
146 Self::B => "Good",
147 Self::C => "Fair",
148 Self::D => "Poor",
149 Self::F => "Failing",
150 }
151 }
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct Recommendation {
157 pub priority: u8,
159 pub category: RecommendationCategory,
161 pub message: String,
163 pub impact: f32,
165 pub affected_count: usize,
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
171pub enum RecommendationCategory {
172 Completeness,
173 Identifiers,
174 Licenses,
175 Vulnerabilities,
176 Dependencies,
177 Compliance,
178}
179
180impl RecommendationCategory {
181 pub fn name(&self) -> &'static str {
182 match self {
183 Self::Completeness => "Completeness",
184 Self::Identifiers => "Identifiers",
185 Self::Licenses => "Licenses",
186 Self::Vulnerabilities => "Vulnerabilities",
187 Self::Dependencies => "Dependencies",
188 Self::Compliance => "Compliance",
189 }
190 }
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct QualityReport {
196 pub overall_score: f32,
198 pub grade: QualityGrade,
200 pub profile: ScoringProfile,
202 pub completeness_score: f32,
204 pub identifier_score: f32,
206 pub license_score: f32,
208 pub vulnerability_score: f32,
210 pub dependency_score: f32,
212 pub completeness_metrics: CompletenessMetrics,
214 pub identifier_metrics: IdentifierMetrics,
216 pub license_metrics: LicenseMetrics,
218 pub vulnerability_metrics: VulnerabilityMetrics,
220 pub dependency_metrics: DependencyMetrics,
222 pub compliance: ComplianceResult,
224 pub recommendations: Vec<Recommendation>,
226}
227
228#[derive(Debug, Clone)]
230pub struct QualityScorer {
231 profile: ScoringProfile,
233 completeness_weights: CompletenessWeights,
235}
236
237impl QualityScorer {
238 pub fn new(profile: ScoringProfile) -> Self {
240 Self {
241 profile,
242 completeness_weights: CompletenessWeights::default(),
243 }
244 }
245
246 pub fn with_completeness_weights(mut self, weights: CompletenessWeights) -> Self {
248 self.completeness_weights = weights;
249 self
250 }
251
252 pub fn score(&self, sbom: &NormalizedSbom) -> QualityReport {
254 let total_components = sbom.components.len();
255
256 let completeness_metrics = CompletenessMetrics::from_sbom(sbom);
258 let identifier_metrics = IdentifierMetrics::from_sbom(sbom);
259 let license_metrics = LicenseMetrics::from_sbom(sbom);
260 let vulnerability_metrics = VulnerabilityMetrics::from_sbom(sbom);
261 let dependency_metrics = DependencyMetrics::from_sbom(sbom);
262
263 let completeness_score = completeness_metrics.overall_score(&self.completeness_weights);
265 let identifier_score = identifier_metrics.quality_score(total_components);
266 let license_score = license_metrics.quality_score(total_components);
267 let vulnerability_score = vulnerability_metrics.documentation_score();
268 let dependency_score = dependency_metrics.quality_score(total_components);
269
270 let weights = self.profile.weights();
272 let overall_score = (completeness_score * weights.completeness
273 + identifier_score * weights.identifiers
274 + license_score * weights.licenses
275 + vulnerability_score * weights.vulnerabilities
276 + dependency_score * weights.dependencies)
277 .min(100.0);
278
279 let compliance_checker = ComplianceChecker::new(self.profile.compliance_level());
281 let compliance = compliance_checker.check(sbom);
282
283 let recommendations = self.generate_recommendations(
285 &completeness_metrics,
286 &identifier_metrics,
287 &license_metrics,
288 &dependency_metrics,
289 &compliance,
290 total_components,
291 );
292
293 QualityReport {
294 overall_score,
295 grade: QualityGrade::from_score(overall_score),
296 profile: self.profile,
297 completeness_score,
298 identifier_score,
299 license_score,
300 vulnerability_score,
301 dependency_score,
302 completeness_metrics,
303 identifier_metrics,
304 license_metrics,
305 vulnerability_metrics,
306 dependency_metrics,
307 compliance,
308 recommendations,
309 }
310 }
311
312 fn generate_recommendations(
313 &self,
314 completeness: &CompletenessMetrics,
315 identifiers: &IdentifierMetrics,
316 licenses: &LicenseMetrics,
317 dependencies: &DependencyMetrics,
318 compliance: &ComplianceResult,
319 total_components: usize,
320 ) -> Vec<Recommendation> {
321 let mut recommendations = Vec::new();
322
323 if compliance.error_count > 0 {
325 recommendations.push(Recommendation {
326 priority: 1,
327 category: RecommendationCategory::Compliance,
328 message: format!(
329 "Fix {} compliance error(s) to meet {} requirements",
330 compliance.error_count,
331 compliance.level.name()
332 ),
333 impact: 20.0,
334 affected_count: compliance.error_count,
335 });
336 }
337
338 let missing_versions = total_components
340 - ((completeness.components_with_version / 100.0) * total_components as f32) as usize;
341 if missing_versions > 0 {
342 recommendations.push(Recommendation {
343 priority: 1,
344 category: RecommendationCategory::Completeness,
345 message: "Add version information to all components".to_string(),
346 impact: (missing_versions as f32 / total_components.max(1) as f32) * 15.0,
347 affected_count: missing_versions,
348 });
349 }
350
351 if identifiers.missing_all_identifiers > 0 {
353 recommendations.push(Recommendation {
354 priority: 2,
355 category: RecommendationCategory::Identifiers,
356 message: "Add PURL or CPE identifiers to components".to_string(),
357 impact: (identifiers.missing_all_identifiers as f32
358 / total_components.max(1) as f32)
359 * 20.0,
360 affected_count: identifiers.missing_all_identifiers,
361 });
362 }
363
364 let invalid_ids = identifiers.invalid_purls + identifiers.invalid_cpes;
366 if invalid_ids > 0 {
367 recommendations.push(Recommendation {
368 priority: 2,
369 category: RecommendationCategory::Identifiers,
370 message: "Fix malformed PURL/CPE identifiers".to_string(),
371 impact: 10.0,
372 affected_count: invalid_ids,
373 });
374 }
375
376 let missing_licenses = total_components - licenses.with_declared;
378 if missing_licenses > 0 && (missing_licenses as f32 / total_components.max(1) as f32) > 0.2
379 {
380 recommendations.push(Recommendation {
381 priority: 3,
382 category: RecommendationCategory::Licenses,
383 message: "Add license information to components".to_string(),
384 impact: (missing_licenses as f32 / total_components.max(1) as f32) * 12.0,
385 affected_count: missing_licenses,
386 });
387 }
388
389 if licenses.noassertion_count > 0 {
391 recommendations.push(Recommendation {
392 priority: 3,
393 category: RecommendationCategory::Licenses,
394 message: "Replace NOASSERTION with actual license information".to_string(),
395 impact: 5.0,
396 affected_count: licenses.noassertion_count,
397 });
398 }
399
400 if licenses.non_standard_licenses > 0 {
402 recommendations.push(Recommendation {
403 priority: 4,
404 category: RecommendationCategory::Licenses,
405 message: "Use SPDX license identifiers for better interoperability".to_string(),
406 impact: 3.0,
407 affected_count: licenses.non_standard_licenses,
408 });
409 }
410
411 if total_components > 1 && dependencies.total_dependencies == 0 {
413 recommendations.push(Recommendation {
414 priority: 4,
415 category: RecommendationCategory::Dependencies,
416 message: "Add dependency relationships between components".to_string(),
417 impact: 10.0,
418 affected_count: total_components,
419 });
420 }
421
422 if dependencies.orphan_components > 1
424 && (dependencies.orphan_components as f32 / total_components.max(1) as f32) > 0.3
425 {
426 recommendations.push(Recommendation {
427 priority: 4,
428 category: RecommendationCategory::Dependencies,
429 message: "Review orphan components that have no dependency relationships"
430 .to_string(),
431 impact: 5.0,
432 affected_count: dependencies.orphan_components,
433 });
434 }
435
436 let missing_suppliers = total_components
438 - ((completeness.components_with_supplier / 100.0) * total_components as f32) as usize;
439 if missing_suppliers > 0
440 && (missing_suppliers as f32 / total_components.max(1) as f32) > 0.5
441 {
442 recommendations.push(Recommendation {
443 priority: 5,
444 category: RecommendationCategory::Completeness,
445 message: "Add supplier information to components".to_string(),
446 impact: (missing_suppliers as f32 / total_components.max(1) as f32) * 8.0,
447 affected_count: missing_suppliers,
448 });
449 }
450
451 let missing_hashes = total_components
453 - ((completeness.components_with_hashes / 100.0) * total_components as f32) as usize;
454 if missing_hashes > 0
455 && matches!(
456 self.profile,
457 ScoringProfile::Security | ScoringProfile::Comprehensive
458 )
459 {
460 recommendations.push(Recommendation {
461 priority: 5,
462 category: RecommendationCategory::Completeness,
463 message: "Add cryptographic hashes for integrity verification".to_string(),
464 impact: (missing_hashes as f32 / total_components.max(1) as f32) * 5.0,
465 affected_count: missing_hashes,
466 });
467 }
468
469 recommendations.sort_by(|a, b| {
471 a.priority.cmp(&b.priority).then_with(|| {
472 b.impact
473 .partial_cmp(&a.impact)
474 .unwrap_or(std::cmp::Ordering::Equal)
475 })
476 });
477
478 recommendations
479 }
480}
481
482impl Default for QualityScorer {
483 fn default() -> Self {
484 Self::new(ScoringProfile::Standard)
485 }
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491
492 #[test]
493 fn test_grade_from_score() {
494 assert_eq!(QualityGrade::from_score(95.0), QualityGrade::A);
495 assert_eq!(QualityGrade::from_score(85.0), QualityGrade::B);
496 assert_eq!(QualityGrade::from_score(75.0), QualityGrade::C);
497 assert_eq!(QualityGrade::from_score(65.0), QualityGrade::D);
498 assert_eq!(QualityGrade::from_score(55.0), QualityGrade::F);
499 }
500
501 #[test]
502 fn test_scoring_profile_compliance_level() {
503 assert_eq!(
504 ScoringProfile::Minimal.compliance_level(),
505 ComplianceLevel::Minimum
506 );
507 assert_eq!(
508 ScoringProfile::Security.compliance_level(),
509 ComplianceLevel::NtiaMinimum
510 );
511 assert_eq!(
512 ScoringProfile::Comprehensive.compliance_level(),
513 ComplianceLevel::Comprehensive
514 );
515 }
516}