Skip to main content

sbom_tools/quality/
metrics.rs

1//! Quality metrics for SBOM assessment.
2//!
3//! Provides detailed metrics for different aspects of SBOM quality.
4
5use std::collections::{BTreeMap, HashMap, HashSet};
6
7use crate::model::{
8    CompletenessDeclaration, ComponentType, CreatorType, CryptoAssetType, CryptoMaterialState,
9    CryptoPrimitive, EolStatus, ExternalRefType, HashAlgorithm, NormalizedSbom, StalenessLevel,
10};
11use serde::{Deserialize, Serialize};
12
13/// Overall completeness metrics for an SBOM
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct CompletenessMetrics {
16    /// Percentage of components with versions (0-100)
17    pub components_with_version: f32,
18    /// Percentage of components with PURLs (0-100)
19    pub components_with_purl: f32,
20    /// Percentage of components with CPEs (0-100)
21    pub components_with_cpe: f32,
22    /// Percentage of components with suppliers (0-100)
23    pub components_with_supplier: f32,
24    /// Percentage of components with hashes (0-100)
25    pub components_with_hashes: f32,
26    /// Percentage of components with licenses (0-100)
27    pub components_with_licenses: f32,
28    /// Percentage of components with descriptions (0-100)
29    pub components_with_description: f32,
30    /// Whether document has creator information
31    pub has_creator_info: bool,
32    /// Whether document has timestamp
33    pub has_timestamp: bool,
34    /// Whether document has serial number/ID
35    pub has_serial_number: bool,
36    /// Total component count
37    pub total_components: usize,
38}
39
40impl CompletenessMetrics {
41    /// Calculate completeness metrics from an SBOM
42    #[must_use]
43    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
44        let total = sbom.components.len();
45        if total == 0 {
46            return Self::empty();
47        }
48
49        let mut with_version = 0;
50        let mut with_purl = 0;
51        let mut with_cpe = 0;
52        let mut with_supplier = 0;
53        let mut with_hashes = 0;
54        let mut with_licenses = 0;
55        let mut with_description = 0;
56
57        for comp in sbom.components.values() {
58            if comp.version.is_some() {
59                with_version += 1;
60            }
61            if comp.identifiers.purl.is_some() {
62                with_purl += 1;
63            }
64            if !comp.identifiers.cpe.is_empty() {
65                with_cpe += 1;
66            }
67            if comp.supplier.is_some() {
68                with_supplier += 1;
69            }
70            if !comp.hashes.is_empty() {
71                with_hashes += 1;
72            }
73            if !comp.licenses.declared.is_empty() || comp.licenses.concluded.is_some() {
74                with_licenses += 1;
75            }
76            if comp.description.is_some() {
77                with_description += 1;
78            }
79        }
80
81        let pct = |count: usize| (count as f32 / total as f32) * 100.0;
82
83        Self {
84            components_with_version: pct(with_version),
85            components_with_purl: pct(with_purl),
86            components_with_cpe: pct(with_cpe),
87            components_with_supplier: pct(with_supplier),
88            components_with_hashes: pct(with_hashes),
89            components_with_licenses: pct(with_licenses),
90            components_with_description: pct(with_description),
91            has_creator_info: !sbom.document.creators.is_empty(),
92            has_timestamp: true, // Always set in our model
93            has_serial_number: sbom.document.serial_number.is_some(),
94            total_components: total,
95        }
96    }
97
98    /// Create empty metrics
99    #[must_use]
100    pub const fn empty() -> Self {
101        Self {
102            components_with_version: 0.0,
103            components_with_purl: 0.0,
104            components_with_cpe: 0.0,
105            components_with_supplier: 0.0,
106            components_with_hashes: 0.0,
107            components_with_licenses: 0.0,
108            components_with_description: 0.0,
109            has_creator_info: false,
110            has_timestamp: false,
111            has_serial_number: false,
112            total_components: 0,
113        }
114    }
115
116    /// Calculate overall completeness score (0-100)
117    #[must_use]
118    pub fn overall_score(&self, weights: &CompletenessWeights) -> f32 {
119        let mut score = 0.0;
120        let mut total_weight = 0.0;
121
122        // Component field scores
123        score += self.components_with_version * weights.version;
124        total_weight += weights.version * 100.0;
125
126        score += self.components_with_purl * weights.purl;
127        total_weight += weights.purl * 100.0;
128
129        score += self.components_with_cpe * weights.cpe;
130        total_weight += weights.cpe * 100.0;
131
132        score += self.components_with_supplier * weights.supplier;
133        total_weight += weights.supplier * 100.0;
134
135        score += self.components_with_hashes * weights.hashes;
136        total_weight += weights.hashes * 100.0;
137
138        score += self.components_with_licenses * weights.licenses;
139        total_weight += weights.licenses * 100.0;
140
141        // Document metadata scores
142        if self.has_creator_info {
143            score += 100.0 * weights.creator_info;
144        }
145        total_weight += weights.creator_info * 100.0;
146
147        if self.has_serial_number {
148            score += 100.0 * weights.serial_number;
149        }
150        total_weight += weights.serial_number * 100.0;
151
152        if total_weight > 0.0 {
153            (score / total_weight) * 100.0
154        } else {
155            0.0
156        }
157    }
158}
159
160/// Weights for completeness score calculation
161#[derive(Debug, Clone)]
162pub struct CompletenessWeights {
163    pub version: f32,
164    pub purl: f32,
165    pub cpe: f32,
166    pub supplier: f32,
167    pub hashes: f32,
168    pub licenses: f32,
169    pub creator_info: f32,
170    pub serial_number: f32,
171}
172
173impl Default for CompletenessWeights {
174    fn default() -> Self {
175        Self {
176            version: 1.0,
177            purl: 1.5, // Higher weight for PURL
178            cpe: 0.5,  // Lower weight, nice to have
179            supplier: 1.0,
180            hashes: 1.0,
181            licenses: 1.2, // Important for compliance
182            creator_info: 0.3,
183            serial_number: 0.2,
184        }
185    }
186}
187
188// ============================================================================
189// Hash quality metrics
190// ============================================================================
191
192/// Hash/integrity quality metrics
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct HashQualityMetrics {
195    /// Components with any hash
196    pub components_with_any_hash: usize,
197    /// Components with at least one strong hash (SHA-256+, SHA-3, BLAKE, Blake3)
198    pub components_with_strong_hash: usize,
199    /// Components with only weak hashes (MD5, SHA-1) and no strong backup
200    pub components_with_weak_only: usize,
201    /// Distribution of hash algorithms across all components
202    pub algorithm_distribution: BTreeMap<String, usize>,
203    /// Total hash entries across all components
204    pub total_hashes: usize,
205    /// Vendor-supplied components — supplier or author set AND a non-synthetic
206    /// canonical identifier (PURL/CPE/SWHID/SWID).
207    /// Tracks how many such "upstream" components exist, used to verify
208    /// CRA prEN 40000-1-3 `[PRE-7-RQ-07-RE]` (carry-through of vendor hashes).
209    pub vendor_components_total: usize,
210    /// Vendor components that carry at least one hash entry.
211    pub vendor_components_with_hash: usize,
212    /// Vendor components that carry at least one strong hash (SHA-256+).
213    pub vendor_components_with_strong_hash: usize,
214}
215
216impl HashQualityMetrics {
217    /// Calculate hash quality metrics from an SBOM
218    #[must_use]
219    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
220        let mut with_any = 0;
221        let mut with_strong = 0;
222        let mut with_weak_only = 0;
223        let mut distribution: BTreeMap<String, usize> = BTreeMap::new();
224        let mut total_hashes = 0;
225        let mut vendor_total = 0;
226        let mut vendor_with_hash = 0;
227        let mut vendor_with_strong = 0;
228
229        for comp in sbom.components.values() {
230            // Vendor-component classification (independent of hash presence)
231            let is_vendor = (comp.supplier.is_some() || comp.author.is_some())
232                && !matches!(
233                    comp.canonical_id.source(),
234                    crate::model::IdSource::Synthetic
235                        | crate::model::IdSource::FormatSpecific
236                        | crate::model::IdSource::NameVersion
237                );
238            if is_vendor {
239                vendor_total += 1;
240            }
241
242            if comp.hashes.is_empty() {
243                continue;
244            }
245            with_any += 1;
246            total_hashes += comp.hashes.len();
247
248            let mut has_strong = false;
249            let mut has_weak = false;
250
251            for hash in &comp.hashes {
252                let label = hash_algorithm_label(&hash.algorithm);
253                *distribution.entry(label).or_insert(0) += 1;
254
255                if is_strong_hash(&hash.algorithm) {
256                    has_strong = true;
257                } else {
258                    has_weak = true;
259                }
260            }
261
262            if has_strong {
263                with_strong += 1;
264            } else if has_weak {
265                with_weak_only += 1;
266            }
267
268            if is_vendor {
269                vendor_with_hash += 1;
270                if has_strong {
271                    vendor_with_strong += 1;
272                }
273            }
274        }
275
276        Self {
277            components_with_any_hash: with_any,
278            components_with_strong_hash: with_strong,
279            components_with_weak_only: with_weak_only,
280            algorithm_distribution: distribution,
281            total_hashes,
282            vendor_components_total: vendor_total,
283            vendor_components_with_hash: vendor_with_hash,
284            vendor_components_with_strong_hash: vendor_with_strong,
285        }
286    }
287
288    /// Vendor-hash coverage (fraction of vendor-supplied components carrying
289    /// at least one hash). Returns `None` when there are no vendor components,
290    /// so the caller can suppress the violation rather than divide by zero.
291    #[must_use]
292    pub fn vendor_hash_coverage(&self) -> Option<f64> {
293        if self.vendor_components_total == 0 {
294            None
295        } else {
296            #[allow(clippy::cast_precision_loss)]
297            Some(self.vendor_components_with_hash as f64 / self.vendor_components_total as f64)
298        }
299    }
300
301    /// Vendor strong-hash coverage (fraction with at least one SHA-256+ hash).
302    #[must_use]
303    pub fn vendor_strong_hash_coverage(&self) -> Option<f64> {
304        if self.vendor_components_total == 0 {
305            None
306        } else {
307            #[allow(clippy::cast_precision_loss)]
308            Some(
309                self.vendor_components_with_strong_hash as f64
310                    / self.vendor_components_total as f64,
311            )
312        }
313    }
314
315    /// Calculate integrity quality score (0-100)
316    ///
317    /// Base 60% for any-hash coverage + 40% bonus for strong-hash coverage,
318    /// with a penalty for weak-only components.
319    #[must_use]
320    pub fn quality_score(&self, total_components: usize) -> f32 {
321        if total_components == 0 {
322            return 0.0;
323        }
324
325        let any_coverage = self.components_with_any_hash as f32 / total_components as f32;
326        let strong_coverage = self.components_with_strong_hash as f32 / total_components as f32;
327        let weak_only_ratio = self.components_with_weak_only as f32 / total_components as f32;
328
329        let base = any_coverage * 60.0;
330        let strong_bonus = strong_coverage * 40.0;
331        let weak_penalty = weak_only_ratio * 10.0;
332
333        (base + strong_bonus - weak_penalty).clamp(0.0, 100.0)
334    }
335}
336
337/// Whether a hash algorithm is considered cryptographically strong
338fn is_strong_hash(algo: &HashAlgorithm) -> bool {
339    matches!(
340        algo,
341        HashAlgorithm::Sha256
342            | HashAlgorithm::Sha384
343            | HashAlgorithm::Sha512
344            | HashAlgorithm::Sha3_256
345            | HashAlgorithm::Sha3_384
346            | HashAlgorithm::Sha3_512
347            | HashAlgorithm::Blake2b256
348            | HashAlgorithm::Blake2b384
349            | HashAlgorithm::Blake2b512
350            | HashAlgorithm::Blake3
351            | HashAlgorithm::Streebog256
352            | HashAlgorithm::Streebog512
353    )
354}
355
356/// Human-readable label for a hash algorithm
357fn hash_algorithm_label(algo: &HashAlgorithm) -> String {
358    match algo {
359        HashAlgorithm::Md5 => "MD5".to_string(),
360        HashAlgorithm::Sha1 => "SHA-1".to_string(),
361        HashAlgorithm::Sha256 => "SHA-256".to_string(),
362        HashAlgorithm::Sha384 => "SHA-384".to_string(),
363        HashAlgorithm::Sha512 => "SHA-512".to_string(),
364        HashAlgorithm::Sha3_256 => "SHA3-256".to_string(),
365        HashAlgorithm::Sha3_384 => "SHA3-384".to_string(),
366        HashAlgorithm::Sha3_512 => "SHA3-512".to_string(),
367        HashAlgorithm::Blake2b256 => "BLAKE2b-256".to_string(),
368        HashAlgorithm::Blake2b384 => "BLAKE2b-384".to_string(),
369        HashAlgorithm::Blake2b512 => "BLAKE2b-512".to_string(),
370        HashAlgorithm::Blake3 => "BLAKE3".to_string(),
371        HashAlgorithm::Streebog256 => "Streebog-256".to_string(),
372        HashAlgorithm::Streebog512 => "Streebog-512".to_string(),
373        HashAlgorithm::Other(s) => s.clone(),
374    }
375}
376
377// ============================================================================
378// Identifier quality metrics
379// ============================================================================
380
381/// Identifier quality metrics
382#[derive(Debug, Clone, Serialize, Deserialize)]
383pub struct IdentifierMetrics {
384    /// Components with valid PURLs
385    pub valid_purls: usize,
386    /// Components with invalid/malformed PURLs
387    pub invalid_purls: usize,
388    /// Components with valid CPEs
389    pub valid_cpes: usize,
390    /// Components with invalid/malformed CPEs
391    pub invalid_cpes: usize,
392    /// Components with SWID tags
393    pub with_swid: usize,
394    /// Unique ecosystems identified
395    pub ecosystems: Vec<String>,
396    /// Components missing all identifiers (only name)
397    pub missing_all_identifiers: usize,
398}
399
400impl IdentifierMetrics {
401    /// Calculate identifier metrics from an SBOM
402    #[must_use]
403    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
404        let mut valid_purls = 0;
405        let mut invalid_purls = 0;
406        let mut valid_cpes = 0;
407        let mut invalid_cpes = 0;
408        let mut with_swid = 0;
409        let mut missing_all = 0;
410        let mut ecosystems = std::collections::HashSet::new();
411
412        for comp in sbom.components.values() {
413            let has_purl = comp.identifiers.purl.is_some();
414            let has_cpe = !comp.identifiers.cpe.is_empty();
415            let has_swid = comp.identifiers.swid.is_some();
416
417            if let Some(ref purl) = comp.identifiers.purl {
418                if is_valid_purl(purl) {
419                    valid_purls += 1;
420                    // Extract ecosystem from PURL
421                    if let Some(eco) = extract_ecosystem_from_purl(purl) {
422                        ecosystems.insert(eco);
423                    }
424                } else {
425                    invalid_purls += 1;
426                }
427            }
428
429            for cpe in &comp.identifiers.cpe {
430                if is_valid_cpe(cpe) {
431                    valid_cpes += 1;
432                } else {
433                    invalid_cpes += 1;
434                }
435            }
436
437            if has_swid {
438                with_swid += 1;
439            }
440
441            if !has_purl && !has_cpe && !has_swid {
442                missing_all += 1;
443            }
444        }
445
446        let mut ecosystem_list: Vec<String> = ecosystems.into_iter().collect();
447        ecosystem_list.sort();
448
449        Self {
450            valid_purls,
451            invalid_purls,
452            valid_cpes,
453            invalid_cpes,
454            with_swid,
455            ecosystems: ecosystem_list,
456            missing_all_identifiers: missing_all,
457        }
458    }
459
460    /// Calculate identifier quality score (0-100)
461    #[must_use]
462    pub fn quality_score(&self, total_components: usize) -> f32 {
463        if total_components == 0 {
464            return 0.0;
465        }
466
467        let with_valid_id = self.valid_purls + self.valid_cpes + self.with_swid;
468        let coverage =
469            (with_valid_id.min(total_components) as f32 / total_components as f32) * 100.0;
470
471        // Penalize invalid identifiers
472        let invalid_count = self.invalid_purls + self.invalid_cpes;
473        let penalty = (invalid_count as f32 / total_components as f32) * 20.0;
474
475        (coverage - penalty).clamp(0.0, 100.0)
476    }
477}
478
479/// License quality metrics
480#[derive(Debug, Clone, Serialize, Deserialize)]
481pub struct LicenseMetrics {
482    /// Components with declared licenses
483    pub with_declared: usize,
484    /// Components with concluded licenses
485    pub with_concluded: usize,
486    /// Components with valid SPDX expressions
487    pub valid_spdx_expressions: usize,
488    /// Components with non-standard license names
489    pub non_standard_licenses: usize,
490    /// Components with NOASSERTION license
491    pub noassertion_count: usize,
492    /// Components with deprecated SPDX license identifiers
493    pub deprecated_licenses: usize,
494    /// Components with restrictive/copyleft licenses (GPL family)
495    pub restrictive_licenses: usize,
496    /// Specific copyleft license identifiers found
497    pub copyleft_license_ids: Vec<String>,
498    /// Unique licenses found
499    pub unique_licenses: Vec<String>,
500}
501
502impl LicenseMetrics {
503    /// Calculate license metrics from an SBOM
504    #[must_use]
505    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
506        let mut with_declared = 0;
507        let mut with_concluded = 0;
508        let mut valid_spdx = 0;
509        let mut non_standard = 0;
510        let mut noassertion = 0;
511        let mut deprecated = 0;
512        let mut restrictive = 0;
513        let mut licenses = HashSet::new();
514        let mut copyleft_ids = HashSet::new();
515
516        for comp in sbom.components.values() {
517            if !comp.licenses.declared.is_empty() {
518                with_declared += 1;
519                for lic in &comp.licenses.declared {
520                    let expr = &lic.expression;
521                    licenses.insert(expr.clone());
522
523                    if expr == "NOASSERTION" {
524                        noassertion += 1;
525                    } else if is_valid_spdx_license(expr) {
526                        valid_spdx += 1;
527                    } else {
528                        non_standard += 1;
529                    }
530
531                    if is_deprecated_spdx_license(expr) {
532                        deprecated += 1;
533                    }
534                    if is_restrictive_license(expr) {
535                        restrictive += 1;
536                        copyleft_ids.insert(expr.clone());
537                    }
538                }
539            }
540
541            if comp.licenses.concluded.is_some() {
542                with_concluded += 1;
543            }
544        }
545
546        let mut license_list: Vec<String> = licenses.into_iter().collect();
547        license_list.sort();
548
549        let mut copyleft_list: Vec<String> = copyleft_ids.into_iter().collect();
550        copyleft_list.sort();
551
552        Self {
553            with_declared,
554            with_concluded,
555            valid_spdx_expressions: valid_spdx,
556            non_standard_licenses: non_standard,
557            noassertion_count: noassertion,
558            deprecated_licenses: deprecated,
559            restrictive_licenses: restrictive,
560            copyleft_license_ids: copyleft_list,
561            unique_licenses: license_list,
562        }
563    }
564
565    /// Calculate license quality score (0-100)
566    #[must_use]
567    pub fn quality_score(&self, total_components: usize) -> f32 {
568        if total_components == 0 {
569            return 0.0;
570        }
571
572        let coverage = (self.with_declared as f32 / total_components as f32) * 60.0;
573
574        // Bonus for SPDX compliance
575        let spdx_ratio = if self.with_declared > 0 {
576            self.valid_spdx_expressions as f32 / self.with_declared as f32
577        } else {
578            0.0
579        };
580        let spdx_bonus = spdx_ratio * 30.0;
581
582        // Penalty for NOASSERTION
583        let noassertion_penalty =
584            (self.noassertion_count as f32 / total_components.max(1) as f32) * 10.0;
585
586        // Penalty for deprecated licenses (2 points each, capped)
587        let deprecated_penalty = (self.deprecated_licenses as f32 * 2.0).min(10.0);
588
589        (coverage + spdx_bonus - noassertion_penalty - deprecated_penalty).clamp(0.0, 100.0)
590    }
591}
592
593/// Vulnerability information quality metrics
594#[derive(Debug, Clone, Serialize, Deserialize)]
595pub struct VulnerabilityMetrics {
596    /// Components with vulnerability information
597    pub components_with_vulns: usize,
598    /// Total vulnerabilities reported
599    pub total_vulnerabilities: usize,
600    /// Vulnerabilities with CVSS scores
601    pub with_cvss: usize,
602    /// Vulnerabilities with CWE information
603    pub with_cwe: usize,
604    /// Vulnerabilities with remediation info
605    pub with_remediation: usize,
606    /// Components with VEX status
607    pub with_vex_status: usize,
608}
609
610impl VulnerabilityMetrics {
611    /// Calculate vulnerability metrics from an SBOM
612    #[must_use]
613    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
614        let mut components_with_vulns = 0;
615        let mut total_vulns = 0;
616        let mut with_cvss = 0;
617        let mut with_cwe = 0;
618        let mut with_remediation = 0;
619        let mut with_vex = 0;
620
621        for comp in sbom.components.values() {
622            if !comp.vulnerabilities.is_empty() {
623                components_with_vulns += 1;
624            }
625
626            for vuln in &comp.vulnerabilities {
627                total_vulns += 1;
628
629                if !vuln.cvss.is_empty() {
630                    with_cvss += 1;
631                }
632                if !vuln.cwes.is_empty() {
633                    with_cwe += 1;
634                }
635                if vuln.remediation.is_some() {
636                    with_remediation += 1;
637                }
638            }
639
640            if comp.vex_status.is_some()
641                || comp.vulnerabilities.iter().any(|v| v.vex_status.is_some())
642            {
643                with_vex += 1;
644            }
645        }
646
647        Self {
648            components_with_vulns,
649            total_vulnerabilities: total_vulns,
650            with_cvss,
651            with_cwe,
652            with_remediation,
653            with_vex_status: with_vex,
654        }
655    }
656
657    /// Calculate vulnerability documentation quality score (0-100)
658    ///
659    /// Returns `None` when no vulnerability data exists, signaling that this
660    /// category should be excluded from the weighted score (N/A-aware).
661    /// This prevents inflating the overall score when vulnerability assessment
662    /// was not performed.
663    #[must_use]
664    pub fn documentation_score(&self) -> Option<f32> {
665        if self.total_vulnerabilities == 0 {
666            return None; // No vulnerability data — treat as N/A
667        }
668
669        let cvss_ratio = self.with_cvss as f32 / self.total_vulnerabilities as f32;
670        let cwe_ratio = self.with_cwe as f32 / self.total_vulnerabilities as f32;
671        let remediation_ratio = self.with_remediation as f32 / self.total_vulnerabilities as f32;
672
673        Some(
674            remediation_ratio
675                .mul_add(30.0, cvss_ratio.mul_add(40.0, cwe_ratio * 30.0))
676                .min(100.0),
677        )
678    }
679}
680
681// ============================================================================
682// Dependency graph quality metrics
683// ============================================================================
684
685/// Maximum edge count before skipping expensive graph analysis
686const MAX_EDGES_FOR_GRAPH_ANALYSIS: usize = 1_000_000;
687
688// ============================================================================
689// Software complexity index
690// ============================================================================
691
692/// Complexity level bands for the software complexity index
693#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
694#[non_exhaustive]
695pub enum ComplexityLevel {
696    /// Simplicity 75–100 (raw complexity 0–0.25)
697    Low,
698    /// Simplicity 50–74 (raw complexity 0.26–0.50)
699    Moderate,
700    /// Simplicity 25–49 (raw complexity 0.51–0.75)
701    High,
702    /// Simplicity 0–24 (raw complexity 0.76–1.00)
703    VeryHigh,
704}
705
706impl ComplexityLevel {
707    /// Determine complexity level from a simplicity score (0–100)
708    #[must_use]
709    pub const fn from_score(simplicity: f32) -> Self {
710        match simplicity as u32 {
711            75..=100 => Self::Low,
712            50..=74 => Self::Moderate,
713            25..=49 => Self::High,
714            _ => Self::VeryHigh,
715        }
716    }
717
718    /// Human-readable label
719    #[must_use]
720    pub const fn label(&self) -> &'static str {
721        match self {
722            Self::Low => "Low",
723            Self::Moderate => "Moderate",
724            Self::High => "High",
725            Self::VeryHigh => "Very High",
726        }
727    }
728}
729
730impl std::fmt::Display for ComplexityLevel {
731    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
732        f.write_str(self.label())
733    }
734}
735
736/// Breakdown of the five factors that compose the software complexity index.
737/// Each factor is normalized to 0.0–1.0 where higher = more complex.
738#[derive(Debug, Clone, Serialize, Deserialize)]
739pub struct ComplexityFactors {
740    /// Log-scaled edge density: `min(1.0, ln(1 + edges/components) / ln(20))`
741    pub dependency_volume: f32,
742    /// Depth ratio: `min(1.0, max_depth / 15.0)`
743    pub normalized_depth: f32,
744    /// Hub dominance: `min(1.0, max_out_degree / max(components * 0.25, 4))`
745    pub fanout_concentration: f32,
746    /// Cycle density: `min(1.0, cycle_count / max(1, components * 0.05))`
747    pub cycle_ratio: f32,
748    /// Extra disconnected subgraphs: `(islands - 1) / max(1, components - 1)`
749    pub fragmentation: f32,
750}
751
752/// Dependency graph quality metrics
753#[derive(Debug, Clone, Serialize, Deserialize)]
754pub struct DependencyMetrics {
755    /// Total dependency relationships
756    pub total_dependencies: usize,
757    /// Components with at least one dependency
758    pub components_with_deps: usize,
759    /// Maximum dependency depth (computed via BFS from roots)
760    pub max_depth: Option<usize>,
761    /// Average dependency depth across all reachable components
762    pub avg_depth: Option<f32>,
763    /// Orphan components (no incoming or outgoing deps)
764    pub orphan_components: usize,
765    /// Root components (no incoming deps, but has outgoing)
766    pub root_components: usize,
767    /// Number of dependency cycles detected (SCCs with more than one node, plus self-loops)
768    pub cycle_count: usize,
769    /// Number of disconnected subgraphs (islands)
770    pub island_count: usize,
771    /// Whether graph analysis was skipped due to size
772    pub graph_analysis_skipped: bool,
773    /// Maximum out-degree (most dependencies from a single component)
774    pub max_out_degree: usize,
775    /// Software complexity index (0–100, higher = simpler). `None` when graph analysis skipped.
776    pub software_complexity_index: Option<f32>,
777    /// Complexity level band. `None` when graph analysis skipped.
778    pub complexity_level: Option<ComplexityLevel>,
779    /// Factor breakdown. `None` when graph analysis skipped.
780    pub complexity_factors: Option<ComplexityFactors>,
781}
782
783impl DependencyMetrics {
784    /// Calculate dependency metrics from an SBOM
785    #[must_use]
786    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
787        use crate::model::CanonicalId;
788
789        let total_deps = sbom.edges.len();
790
791        // Build adjacency lists using CanonicalId.value() for string keys
792        let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
793        let mut has_outgoing: HashSet<&str> = HashSet::new();
794        let mut has_incoming: HashSet<&str> = HashSet::new();
795
796        for edge in &sbom.edges {
797            children
798                .entry(edge.from.value())
799                .or_default()
800                .push(edge.to.value());
801            has_outgoing.insert(edge.from.value());
802            has_incoming.insert(edge.to.value());
803        }
804
805        let all_ids: Vec<&str> = sbom.components.keys().map(CanonicalId::value).collect();
806
807        let orphans = all_ids
808            .iter()
809            .filter(|c| !has_outgoing.contains(*c) && !has_incoming.contains(*c))
810            .count();
811
812        let roots: Vec<&str> = has_outgoing
813            .iter()
814            .filter(|c| !has_incoming.contains(*c))
815            .copied()
816            .collect();
817        let root_count = roots.len();
818
819        // Compute max out-degree (single pass over adjacency, O(V))
820        let max_out_degree = children.values().map(Vec::len).max().unwrap_or(0);
821
822        // Skip expensive graph analysis for very large graphs
823        if total_deps > MAX_EDGES_FOR_GRAPH_ANALYSIS {
824            return Self {
825                total_dependencies: total_deps,
826                components_with_deps: has_outgoing.len(),
827                max_depth: None,
828                avg_depth: None,
829                orphan_components: orphans,
830                root_components: root_count,
831                cycle_count: 0,
832                island_count: 0,
833                graph_analysis_skipped: true,
834                max_out_degree,
835                software_complexity_index: None,
836                complexity_level: None,
837                complexity_factors: None,
838            };
839        }
840
841        // BFS from roots to compute depth
842        let (max_depth, avg_depth) = compute_depth(&roots, &children);
843
844        // Iterative Tarjan SCC cycle detection
845        let cycle_count = detect_cycles(&all_ids, &children);
846
847        // Union-Find for island/subgraph detection
848        let island_count = count_islands(&all_ids, &sbom.edges);
849
850        // Compute software complexity index
851        let component_count = all_ids.len();
852        let (complexity_index, complexity_lvl, factors) = compute_complexity(
853            total_deps,
854            component_count,
855            max_depth.unwrap_or(0),
856            max_out_degree,
857            cycle_count,
858            orphans,
859            island_count,
860        );
861
862        Self {
863            total_dependencies: total_deps,
864            components_with_deps: has_outgoing.len(),
865            max_depth,
866            avg_depth,
867            orphan_components: orphans,
868            root_components: root_count,
869            cycle_count,
870            island_count,
871            graph_analysis_skipped: false,
872            max_out_degree,
873            software_complexity_index: Some(complexity_index),
874            complexity_level: Some(complexity_lvl),
875            complexity_factors: Some(factors),
876        }
877    }
878
879    /// Calculate dependency graph quality score (0-100)
880    #[must_use]
881    pub fn quality_score(&self, total_components: usize) -> f32 {
882        if total_components == 0 {
883            return 0.0;
884        }
885
886        // Score based on how many components have dependency info
887        let coverage = if total_components > 1 {
888            (self.components_with_deps as f32 / (total_components - 1) as f32) * 100.0
889        } else {
890            100.0 // Single component SBOM
891        };
892
893        // Slight penalty for orphan components
894        let orphan_ratio = self.orphan_components as f32 / total_components as f32;
895        let orphan_penalty = orphan_ratio * 10.0;
896
897        // Penalty for cycles (5 points each, capped at 20)
898        let cycle_penalty = (self.cycle_count as f32 * 5.0).min(20.0);
899
900        // Penalty for excessive islands (>3 in multi-component SBOMs)
901        let island_penalty = if total_components > 5 && self.island_count > 3 {
902            ((self.island_count - 3) as f32 * 3.0).min(15.0)
903        } else {
904            0.0
905        };
906
907        (coverage - orphan_penalty - cycle_penalty - island_penalty).clamp(0.0, 100.0)
908    }
909}
910
911/// BFS from roots to compute max and average depth
912fn compute_depth(
913    roots: &[&str],
914    children: &HashMap<&str, Vec<&str>>,
915) -> (Option<usize>, Option<f32>) {
916    use std::collections::VecDeque;
917
918    if roots.is_empty() {
919        return (None, None);
920    }
921
922    let mut visited: HashSet<&str> = HashSet::new();
923    let mut queue: VecDeque<(&str, usize)> = VecDeque::new();
924    let mut max_d: usize = 0;
925    let mut total_depth: usize = 0;
926    let mut count: usize = 0;
927
928    for &root in roots {
929        if visited.insert(root) {
930            queue.push_back((root, 0));
931        }
932    }
933
934    while let Some((node, depth)) = queue.pop_front() {
935        max_d = max_d.max(depth);
936        total_depth += depth;
937        count += 1;
938
939        if let Some(kids) = children.get(node) {
940            for &kid in kids {
941                if visited.insert(kid) {
942                    queue.push_back((kid, depth + 1));
943                }
944            }
945        }
946    }
947
948    let avg = if count > 0 {
949        Some(total_depth as f32 / count as f32)
950    } else {
951        None
952    };
953
954    (Some(max_d), avg)
955}
956
957/// Iterative Tarjan SCC-based cycle detection.
958///
959/// Counts each strongly connected component with more than one node as a
960/// single cycle, plus single-node components with a self-loop. Uses explicit
961/// stacks instead of recursion so arbitrarily deep graphs cannot overflow
962/// the call stack.
963fn detect_cycles(all_nodes: &[&str], children: &HashMap<&str, Vec<&str>>) -> usize {
964    let mut index_of: HashMap<&str, usize> = HashMap::with_capacity(all_nodes.len());
965    for &node in all_nodes {
966        let next = index_of.len();
967        index_of.entry(node).or_insert(next);
968    }
969    for (&from, kids) in children {
970        let next = index_of.len();
971        index_of.entry(from).or_insert(next);
972        for &kid in kids {
973            let next = index_of.len();
974            index_of.entry(kid).or_insert(next);
975        }
976    }
977
978    let node_count = index_of.len();
979    let mut adjacency: Vec<Vec<usize>> = vec![Vec::new(); node_count];
980    let mut has_self_loop = vec![false; node_count];
981    for (from, kids) in children {
982        let from_idx = index_of[from];
983        for kid in kids {
984            let kid_idx = index_of[kid];
985            if from_idx == kid_idx {
986                has_self_loop[from_idx] = true;
987            }
988            adjacency[from_idx].push(kid_idx);
989        }
990    }
991
992    const UNVISITED: usize = usize::MAX;
993    let mut order = vec![UNVISITED; node_count];
994    let mut lowlink = vec![0usize; node_count];
995    let mut on_stack = vec![false; node_count];
996    let mut scc_stack: Vec<usize> = Vec::new();
997    let mut call_stack: Vec<(usize, usize)> = Vec::new();
998    let mut next_order = 0usize;
999    let mut cycles = 0usize;
1000
1001    for start in 0..node_count {
1002        if order[start] != UNVISITED {
1003            continue;
1004        }
1005        call_stack.push((start, 0));
1006        while let Some(frame) = call_stack.last_mut() {
1007            let node = frame.0;
1008            if frame.1 == 0 {
1009                order[node] = next_order;
1010                lowlink[node] = next_order;
1011                next_order += 1;
1012                scc_stack.push(node);
1013                on_stack[node] = true;
1014            }
1015            if let Some(&target) = adjacency[node].get(frame.1) {
1016                frame.1 += 1;
1017                if order[target] == UNVISITED {
1018                    call_stack.push((target, 0));
1019                } else if on_stack[target] {
1020                    lowlink[node] = lowlink[node].min(order[target]);
1021                }
1022            } else {
1023                call_stack.pop();
1024                if let Some(&(parent, _)) = call_stack.last() {
1025                    lowlink[parent] = lowlink[parent].min(lowlink[node]);
1026                }
1027                if lowlink[node] == order[node] {
1028                    let mut scc_size = 0usize;
1029                    while let Some(member) = scc_stack.pop() {
1030                        on_stack[member] = false;
1031                        scc_size += 1;
1032                        if member == node {
1033                            break;
1034                        }
1035                    }
1036                    if scc_size > 1 || has_self_loop[node] {
1037                        cycles += 1;
1038                    }
1039                }
1040            }
1041        }
1042    }
1043
1044    cycles
1045}
1046
1047/// Union-Find to count disconnected subgraphs (islands)
1048fn count_islands(all_nodes: &[&str], edges: &[crate::model::DependencyEdge]) -> usize {
1049    if all_nodes.is_empty() {
1050        return 0;
1051    }
1052
1053    // Map node IDs to indices
1054    let node_idx: HashMap<&str, usize> =
1055        all_nodes.iter().enumerate().map(|(i, &n)| (n, i)).collect();
1056
1057    let mut parent: Vec<usize> = (0..all_nodes.len()).collect();
1058    let mut rank: Vec<u8> = vec![0; all_nodes.len()];
1059
1060    fn find(parent: &mut Vec<usize>, x: usize) -> usize {
1061        if parent[x] != x {
1062            parent[x] = find(parent, parent[x]); // path compression
1063        }
1064        parent[x]
1065    }
1066
1067    fn union(parent: &mut Vec<usize>, rank: &mut [u8], a: usize, b: usize) {
1068        let ra = find(parent, a);
1069        let rb = find(parent, b);
1070        if ra != rb {
1071            if rank[ra] < rank[rb] {
1072                parent[ra] = rb;
1073            } else if rank[ra] > rank[rb] {
1074                parent[rb] = ra;
1075            } else {
1076                parent[rb] = ra;
1077                rank[ra] += 1;
1078            }
1079        }
1080    }
1081
1082    for edge in edges {
1083        if let (Some(&a), Some(&b)) = (
1084            node_idx.get(edge.from.value()),
1085            node_idx.get(edge.to.value()),
1086        ) {
1087            union(&mut parent, &mut rank, a, b);
1088        }
1089    }
1090
1091    // Count unique roots
1092    let mut roots = HashSet::new();
1093    for i in 0..all_nodes.len() {
1094        roots.insert(find(&mut parent, i));
1095    }
1096
1097    roots.len()
1098}
1099
1100/// Compute the software complexity index and factor breakdown.
1101///
1102/// Returns `(simplicity_index, complexity_level, factors)`.
1103/// `simplicity_index` is 0–100 where 100 = simplest.
1104fn compute_complexity(
1105    edges: usize,
1106    components: usize,
1107    max_depth: usize,
1108    max_out_degree: usize,
1109    cycle_count: usize,
1110    _orphans: usize,
1111    islands: usize,
1112) -> (f32, ComplexityLevel, ComplexityFactors) {
1113    if components == 0 {
1114        let factors = ComplexityFactors {
1115            dependency_volume: 0.0,
1116            normalized_depth: 0.0,
1117            fanout_concentration: 0.0,
1118            cycle_ratio: 0.0,
1119            fragmentation: 0.0,
1120        };
1121        return (100.0, ComplexityLevel::Low, factors);
1122    }
1123
1124    // Factor 1: dependency volume — log-scaled edge density
1125    let edge_ratio = edges as f64 / components as f64;
1126    let dependency_volume = ((1.0 + edge_ratio).ln() / 20.0_f64.ln()).min(1.0) as f32;
1127
1128    // Factor 2: normalized depth
1129    let normalized_depth = (max_depth as f32 / 15.0).min(1.0);
1130
1131    // Factor 3: fanout concentration — hub dominance
1132    // Floor of 4.0 prevents small graphs from being penalized for max_out_degree of 1
1133    let fanout_denom = (components as f32 * 0.25).max(4.0);
1134    let fanout_concentration = (max_out_degree as f32 / fanout_denom).min(1.0);
1135
1136    // Factor 4: cycle ratio
1137    let cycle_threshold = (components as f32 * 0.05).max(1.0);
1138    let cycle_ratio = (cycle_count as f32 / cycle_threshold).min(1.0);
1139
1140    // Factor 5: fragmentation — extra disconnected subgraphs beyond the ideal of 1
1141    // Uses (islands - 1) because orphans are already counted as individual islands.
1142    let extra_islands = islands.saturating_sub(1);
1143    let fragmentation = if components > 1 {
1144        (extra_islands as f32 / (components - 1) as f32).min(1.0)
1145    } else {
1146        0.0
1147    };
1148
1149    let factors = ComplexityFactors {
1150        dependency_volume,
1151        normalized_depth,
1152        fanout_concentration,
1153        cycle_ratio,
1154        fragmentation,
1155    };
1156
1157    let raw_complexity = 0.30 * dependency_volume
1158        + 0.20 * normalized_depth
1159        + 0.20 * fanout_concentration
1160        + 0.20 * cycle_ratio
1161        + 0.10 * fragmentation;
1162
1163    let simplicity_index = (100.0 - raw_complexity * 100.0).clamp(0.0, 100.0);
1164    let level = ComplexityLevel::from_score(simplicity_index);
1165
1166    (simplicity_index, level, factors)
1167}
1168
1169// ============================================================================
1170// Provenance metrics
1171// ============================================================================
1172
1173/// Document provenance and authorship quality metrics
1174#[derive(Debug, Clone, Serialize, Deserialize)]
1175pub struct ProvenanceMetrics {
1176    /// Whether the SBOM was created by an identified tool
1177    pub has_tool_creator: bool,
1178    /// Whether the tool creator includes version information
1179    pub has_tool_version: bool,
1180    /// Whether an organization is identified as creator
1181    pub has_org_creator: bool,
1182    /// Whether any creator has a contact email
1183    pub has_contact_email: bool,
1184    /// Whether the document has a serial number / namespace
1185    pub has_serial_number: bool,
1186    /// Whether the document has a name
1187    pub has_document_name: bool,
1188    /// Age of the SBOM in days (since creation timestamp)
1189    pub timestamp_age_days: u32,
1190    /// Whether the SBOM is considered fresh (< 90 days old)
1191    pub is_fresh: bool,
1192    /// Whether a primary/described component is identified
1193    pub has_primary_component: bool,
1194    /// SBOM lifecycle phase (from CycloneDX 1.5+ metadata)
1195    pub lifecycle_phase: Option<String>,
1196    /// Self-declared completeness level of the SBOM
1197    pub completeness_declaration: CompletenessDeclaration,
1198    /// Whether the SBOM has a digital signature
1199    pub has_signature: bool,
1200    /// Whether the SBOM has data provenance citations (CycloneDX 1.7+)
1201    pub has_citations: bool,
1202    /// Number of data provenance citations
1203    pub citations_count: usize,
1204}
1205
1206/// Freshness threshold in days
1207const FRESHNESS_THRESHOLD_DAYS: u32 = 90;
1208
1209impl ProvenanceMetrics {
1210    /// Calculate provenance metrics from an SBOM
1211    #[must_use]
1212    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
1213        let doc = &sbom.document;
1214
1215        let has_tool_creator = doc
1216            .creators
1217            .iter()
1218            .any(|c| c.creator_type == CreatorType::Tool);
1219        let has_tool_version = doc.creators.iter().any(|c| {
1220            c.creator_type == CreatorType::Tool
1221                && (c.name.contains(' ') || c.name.contains('/') || c.name.contains('@'))
1222        });
1223        let has_org_creator = doc
1224            .creators
1225            .iter()
1226            .any(|c| c.creator_type == CreatorType::Organization);
1227        let has_contact_email = doc.creators.iter().any(|c| c.email.is_some());
1228
1229        let age_days = (chrono::Utc::now() - doc.created).num_days().max(0) as u32;
1230
1231        Self {
1232            has_tool_creator,
1233            has_tool_version,
1234            has_org_creator,
1235            has_contact_email,
1236            has_serial_number: doc.serial_number.is_some(),
1237            has_document_name: doc.name.is_some(),
1238            timestamp_age_days: age_days,
1239            is_fresh: age_days < FRESHNESS_THRESHOLD_DAYS,
1240            has_primary_component: sbom.primary_component_id.is_some(),
1241            lifecycle_phase: doc.lifecycle_phase.clone(),
1242            completeness_declaration: doc.completeness_declaration.clone(),
1243            has_signature: doc.signature.is_some(),
1244            has_citations: doc.citations_count > 0,
1245            citations_count: doc.citations_count,
1246        }
1247    }
1248
1249    /// Calculate provenance quality score (0-100)
1250    ///
1251    /// Weighted checklist: tool creator (15%), tool version (5%), org creator (12%),
1252    /// contact email (8%), serial number (8%), document name (5%), freshness (12%),
1253    /// primary component (12%), completeness declaration (8%), signature (5%),
1254    /// lifecycle phase (10% CDX-only).
1255    #[must_use]
1256    pub fn quality_score(&self, is_cyclonedx: bool) -> f32 {
1257        let mut score = 0.0;
1258        let mut total_weight = 0.0;
1259
1260        let completeness_declared =
1261            self.completeness_declaration != CompletenessDeclaration::Unknown;
1262
1263        let checks: &[(bool, f32)] = &[
1264            (self.has_tool_creator, 15.0),
1265            (self.has_tool_version, 5.0),
1266            (self.has_org_creator, 12.0),
1267            (self.has_contact_email, 8.0),
1268            (self.has_serial_number, 8.0),
1269            (self.has_document_name, 5.0),
1270            (self.is_fresh, 12.0),
1271            (self.has_primary_component, 12.0),
1272            (completeness_declared, 8.0),
1273            (self.has_signature, 5.0),
1274        ];
1275
1276        for &(present, weight) in checks {
1277            if present {
1278                score += weight;
1279            }
1280            total_weight += weight;
1281        }
1282
1283        // Lifecycle phase: only applicable for CycloneDX 1.5+
1284        if is_cyclonedx {
1285            let weight = 10.0;
1286            if self.lifecycle_phase.is_some() {
1287                score += weight;
1288            }
1289            total_weight += weight;
1290
1291            // Data provenance citations bonus (CycloneDX 1.7+)
1292            let citations_weight = 5.0;
1293            if self.has_citations {
1294                score += citations_weight;
1295            }
1296            total_weight += citations_weight;
1297        }
1298
1299        if total_weight > 0.0 {
1300            (score / total_weight) * 100.0
1301        } else {
1302            0.0
1303        }
1304    }
1305}
1306
1307// ============================================================================
1308// Auditability metrics
1309// ============================================================================
1310
1311/// External reference and auditability quality metrics
1312#[derive(Debug, Clone, Serialize, Deserialize)]
1313pub struct AuditabilityMetrics {
1314    /// Components with VCS (version control) references
1315    pub components_with_vcs: usize,
1316    /// Components with website references
1317    pub components_with_website: usize,
1318    /// Components with security advisory references
1319    pub components_with_advisories: usize,
1320    /// Components with any external reference
1321    pub components_with_any_external_ref: usize,
1322    /// Whether the document has a security contact
1323    pub has_security_contact: bool,
1324    /// Whether the document has a vulnerability disclosure URL
1325    pub has_vuln_disclosure_url: bool,
1326}
1327
1328impl AuditabilityMetrics {
1329    /// Calculate auditability metrics from an SBOM
1330    #[must_use]
1331    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
1332        let mut with_vcs = 0;
1333        let mut with_website = 0;
1334        let mut with_advisories = 0;
1335        let mut with_any = 0;
1336
1337        for comp in sbom.components.values() {
1338            if comp.external_refs.is_empty() {
1339                continue;
1340            }
1341            with_any += 1;
1342
1343            let has_vcs = comp
1344                .external_refs
1345                .iter()
1346                .any(|r| r.ref_type == ExternalRefType::Vcs);
1347            let has_website = comp
1348                .external_refs
1349                .iter()
1350                .any(|r| r.ref_type == ExternalRefType::Website);
1351            let has_advisories = comp
1352                .external_refs
1353                .iter()
1354                .any(|r| r.ref_type == ExternalRefType::Advisories);
1355
1356            if has_vcs {
1357                with_vcs += 1;
1358            }
1359            if has_website {
1360                with_website += 1;
1361            }
1362            if has_advisories {
1363                with_advisories += 1;
1364            }
1365        }
1366
1367        Self {
1368            components_with_vcs: with_vcs,
1369            components_with_website: with_website,
1370            components_with_advisories: with_advisories,
1371            components_with_any_external_ref: with_any,
1372            has_security_contact: sbom.document.security_contact.is_some(),
1373            has_vuln_disclosure_url: sbom.document.vulnerability_disclosure_url.is_some(),
1374        }
1375    }
1376
1377    /// Calculate auditability quality score (0-100)
1378    ///
1379    /// Component-level coverage (60%) + document-level security metadata (40%).
1380    #[must_use]
1381    pub fn quality_score(&self, total_components: usize) -> f32 {
1382        if total_components == 0 {
1383            return 0.0;
1384        }
1385
1386        // Component-level: external ref coverage
1387        let ref_coverage =
1388            (self.components_with_any_external_ref as f32 / total_components as f32) * 40.0;
1389        let vcs_coverage = (self.components_with_vcs as f32 / total_components as f32) * 20.0;
1390
1391        // Document-level security metadata
1392        let security_contact_score = if self.has_security_contact { 20.0 } else { 0.0 };
1393        let disclosure_score = if self.has_vuln_disclosure_url {
1394            20.0
1395        } else {
1396            0.0
1397        };
1398
1399        (ref_coverage + vcs_coverage + security_contact_score + disclosure_score).min(100.0)
1400    }
1401}
1402
1403// ============================================================================
1404// Lifecycle metrics
1405// ============================================================================
1406
1407/// Component lifecycle quality metrics (requires enrichment data)
1408#[derive(Debug, Clone, Serialize, Deserialize)]
1409pub struct LifecycleMetrics {
1410    /// Components that have reached end-of-life
1411    pub eol_components: usize,
1412    /// Components classified as stale (no updates for 1+ years)
1413    pub stale_components: usize,
1414    /// Components explicitly marked as deprecated
1415    pub deprecated_components: usize,
1416    /// Components with archived repositories
1417    pub archived_components: usize,
1418    /// Components with a newer version available
1419    pub outdated_components: usize,
1420    /// Components that had lifecycle enrichment data
1421    pub enriched_components: usize,
1422    /// Enrichment coverage percentage (0-100)
1423    pub enrichment_coverage: f32,
1424}
1425
1426impl LifecycleMetrics {
1427    /// Calculate lifecycle metrics from an SBOM
1428    ///
1429    /// These metrics are only meaningful after enrichment. When
1430    /// `enrichment_coverage == 0`, the lifecycle score should be
1431    /// treated as N/A and excluded from the weighted total.
1432    #[must_use]
1433    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
1434        let total = sbom.components.len();
1435        let mut eol = 0;
1436        let mut stale = 0;
1437        let mut deprecated = 0;
1438        let mut archived = 0;
1439        let mut outdated = 0;
1440        let mut enriched = 0;
1441
1442        for comp in sbom.components.values() {
1443            let has_lifecycle_data = comp.eol.is_some() || comp.staleness.is_some();
1444            if has_lifecycle_data {
1445                enriched += 1;
1446            }
1447
1448            if let Some(ref eol_info) = comp.eol
1449                && eol_info.status == EolStatus::EndOfLife
1450            {
1451                eol += 1;
1452            }
1453
1454            if let Some(ref stale_info) = comp.staleness {
1455                match stale_info.level {
1456                    StalenessLevel::Stale | StalenessLevel::Abandoned => stale += 1,
1457                    StalenessLevel::Deprecated => deprecated += 1,
1458                    StalenessLevel::Archived => archived += 1,
1459                    _ => {}
1460                }
1461                if stale_info.is_deprecated {
1462                    deprecated += 1;
1463                }
1464                if stale_info.is_archived {
1465                    archived += 1;
1466                }
1467                if stale_info.latest_version.is_some() {
1468                    outdated += 1;
1469                }
1470            }
1471        }
1472
1473        let coverage = if total > 0 {
1474            (enriched as f32 / total as f32) * 100.0
1475        } else {
1476            0.0
1477        };
1478
1479        Self {
1480            eol_components: eol,
1481            stale_components: stale,
1482            deprecated_components: deprecated,
1483            archived_components: archived,
1484            outdated_components: outdated,
1485            enriched_components: enriched,
1486            enrichment_coverage: coverage,
1487        }
1488    }
1489
1490    /// Whether enrichment data is available for scoring
1491    #[must_use]
1492    pub fn has_data(&self) -> bool {
1493        self.enriched_components > 0
1494    }
1495
1496    /// Calculate lifecycle quality score (0-100)
1497    ///
1498    /// Starts at 100, subtracts penalties for problematic components.
1499    /// Returns `None` if no enrichment data is available.
1500    #[must_use]
1501    pub fn quality_score(&self) -> Option<f32> {
1502        if !self.has_data() {
1503            return None;
1504        }
1505
1506        let mut score = 100.0_f32;
1507
1508        // EOL: severe penalty (15 points each, capped at 60)
1509        score -= (self.eol_components as f32 * 15.0).min(60.0);
1510        // Stale: moderate penalty (5 points each, capped at 30)
1511        score -= (self.stale_components as f32 * 5.0).min(30.0);
1512        // Deprecated/archived: moderate penalty (3 points each, capped at 20)
1513        score -= ((self.deprecated_components + self.archived_components) as f32 * 3.0).min(20.0);
1514        // Outdated: mild penalty (1 point each, capped at 10)
1515        score -= (self.outdated_components as f32 * 1.0).min(10.0);
1516
1517        Some(score.clamp(0.0, 100.0))
1518    }
1519}
1520
1521// ============================================================================
1522// Cryptography Metrics
1523// ============================================================================
1524
1525/// Cryptographic asset metrics for quantum readiness and crypto hygiene assessment.
1526///
1527/// Computed from components with `component_type == Cryptographic` and
1528/// populated `crypto_properties`. Returns `None` for quality score when
1529/// no crypto components are present (N/A-aware).
1530#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1531pub struct CryptographyMetrics {
1532    /// Total number of cryptographic-asset components
1533    pub total_crypto_components: usize,
1534    /// Number of algorithm assets
1535    pub algorithms_count: usize,
1536    /// Number of certificate assets
1537    pub certificates_count: usize,
1538    /// Number of key material assets
1539    pub keys_count: usize,
1540    /// Number of protocol assets
1541    pub protocols_count: usize,
1542    /// Algorithms with `nistQuantumSecurityLevel > 0`
1543    pub quantum_safe_count: usize,
1544    /// Algorithms with `nistQuantumSecurityLevel == 0`
1545    pub quantum_vulnerable_count: usize,
1546    /// Algorithms flagged as weak/broken (MD5, SHA-1, DES, etc.)
1547    pub weak_algorithm_count: usize,
1548    /// Hybrid PQC combiner algorithms
1549    pub hybrid_pqc_count: usize,
1550    /// Certificates past `notValidAfter`
1551    pub expired_certificates: usize,
1552    /// Certificates expiring within 90 days
1553    pub expiring_soon_certificates: usize,
1554    /// Key material in `compromised` state
1555    pub compromised_keys: usize,
1556    /// Symmetric keys < 128 bits or asymmetric keys below recommended minimum
1557    pub inadequate_key_sizes: usize,
1558    /// Names of weak/broken algorithms found
1559    pub weak_algorithm_names: Vec<String>,
1560
1561    // --- Algorithm completeness (slot 1: Crpt) ---
1562    /// Algorithms with an OID identifier
1563    pub algorithms_with_oid: usize,
1564    /// Algorithms with `algorithm_family` set
1565    pub algorithms_with_family: usize,
1566    /// Algorithms with a recognized primitive (not `Other`)
1567    pub algorithms_with_primitive: usize,
1568    /// Algorithms with classical or quantum security level set
1569    pub algorithms_with_security_level: usize,
1570
1571    // --- Cross-reference resolution (slot 4: Refs) ---
1572    /// Certificates with `signature_algorithm_ref` set
1573    pub certs_with_signature_algo_ref: usize,
1574    /// Keys with `algorithm_ref` set
1575    pub keys_with_algorithm_ref: usize,
1576    /// Protocols with at least one cipher suite
1577    pub protocols_with_cipher_suites: usize,
1578
1579    // --- Key lifecycle (slot 5: Life) ---
1580    /// Keys with `state` tracked
1581    pub keys_with_state: usize,
1582    /// Keys with `secured_by` protection
1583    pub keys_with_protection: usize,
1584    /// Keys with `creation_date` or `activation_date`
1585    pub keys_with_lifecycle_dates: usize,
1586
1587    // --- Certificate health (slot 5: Life) ---
1588    /// Certificates with both `not_valid_before` and `not_valid_after`
1589    pub certs_with_validity_dates: usize,
1590}
1591
1592impl CryptographyMetrics {
1593    /// Compute cryptography metrics from an SBOM.
1594    #[must_use]
1595    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
1596        let mut m = Self::default();
1597
1598        for comp in sbom.components.values() {
1599            if comp.component_type != ComponentType::Cryptographic {
1600                continue;
1601            }
1602            m.total_crypto_components += 1;
1603
1604            let Some(cp) = &comp.crypto_properties else {
1605                continue;
1606            };
1607
1608            match cp.asset_type {
1609                CryptoAssetType::Algorithm => {
1610                    m.algorithms_count += 1;
1611                    if cp.oid.is_some() {
1612                        m.algorithms_with_oid += 1;
1613                    }
1614                    if let Some(algo) = &cp.algorithm_properties {
1615                        if algo.algorithm_family.is_some() {
1616                            m.algorithms_with_family += 1;
1617                        }
1618                        if !matches!(algo.primitive, CryptoPrimitive::Other(_)) {
1619                            m.algorithms_with_primitive += 1;
1620                        }
1621                        if algo.classical_security_level.is_some()
1622                            || algo.nist_quantum_security_level.is_some()
1623                        {
1624                            m.algorithms_with_security_level += 1;
1625                        }
1626                        if algo.is_quantum_safe() {
1627                            m.quantum_safe_count += 1;
1628                        } else if algo.nist_quantum_security_level == Some(0) {
1629                            m.quantum_vulnerable_count += 1;
1630                        }
1631                        if algo.is_weak_by_name(&comp.name) {
1632                            m.weak_algorithm_count += 1;
1633                            m.weak_algorithm_names.push(comp.name.clone());
1634                        }
1635                        if algo.is_hybrid_pqc() {
1636                            m.hybrid_pqc_count += 1;
1637                        }
1638                    }
1639                }
1640                CryptoAssetType::Certificate => {
1641                    m.certificates_count += 1;
1642                    if let Some(cert) = &cp.certificate_properties {
1643                        if cert.not_valid_before.is_some() && cert.not_valid_after.is_some() {
1644                            m.certs_with_validity_dates += 1;
1645                        }
1646                        if cert.signature_algorithm_ref.is_some() {
1647                            m.certs_with_signature_algo_ref += 1;
1648                        }
1649                        if cert.is_expired() {
1650                            m.expired_certificates += 1;
1651                        } else if cert.is_expiring_soon(90) {
1652                            m.expiring_soon_certificates += 1;
1653                        }
1654                    }
1655                }
1656                CryptoAssetType::RelatedCryptoMaterial => {
1657                    m.keys_count += 1;
1658                    if let Some(mat) = &cp.related_crypto_material_properties {
1659                        if mat.state.is_some() {
1660                            m.keys_with_state += 1;
1661                        }
1662                        if mat.secured_by.is_some() {
1663                            m.keys_with_protection += 1;
1664                        }
1665                        if mat.creation_date.is_some() || mat.activation_date.is_some() {
1666                            m.keys_with_lifecycle_dates += 1;
1667                        }
1668                        if mat.algorithm_ref.is_some() {
1669                            m.keys_with_algorithm_ref += 1;
1670                        }
1671                        if mat.state == Some(CryptoMaterialState::Compromised) {
1672                            m.compromised_keys += 1;
1673                        }
1674                        // Flag inadequate key sizes
1675                        if let Some(size) = mat.size {
1676                            let is_symmetric = matches!(
1677                                mat.material_type,
1678                                crate::model::CryptoMaterialType::SymmetricKey
1679                                    | crate::model::CryptoMaterialType::SecretKey
1680                            );
1681                            if (is_symmetric && size < 128) || (!is_symmetric && size < 2048) {
1682                                m.inadequate_key_sizes += 1;
1683                            }
1684                        }
1685                    }
1686                }
1687                CryptoAssetType::Protocol => {
1688                    m.protocols_count += 1;
1689                    if let Some(proto) = &cp.protocol_properties
1690                        && !proto.cipher_suites.is_empty()
1691                    {
1692                        m.protocols_with_cipher_suites += 1;
1693                    }
1694                }
1695                _ => {}
1696            }
1697        }
1698
1699        m
1700    }
1701
1702    /// Whether any crypto components exist (i.e., CBOM data is present).
1703    #[must_use]
1704    pub fn has_data(&self) -> bool {
1705        self.total_crypto_components > 0
1706    }
1707
1708    /// Percentage of algorithms that are quantum-safe (0-100).
1709    /// Returns 100 if no algorithms are present.
1710    #[must_use]
1711    pub fn quantum_readiness_score(&self) -> f32 {
1712        if self.algorithms_count == 0 {
1713            return 100.0;
1714        }
1715        (self.quantum_safe_count as f32 / self.algorithms_count as f32) * 100.0
1716    }
1717
1718    /// Quality score (0-100) based on crypto hygiene. Returns `None` if no crypto data.
1719    #[must_use]
1720    pub fn quality_score(&self) -> Option<f32> {
1721        if !self.has_data() {
1722            return None;
1723        }
1724
1725        let mut score = 100.0_f32;
1726
1727        // Weak algorithms: severe penalty (15 each, capped at 50)
1728        score -= (self.weak_algorithm_count as f32 * 15.0).min(50.0);
1729        // Quantum-vulnerable: moderate penalty (8 each, capped at 40)
1730        score -= (self.quantum_vulnerable_count as f32 * 8.0).min(40.0);
1731        // Expired certs: moderate penalty (10 each, capped at 30)
1732        score -= (self.expired_certificates as f32 * 10.0).min(30.0);
1733        // Compromised keys: severe penalty (20 each, capped at 40)
1734        score -= (self.compromised_keys as f32 * 20.0).min(40.0);
1735        // Inadequate key sizes: mild penalty (5 each, capped at 20)
1736        score -= (self.inadequate_key_sizes as f32 * 5.0).min(20.0);
1737        // Expiring-soon certs: mild penalty (3 each, capped at 15)
1738        score -= (self.expiring_soon_certificates as f32 * 3.0).min(15.0);
1739        // Hybrid PQC bonus: +2 each (capped at +10)
1740        score += (self.hybrid_pqc_count as f32 * 2.0).min(10.0);
1741
1742        Some(score.clamp(0.0, 100.0))
1743    }
1744
1745    // ----- Per-category scores for CBOM ScoringProfile -----
1746
1747    /// Crypto completeness: how fully documented are the crypto assets?
1748    #[must_use]
1749    pub fn crypto_completeness_score(&self) -> f32 {
1750        if self.algorithms_count == 0 {
1751            return 100.0;
1752        }
1753        let family_pct = self.algorithms_with_family as f32 / self.algorithms_count as f32;
1754        let primitive_pct = self.algorithms_with_primitive as f32 / self.algorithms_count as f32;
1755        let level_pct = self.algorithms_with_security_level as f32 / self.algorithms_count as f32;
1756        (family_pct * 40.0 + primitive_pct * 30.0 + level_pct * 30.0).clamp(0.0, 100.0)
1757    }
1758
1759    /// Crypto identifier quality: OID coverage.
1760    #[must_use]
1761    pub fn crypto_identifier_score(&self) -> f32 {
1762        if self.algorithms_count == 0 {
1763            return 100.0;
1764        }
1765        let oid_pct = self.algorithms_with_oid as f32 / self.algorithms_count as f32;
1766        (oid_pct * 100.0).clamp(0.0, 100.0)
1767    }
1768
1769    /// Algorithm strength: penalizes broken/weak/quantum-vulnerable algorithms.
1770    #[must_use]
1771    pub fn algorithm_strength_score(&self) -> f32 {
1772        if self.algorithms_count == 0 {
1773            return 100.0;
1774        }
1775        let mut score = 100.0_f32;
1776        score -= (self.weak_algorithm_count as f32 * 15.0).min(60.0);
1777        score -= (self.inadequate_key_sizes as f32 * 8.0).min(30.0);
1778        if self.algorithms_count > 0 {
1779            let vuln_pct = self.quantum_vulnerable_count as f32 / self.algorithms_count as f32;
1780            score -= vuln_pct * 30.0;
1781        }
1782        score.clamp(0.0, 100.0)
1783    }
1784
1785    /// Crypto dependency references: how well are cert/key/protocol -> algorithm refs resolved?
1786    #[must_use]
1787    pub fn crypto_dependency_score(&self) -> f32 {
1788        let linkable = self.certificates_count + self.keys_count + self.protocols_count;
1789        if linkable == 0 {
1790            return 100.0;
1791        }
1792        let resolved = self.certs_with_signature_algo_ref
1793            + self.keys_with_algorithm_ref
1794            + self.protocols_with_cipher_suites;
1795        let pct = resolved as f32 / linkable as f32;
1796        (pct * 100.0).clamp(0.0, 100.0)
1797    }
1798
1799    /// Crypto lifecycle: merged key management + certificate health.
1800    #[must_use]
1801    pub fn crypto_lifecycle_score(&self) -> f32 {
1802        let mut score = 100.0_f32;
1803
1804        if self.keys_count > 0 {
1805            let state_pct = self.keys_with_state as f32 / self.keys_count as f32;
1806            let protection_pct = self.keys_with_protection as f32 / self.keys_count as f32;
1807            let lifecycle_pct = self.keys_with_lifecycle_dates as f32 / self.keys_count as f32;
1808            let key_completeness =
1809                (state_pct * 0.4 + protection_pct * 0.3 + lifecycle_pct * 0.3) * 100.0;
1810            score = score * 0.5 + key_completeness * 0.5;
1811            score -= (self.compromised_keys as f32 * 20.0).min(40.0);
1812            score -= (self.inadequate_key_sizes as f32 * 5.0).min(20.0);
1813        }
1814
1815        if self.certificates_count > 0 {
1816            let validity_pct =
1817                self.certs_with_validity_dates as f32 / self.certificates_count as f32;
1818            score -= (1.0 - validity_pct) * 15.0;
1819            score -= (self.expired_certificates as f32 * 15.0).min(45.0);
1820            score -= (self.expiring_soon_certificates as f32 * 5.0).min(20.0);
1821        }
1822
1823        score.clamp(0.0, 100.0)
1824    }
1825
1826    /// PQC readiness: quantum migration preparedness.
1827    #[must_use]
1828    pub fn pqc_readiness_score(&self) -> f32 {
1829        if self.algorithms_count == 0 {
1830            return 100.0;
1831        }
1832        let mut score = 0.0_f32;
1833        let qs_pct = self.quantum_safe_count as f32 / self.algorithms_count as f32;
1834        score += qs_pct * 60.0;
1835        if self.hybrid_pqc_count > 0 {
1836            score += 15.0;
1837        }
1838        if self.weak_algorithm_count == 0 {
1839            score += 25.0;
1840        } else {
1841            score += (25.0 - self.weak_algorithm_count as f32 * 5.0).max(0.0);
1842        }
1843        score.clamp(0.0, 100.0)
1844    }
1845
1846    /// Percentage of algorithms that are quantum-safe (for overview display).
1847    #[must_use]
1848    pub fn quantum_readiness_pct(&self) -> f32 {
1849        if self.algorithms_count == 0 {
1850            return 0.0;
1851        }
1852        (self.quantum_safe_count as f32 / self.algorithms_count as f32) * 100.0
1853    }
1854
1855    /// Category labels for CBOM quality chart.
1856    #[must_use]
1857    pub const fn cbom_category_labels() -> [&'static str; 8] {
1858        ["Crpt", "OIDs", "Algo", "Refs", "Life", "PQC", "Prov", "Lic"]
1859    }
1860}
1861
1862// ============================================================================
1863// Helper functions
1864// ============================================================================
1865
1866fn is_valid_purl(purl: &str) -> bool {
1867    // Basic PURL validation: pkg:type/namespace/name@version
1868    purl.starts_with("pkg:") && purl.contains('/')
1869}
1870
1871fn extract_ecosystem_from_purl(purl: &str) -> Option<String> {
1872    // Extract type from pkg:type/...
1873    if let Some(rest) = purl.strip_prefix("pkg:")
1874        && let Some(slash_idx) = rest.find('/')
1875    {
1876        return Some(rest[..slash_idx].to_string());
1877    }
1878    None
1879}
1880
1881fn is_valid_cpe(cpe: &str) -> bool {
1882    // Basic CPE validation
1883    cpe.starts_with("cpe:2.3:") || cpe.starts_with("cpe:/")
1884}
1885
1886fn is_valid_spdx_license(expr: &str) -> bool {
1887    // Common SPDX license identifiers
1888    const COMMON_SPDX: &[&str] = &[
1889        "MIT",
1890        "Apache-2.0",
1891        "GPL-2.0",
1892        "GPL-3.0",
1893        "BSD-2-Clause",
1894        "BSD-3-Clause",
1895        "ISC",
1896        "MPL-2.0",
1897        "LGPL-2.1",
1898        "LGPL-3.0",
1899        "AGPL-3.0",
1900        "Unlicense",
1901        "CC0-1.0",
1902        "0BSD",
1903        "EPL-2.0",
1904        "CDDL-1.0",
1905        "Artistic-2.0",
1906        "GPL-2.0-only",
1907        "GPL-2.0-or-later",
1908        "GPL-3.0-only",
1909        "GPL-3.0-or-later",
1910        "LGPL-2.1-only",
1911        "LGPL-2.1-or-later",
1912        "LGPL-3.0-only",
1913        "LGPL-3.0-or-later",
1914    ];
1915
1916    // Check for common licenses or expressions
1917    let trimmed = expr.trim();
1918    COMMON_SPDX.contains(&trimmed)
1919        || trimmed.contains(" AND ")
1920        || trimmed.contains(" OR ")
1921        || trimmed.contains(" WITH ")
1922}
1923
1924/// Whether a license identifier is on the SPDX deprecated list.
1925///
1926/// These are license IDs that SPDX has deprecated in favor of more specific
1927/// identifiers (e.g., `GPL-2.0` → `GPL-2.0-only` or `GPL-2.0-or-later`).
1928fn is_deprecated_spdx_license(expr: &str) -> bool {
1929    const DEPRECATED: &[&str] = &[
1930        "GPL-2.0",
1931        "GPL-2.0+",
1932        "GPL-3.0",
1933        "GPL-3.0+",
1934        "LGPL-2.0",
1935        "LGPL-2.0+",
1936        "LGPL-2.1",
1937        "LGPL-2.1+",
1938        "LGPL-3.0",
1939        "LGPL-3.0+",
1940        "AGPL-1.0",
1941        "AGPL-3.0",
1942        "GFDL-1.1",
1943        "GFDL-1.2",
1944        "GFDL-1.3",
1945        "BSD-2-Clause-FreeBSD",
1946        "BSD-2-Clause-NetBSD",
1947        "eCos-2.0",
1948        "Nunit",
1949        "StandardML-NJ",
1950        "wxWindows",
1951    ];
1952    let trimmed = expr.trim();
1953    DEPRECATED.contains(&trimmed)
1954}
1955
1956/// Whether a license is considered restrictive/copyleft (GPL family).
1957///
1958/// This is informational — restrictive licenses are not inherently a quality
1959/// issue, but organizations need to know about them for compliance.
1960fn is_restrictive_license(expr: &str) -> bool {
1961    let trimmed = expr.trim().to_uppercase();
1962    trimmed.starts_with("GPL")
1963        || trimmed.starts_with("LGPL")
1964        || trimmed.starts_with("AGPL")
1965        || trimmed.starts_with("EUPL")
1966        || trimmed.starts_with("SSPL")
1967        || trimmed.starts_with("OSL")
1968        || trimmed.starts_with("CPAL")
1969        || trimmed.starts_with("CC-BY-SA")
1970        || trimmed.starts_with("CC-BY-NC")
1971}
1972
1973#[cfg(test)]
1974mod tests {
1975    use super::*;
1976
1977    #[test]
1978    fn test_purl_validation() {
1979        assert!(is_valid_purl("pkg:npm/@scope/name@1.0.0"));
1980        assert!(is_valid_purl("pkg:maven/group/artifact@1.0"));
1981        assert!(!is_valid_purl("npm:something"));
1982        assert!(!is_valid_purl("invalid"));
1983    }
1984
1985    #[test]
1986    fn test_cpe_validation() {
1987        assert!(is_valid_cpe("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*"));
1988        assert!(is_valid_cpe("cpe:/a:vendor:product:1.0"));
1989        assert!(!is_valid_cpe("something:else"));
1990    }
1991
1992    #[test]
1993    fn test_spdx_license_validation() {
1994        assert!(is_valid_spdx_license("MIT"));
1995        assert!(is_valid_spdx_license("Apache-2.0"));
1996        assert!(is_valid_spdx_license("MIT AND Apache-2.0"));
1997        assert!(is_valid_spdx_license("GPL-2.0 OR MIT"));
1998    }
1999
2000    #[test]
2001    fn test_strong_hash_classification() {
2002        assert!(is_strong_hash(&HashAlgorithm::Sha256));
2003        assert!(is_strong_hash(&HashAlgorithm::Sha3_256));
2004        assert!(is_strong_hash(&HashAlgorithm::Blake3));
2005        assert!(!is_strong_hash(&HashAlgorithm::Md5));
2006        assert!(!is_strong_hash(&HashAlgorithm::Sha1));
2007        assert!(!is_strong_hash(&HashAlgorithm::Other("custom".to_string())));
2008    }
2009
2010    #[test]
2011    fn test_deprecated_license_detection() {
2012        assert!(is_deprecated_spdx_license("GPL-2.0"));
2013        assert!(is_deprecated_spdx_license("LGPL-2.1"));
2014        assert!(is_deprecated_spdx_license("AGPL-3.0"));
2015        assert!(!is_deprecated_spdx_license("GPL-2.0-only"));
2016        assert!(!is_deprecated_spdx_license("MIT"));
2017        assert!(!is_deprecated_spdx_license("Apache-2.0"));
2018    }
2019
2020    #[test]
2021    fn test_restrictive_license_detection() {
2022        assert!(is_restrictive_license("GPL-3.0-only"));
2023        assert!(is_restrictive_license("LGPL-2.1-or-later"));
2024        assert!(is_restrictive_license("AGPL-3.0-only"));
2025        assert!(is_restrictive_license("EUPL-1.2"));
2026        assert!(is_restrictive_license("CC-BY-SA-4.0"));
2027        assert!(!is_restrictive_license("MIT"));
2028        assert!(!is_restrictive_license("Apache-2.0"));
2029        assert!(!is_restrictive_license("BSD-3-Clause"));
2030    }
2031
2032    #[test]
2033    fn test_hash_quality_score_no_components() {
2034        let metrics = HashQualityMetrics {
2035            components_with_any_hash: 0,
2036            components_with_strong_hash: 0,
2037            components_with_weak_only: 0,
2038            algorithm_distribution: BTreeMap::new(),
2039            total_hashes: 0,
2040            vendor_components_total: 0,
2041            vendor_components_with_hash: 0,
2042            vendor_components_with_strong_hash: 0,
2043        };
2044        assert_eq!(metrics.quality_score(0), 0.0);
2045    }
2046
2047    #[test]
2048    fn test_hash_quality_score_all_strong() {
2049        let metrics = HashQualityMetrics {
2050            components_with_any_hash: 10,
2051            components_with_strong_hash: 10,
2052            components_with_weak_only: 0,
2053            algorithm_distribution: BTreeMap::new(),
2054            total_hashes: 10,
2055            vendor_components_total: 0,
2056            vendor_components_with_hash: 0,
2057            vendor_components_with_strong_hash: 0,
2058        };
2059        assert_eq!(metrics.quality_score(10), 100.0);
2060    }
2061
2062    #[test]
2063    fn test_hash_quality_score_weak_only_penalty() {
2064        let metrics = HashQualityMetrics {
2065            components_with_any_hash: 10,
2066            components_with_strong_hash: 0,
2067            components_with_weak_only: 10,
2068            algorithm_distribution: BTreeMap::new(),
2069            total_hashes: 10,
2070            vendor_components_total: 0,
2071            vendor_components_with_hash: 0,
2072            vendor_components_with_strong_hash: 0,
2073        };
2074        // 60 (any) + 0 (strong) - 10 (weak penalty) = 50
2075        assert_eq!(metrics.quality_score(10), 50.0);
2076    }
2077
2078    #[test]
2079    fn test_lifecycle_no_enrichment_returns_none() {
2080        let metrics = LifecycleMetrics {
2081            eol_components: 0,
2082            stale_components: 0,
2083            deprecated_components: 0,
2084            archived_components: 0,
2085            outdated_components: 0,
2086            enriched_components: 0,
2087            enrichment_coverage: 0.0,
2088        };
2089        assert!(!metrics.has_data());
2090        assert!(metrics.quality_score().is_none());
2091    }
2092
2093    #[test]
2094    fn test_lifecycle_with_eol_penalty() {
2095        let metrics = LifecycleMetrics {
2096            eol_components: 2,
2097            stale_components: 0,
2098            deprecated_components: 0,
2099            archived_components: 0,
2100            outdated_components: 0,
2101            enriched_components: 10,
2102            enrichment_coverage: 100.0,
2103        };
2104        // 100 - 30 (2 * 15) = 70
2105        assert_eq!(metrics.quality_score(), Some(70.0));
2106    }
2107
2108    #[test]
2109    fn test_cycle_detection_no_cycles() {
2110        let children: HashMap<&str, Vec<&str>> =
2111            HashMap::from([("a", vec!["b"]), ("b", vec!["c"])]);
2112        let all_nodes = vec!["a", "b", "c"];
2113        // Linear chain has no SCC with more than one node
2114        assert_eq!(detect_cycles(&all_nodes, &children), 0);
2115    }
2116
2117    #[test]
2118    fn test_cycle_detection_with_cycle() {
2119        let children: HashMap<&str, Vec<&str>> =
2120            HashMap::from([("a", vec!["b"]), ("b", vec!["c"]), ("c", vec!["a"])]);
2121        let all_nodes = vec!["a", "b", "c"];
2122        // a→b→c→a forms a single 3-node SCC = one cycle
2123        assert_eq!(detect_cycles(&all_nodes, &children), 1);
2124    }
2125
2126    #[test]
2127    fn test_cycle_detection_deep_linear_chain_no_overflow() {
2128        let n = 40_000usize;
2129        let names: Vec<String> = (0..n).map(|i| format!("node-{i}")).collect();
2130        let all_nodes: Vec<&str> = names.iter().map(String::as_str).collect();
2131        let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
2132        for w in all_nodes.windows(2) {
2133            children.entry(w[0]).or_default().push(w[1]);
2134        }
2135        assert_eq!(detect_cycles(&all_nodes, &children), 0);
2136    }
2137
2138    #[test]
2139    fn test_cycle_detection_diamond_dag() {
2140        let children: HashMap<&str, Vec<&str>> =
2141            HashMap::from([("a", vec!["b", "c"]), ("b", vec!["d"]), ("c", vec!["d"])]);
2142        let all_nodes = vec!["a", "b", "c", "d"];
2143        assert_eq!(detect_cycles(&all_nodes, &children), 0);
2144    }
2145
2146    #[test]
2147    fn test_cycle_detection_multi_back_edge_scc_counts_once() {
2148        let children: HashMap<&str, Vec<&str>> = HashMap::from([
2149            ("a", vec!["b", "c"]),
2150            ("b", vec!["a", "c"]),
2151            ("c", vec!["a", "b"]),
2152        ]);
2153        let all_nodes = vec!["a", "b", "c"];
2154        // One SCC with multiple back edges still counts as a single cycle
2155        assert_eq!(detect_cycles(&all_nodes, &children), 1);
2156    }
2157
2158    #[test]
2159    fn test_cycle_detection_self_loop_counts_once() {
2160        let children: HashMap<&str, Vec<&str>> =
2161            HashMap::from([("a", vec!["a", "b"]), ("b", vec!["c"])]);
2162        let all_nodes = vec!["a", "b", "c"];
2163        assert_eq!(detect_cycles(&all_nodes, &children), 1);
2164    }
2165
2166    #[test]
2167    fn test_quality_scorer_deep_chain_end_to_end() {
2168        use crate::model::{Component, DependencyEdge, DependencyType};
2169        use crate::quality::{QualityScorer, ScoringProfile};
2170
2171        let n = 40_000usize;
2172        let mut sbom = NormalizedSbom::default();
2173        let mut ids = Vec::with_capacity(n);
2174        for i in 0..n {
2175            let component = Component::new(format!("node-{i}"), format!("ref-{i}"));
2176            ids.push(component.canonical_id.clone());
2177            sbom.add_component(component);
2178        }
2179        for w in ids.windows(2) {
2180            sbom.add_edge(DependencyEdge::new(
2181                w[0].clone(),
2182                w[1].clone(),
2183                DependencyType::DependsOn,
2184            ));
2185        }
2186
2187        let report = QualityScorer::new(ScoringProfile::Standard).score(&sbom);
2188        assert!(!report.dependency_metrics.graph_analysis_skipped);
2189        assert_eq!(report.dependency_metrics.cycle_count, 0);
2190        assert_eq!(report.dependency_metrics.max_depth, Some(n - 1));
2191    }
2192
2193    #[test]
2194    fn test_depth_computation() {
2195        let children: HashMap<&str, Vec<&str>> =
2196            HashMap::from([("root", vec!["a", "b"]), ("a", vec!["c"])]);
2197        let roots = vec!["root"];
2198        let (max_d, avg_d) = compute_depth(&roots, &children);
2199        assert_eq!(max_d, Some(2)); // root -> a -> c
2200        assert!(avg_d.is_some());
2201    }
2202
2203    #[test]
2204    fn test_depth_empty_roots() {
2205        let children: HashMap<&str, Vec<&str>> = HashMap::new();
2206        let roots: Vec<&str> = vec![];
2207        let (max_d, avg_d) = compute_depth(&roots, &children);
2208        assert_eq!(max_d, None);
2209        assert_eq!(avg_d, None);
2210    }
2211
2212    #[test]
2213    fn test_provenance_quality_score() {
2214        let metrics = ProvenanceMetrics {
2215            has_tool_creator: true,
2216            has_tool_version: true,
2217            has_org_creator: true,
2218            has_contact_email: true,
2219            has_serial_number: true,
2220            has_document_name: true,
2221            timestamp_age_days: 10,
2222            is_fresh: true,
2223            has_primary_component: true,
2224            lifecycle_phase: Some("build".to_string()),
2225            completeness_declaration: CompletenessDeclaration::Complete,
2226            has_signature: true,
2227            has_citations: true,
2228            citations_count: 3,
2229        };
2230        // All checks pass for CycloneDX
2231        assert_eq!(metrics.quality_score(true), 100.0);
2232    }
2233
2234    #[test]
2235    fn test_provenance_score_without_cyclonedx() {
2236        let metrics = ProvenanceMetrics {
2237            has_tool_creator: true,
2238            has_tool_version: true,
2239            has_org_creator: true,
2240            has_contact_email: true,
2241            has_serial_number: true,
2242            has_document_name: true,
2243            timestamp_age_days: 10,
2244            is_fresh: true,
2245            has_primary_component: true,
2246            lifecycle_phase: None,
2247            completeness_declaration: CompletenessDeclaration::Complete,
2248            has_signature: true,
2249            has_citations: false,
2250            citations_count: 0,
2251        };
2252        // Lifecycle phase and citations excluded for non-CDX
2253        assert_eq!(metrics.quality_score(false), 100.0);
2254    }
2255
2256    #[test]
2257    fn test_complexity_empty_graph() {
2258        let (simplicity, level, factors) = compute_complexity(0, 0, 0, 0, 0, 0, 0);
2259        assert_eq!(simplicity, 100.0);
2260        assert_eq!(level, ComplexityLevel::Low);
2261        assert_eq!(factors.dependency_volume, 0.0);
2262    }
2263
2264    #[test]
2265    fn test_complexity_single_node() {
2266        // 1 component, no edges, no cycles, 1 orphan, 1 island
2267        let (simplicity, level, _) = compute_complexity(0, 1, 0, 0, 0, 1, 1);
2268        assert!(
2269            simplicity >= 80.0,
2270            "Single node simplicity {simplicity} should be >= 80"
2271        );
2272        assert_eq!(level, ComplexityLevel::Low);
2273    }
2274
2275    #[test]
2276    fn test_complexity_monotonic_edges() {
2277        // More edges should never increase simplicity
2278        let (s1, _, _) = compute_complexity(5, 10, 2, 3, 0, 1, 1);
2279        let (s2, _, _) = compute_complexity(20, 10, 2, 3, 0, 1, 1);
2280        assert!(
2281            s2 <= s1,
2282            "More edges should not increase simplicity: {s2} vs {s1}"
2283        );
2284    }
2285
2286    #[test]
2287    fn test_complexity_monotonic_cycles() {
2288        let (s1, _, _) = compute_complexity(10, 10, 2, 3, 0, 1, 1);
2289        let (s2, _, _) = compute_complexity(10, 10, 2, 3, 3, 1, 1);
2290        assert!(
2291            s2 <= s1,
2292            "More cycles should not increase simplicity: {s2} vs {s1}"
2293        );
2294    }
2295
2296    #[test]
2297    fn test_complexity_monotonic_depth() {
2298        let (s1, _, _) = compute_complexity(10, 10, 2, 3, 0, 1, 1);
2299        let (s2, _, _) = compute_complexity(10, 10, 10, 3, 0, 1, 1);
2300        assert!(
2301            s2 <= s1,
2302            "More depth should not increase simplicity: {s2} vs {s1}"
2303        );
2304    }
2305
2306    #[test]
2307    fn test_complexity_graph_skipped() {
2308        // When graph_analysis_skipped, DependencyMetrics should have None complexity fields.
2309        // We test compute_complexity separately; the from_sbom integration handles the None case.
2310        let (simplicity, _, _) = compute_complexity(100, 50, 5, 10, 2, 5, 3);
2311        assert!(simplicity >= 0.0 && simplicity <= 100.0);
2312    }
2313
2314    #[test]
2315    fn test_complexity_level_bands() {
2316        assert_eq!(ComplexityLevel::from_score(100.0), ComplexityLevel::Low);
2317        assert_eq!(ComplexityLevel::from_score(75.0), ComplexityLevel::Low);
2318        assert_eq!(ComplexityLevel::from_score(74.0), ComplexityLevel::Moderate);
2319        assert_eq!(ComplexityLevel::from_score(50.0), ComplexityLevel::Moderate);
2320        assert_eq!(ComplexityLevel::from_score(49.0), ComplexityLevel::High);
2321        assert_eq!(ComplexityLevel::from_score(25.0), ComplexityLevel::High);
2322        assert_eq!(ComplexityLevel::from_score(24.0), ComplexityLevel::VeryHigh);
2323        assert_eq!(ComplexityLevel::from_score(0.0), ComplexityLevel::VeryHigh);
2324    }
2325
2326    #[test]
2327    fn test_completeness_declaration_display() {
2328        assert_eq!(CompletenessDeclaration::Complete.to_string(), "complete");
2329        assert_eq!(
2330            CompletenessDeclaration::IncompleteFirstPartyOnly.to_string(),
2331            "incomplete (first-party only)"
2332        );
2333        assert_eq!(CompletenessDeclaration::Unknown.to_string(), "unknown");
2334    }
2335
2336    // ── CryptographyMetrics scoring tests ──
2337
2338    #[test]
2339    fn crypto_completeness_all_documented() {
2340        let m = CryptographyMetrics {
2341            algorithms_count: 4,
2342            algorithms_with_family: 4,
2343            algorithms_with_primitive: 4,
2344            algorithms_with_security_level: 4,
2345            ..Default::default()
2346        };
2347        let score = m.crypto_completeness_score();
2348        assert!(
2349            (score - 100.0).abs() < 0.1,
2350            "fully documented → 100, got {score}"
2351        );
2352    }
2353
2354    #[test]
2355    fn crypto_completeness_partial() {
2356        let m = CryptographyMetrics {
2357            algorithms_count: 4,
2358            algorithms_with_family: 2,         // 50%
2359            algorithms_with_primitive: 4,      // 100%
2360            algorithms_with_security_level: 0, // 0%
2361            ..Default::default()
2362        };
2363        // 0.5*40 + 1.0*30 + 0.0*30 = 20+30+0 = 50
2364        let score = m.crypto_completeness_score();
2365        assert!((score - 50.0).abs() < 0.1, "partial → 50, got {score}");
2366    }
2367
2368    #[test]
2369    fn crypto_identifier_full_oid_coverage() {
2370        let m = CryptographyMetrics {
2371            algorithms_count: 5,
2372            algorithms_with_oid: 5,
2373            ..Default::default()
2374        };
2375        assert!((m.crypto_identifier_score() - 100.0).abs() < 0.1);
2376    }
2377
2378    #[test]
2379    fn crypto_identifier_no_oids() {
2380        let m = CryptographyMetrics {
2381            algorithms_count: 5,
2382            algorithms_with_oid: 0,
2383            ..Default::default()
2384        };
2385        assert!((m.crypto_identifier_score() - 0.0).abs() < 0.1);
2386    }
2387
2388    #[test]
2389    fn algorithm_strength_weak_penalty() {
2390        let m = CryptographyMetrics {
2391            algorithms_count: 5,
2392            weak_algorithm_count: 2,
2393            ..Default::default()
2394        };
2395        // 100 - 2*15 = 70
2396        let score = m.algorithm_strength_score();
2397        assert!((score - 70.0).abs() < 0.1, "2 weak → 70, got {score}");
2398    }
2399
2400    #[test]
2401    fn algorithm_strength_quantum_vulnerable() {
2402        let m = CryptographyMetrics {
2403            algorithms_count: 10,
2404            quantum_vulnerable_count: 10,
2405            ..Default::default()
2406        };
2407        // 100 - (10/10)*30 = 70
2408        let score = m.algorithm_strength_score();
2409        assert!(
2410            (score - 70.0).abs() < 0.1,
2411            "all quantum vuln → 70, got {score}"
2412        );
2413    }
2414
2415    #[test]
2416    fn crypto_lifecycle_compromised_keys() {
2417        let m = CryptographyMetrics {
2418            keys_count: 3,
2419            keys_with_state: 3,
2420            keys_with_protection: 3,
2421            keys_with_lifecycle_dates: 3,
2422            compromised_keys: 1,
2423            ..Default::default()
2424        };
2425        let score = m.crypto_lifecycle_score();
2426        // With full key completeness: 100*0.5 + 100*0.5 = 100, then -20 penalty
2427        assert!(score < 85.0);
2428        assert!(score > 50.0);
2429    }
2430
2431    #[test]
2432    fn crypto_lifecycle_expired_certs() {
2433        let m = CryptographyMetrics {
2434            certificates_count: 4,
2435            certs_with_validity_dates: 4,
2436            expired_certificates: 2,
2437            expiring_soon_certificates: 1,
2438            ..Default::default()
2439        };
2440        let score = m.crypto_lifecycle_score();
2441        // 100 - 2*15 - 1*5 = 100 - 30 - 5 = 65
2442        assert!(score < 70.0);
2443    }
2444
2445    #[test]
2446    fn pqc_readiness_all_quantum_safe() {
2447        let m = CryptographyMetrics {
2448            algorithms_count: 5,
2449            quantum_safe_count: 5,
2450            hybrid_pqc_count: 2,
2451            weak_algorithm_count: 0,
2452            ..Default::default()
2453        };
2454        // (5/5)*60 + 15 + 25 = 100
2455        let score = m.pqc_readiness_score();
2456        assert!(
2457            (score - 100.0).abs() < 0.1,
2458            "all safe + hybrid → 100, got {score}"
2459        );
2460    }
2461
2462    #[test]
2463    fn pqc_readiness_no_quantum_safe() {
2464        let m = CryptographyMetrics {
2465            algorithms_count: 5,
2466            quantum_safe_count: 0,
2467            hybrid_pqc_count: 0,
2468            weak_algorithm_count: 0,
2469            ..Default::default()
2470        };
2471        // 0*60 + 0 + 25 = 25
2472        let score = m.pqc_readiness_score();
2473        assert!(
2474            (score - 25.0).abs() < 0.1,
2475            "no safe, no weak → 25, got {score}"
2476        );
2477    }
2478
2479    #[test]
2480    fn crypto_dependency_all_resolved() {
2481        let m = CryptographyMetrics {
2482            certificates_count: 2,
2483            keys_count: 3,
2484            protocols_count: 1,
2485            certs_with_signature_algo_ref: 2,
2486            keys_with_algorithm_ref: 3,
2487            protocols_with_cipher_suites: 1,
2488            ..Default::default()
2489        };
2490        assert!((m.crypto_dependency_score() - 100.0).abs() < 0.1);
2491    }
2492
2493    #[test]
2494    fn crypto_dependency_none_resolved() {
2495        let m = CryptographyMetrics {
2496            certificates_count: 2,
2497            keys_count: 3,
2498            protocols_count: 1,
2499            ..Default::default()
2500        };
2501        assert!((m.crypto_dependency_score() - 0.0).abs() < 0.1);
2502    }
2503
2504    #[test]
2505    fn quality_score_none_when_no_crypto() {
2506        let m = CryptographyMetrics::default();
2507        assert!(m.quality_score().is_none());
2508    }
2509
2510    #[test]
2511    fn quantum_readiness_pct_zero_algorithms() {
2512        let m = CryptographyMetrics::default();
2513        assert!((m.quantum_readiness_pct() - 0.0).abs() < 0.01);
2514    }
2515}