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        // File/snippet inventory entries are not packages: NTIA-style
58        // completeness fields (version, supplier, purl, …) do not apply to
59        // them. Counting them cratered scores for file-cataloguing SBOMs
60        // (thousands of files → ~0% version coverage on an otherwise
61        // complete document).
62        let mut countable = 0;
63
64        for comp in sbom.components.values() {
65            if matches!(comp.component_type, crate::model::ComponentType::File) {
66                continue;
67            }
68            countable += 1;
69            if comp.version.is_some() {
70                with_version += 1;
71            }
72            if comp.identifiers.purl.is_some() {
73                with_purl += 1;
74            }
75            if !comp.identifiers.cpe.is_empty() {
76                with_cpe += 1;
77            }
78            if comp.supplier.is_some() {
79                with_supplier += 1;
80            }
81            if !comp.hashes.is_empty() {
82                with_hashes += 1;
83            }
84            // A NOASSERTION entry carries zero license information (the
85            // CycloneDX parser emits declared=["NOASSERTION"] for empty
86            // license objects), so it must not count as "has license".
87            // SPDX NONE *is* information (the author asserts no license
88            // exists) and still counts as documented.
89            let has_license_info = comp
90                .licenses
91                .declared
92                .iter()
93                .any(|l| l.expression != "NOASSERTION")
94                || comp
95                    .licenses
96                    .concluded
97                    .as_ref()
98                    .is_some_and(|c| c.expression != "NOASSERTION");
99            if has_license_info {
100                with_licenses += 1;
101            }
102            if comp.description.is_some() {
103                with_description += 1;
104            }
105        }
106
107        let pct = |count: usize| {
108            if countable == 0 {
109                0.0
110            } else {
111                (count as f32 / countable as f32) * 100.0
112            }
113        };
114
115        Self {
116            components_with_version: pct(with_version),
117            components_with_purl: pct(with_purl),
118            components_with_cpe: pct(with_cpe),
119            components_with_supplier: pct(with_supplier),
120            components_with_hashes: pct(with_hashes),
121            components_with_licenses: pct(with_licenses),
122            components_with_description: pct(with_description),
123            has_creator_info: !sbom.document.creators.is_empty(),
124            // A missing/invalid source timestamp is stored as the epoch
125            // sentinel — report it as absent, not hardcoded true.
126            has_timestamp: sbom.document.has_known_timestamp(),
127            has_serial_number: sbom.document.serial_number.is_some(),
128            total_components: total,
129        }
130    }
131
132    /// Create empty metrics
133    #[must_use]
134    pub const fn empty() -> Self {
135        Self {
136            components_with_version: 0.0,
137            components_with_purl: 0.0,
138            components_with_cpe: 0.0,
139            components_with_supplier: 0.0,
140            components_with_hashes: 0.0,
141            components_with_licenses: 0.0,
142            components_with_description: 0.0,
143            has_creator_info: false,
144            has_timestamp: false,
145            has_serial_number: false,
146            total_components: 0,
147        }
148    }
149
150    /// Calculate overall completeness score (0-100)
151    #[must_use]
152    pub fn overall_score(&self, weights: &CompletenessWeights) -> f32 {
153        let mut score = 0.0;
154        let mut total_weight = 0.0;
155
156        // Component field scores
157        score += self.components_with_version * weights.version;
158        total_weight += weights.version * 100.0;
159
160        score += self.components_with_purl * weights.purl;
161        total_weight += weights.purl * 100.0;
162
163        score += self.components_with_cpe * weights.cpe;
164        total_weight += weights.cpe * 100.0;
165
166        score += self.components_with_supplier * weights.supplier;
167        total_weight += weights.supplier * 100.0;
168
169        score += self.components_with_hashes * weights.hashes;
170        total_weight += weights.hashes * 100.0;
171
172        score += self.components_with_licenses * weights.licenses;
173        total_weight += weights.licenses * 100.0;
174
175        // Document metadata scores
176        if self.has_creator_info {
177            score += 100.0 * weights.creator_info;
178        }
179        total_weight += weights.creator_info * 100.0;
180
181        if self.has_serial_number {
182            score += 100.0 * weights.serial_number;
183        }
184        total_weight += weights.serial_number * 100.0;
185
186        if total_weight > 0.0 {
187            (score / total_weight) * 100.0
188        } else {
189            0.0
190        }
191    }
192}
193
194/// Weights for completeness score calculation
195#[derive(Debug, Clone)]
196pub struct CompletenessWeights {
197    pub version: f32,
198    pub purl: f32,
199    pub cpe: f32,
200    pub supplier: f32,
201    pub hashes: f32,
202    pub licenses: f32,
203    pub creator_info: f32,
204    pub serial_number: f32,
205}
206
207impl Default for CompletenessWeights {
208    fn default() -> Self {
209        Self {
210            version: 1.0,
211            purl: 1.5, // Higher weight for PURL
212            cpe: 0.5,  // Lower weight, nice to have
213            supplier: 1.0,
214            hashes: 1.0,
215            licenses: 1.2, // Important for compliance
216            creator_info: 0.3,
217            serial_number: 0.2,
218        }
219    }
220}
221
222// ============================================================================
223// Hash quality metrics
224// ============================================================================
225
226/// Hash/integrity quality metrics
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct HashQualityMetrics {
229    /// Components with any hash
230    pub components_with_any_hash: usize,
231    /// Components with at least one strong hash (SHA-256+, SHA-3, BLAKE, Blake3)
232    pub components_with_strong_hash: usize,
233    /// Components with only weak hashes (MD5, SHA-1) and no strong backup
234    pub components_with_weak_only: usize,
235    /// Distribution of hash algorithms across all components
236    pub algorithm_distribution: BTreeMap<String, usize>,
237    /// Total hash entries across all components
238    pub total_hashes: usize,
239    /// Vendor-supplied components — supplier or author set AND a non-synthetic
240    /// canonical identifier (PURL/CPE/SWHID/SWID).
241    /// Tracks how many such "upstream" components exist, used to verify
242    /// CRA prEN 40000-1-3 `[PRE-7-RQ-07-RE]` (carry-through of vendor hashes).
243    pub vendor_components_total: usize,
244    /// Vendor components that carry at least one hash entry.
245    pub vendor_components_with_hash: usize,
246    /// Vendor components that carry at least one strong hash (SHA-256+).
247    pub vendor_components_with_strong_hash: usize,
248}
249
250impl HashQualityMetrics {
251    /// Calculate hash quality metrics from an SBOM
252    #[must_use]
253    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
254        let mut with_any = 0;
255        let mut with_strong = 0;
256        let mut with_weak_only = 0;
257        let mut distribution: BTreeMap<String, usize> = BTreeMap::new();
258        let mut total_hashes = 0;
259        let mut vendor_total = 0;
260        let mut vendor_with_hash = 0;
261        let mut vendor_with_strong = 0;
262
263        for comp in sbom.components.values() {
264            // Vendor-component classification (independent of hash presence)
265            let is_vendor = (comp.supplier.is_some() || comp.author.is_some())
266                && !matches!(
267                    comp.canonical_id.source(),
268                    crate::model::IdSource::Synthetic
269                        | crate::model::IdSource::FormatSpecific
270                        | crate::model::IdSource::NameVersion
271                );
272            if is_vendor {
273                vendor_total += 1;
274            }
275
276            if comp.hashes.is_empty() {
277                continue;
278            }
279            with_any += 1;
280            total_hashes += comp.hashes.len();
281
282            let mut has_strong = false;
283            let mut has_weak = false;
284
285            for hash in &comp.hashes {
286                let label = hash_algorithm_label(&hash.algorithm);
287                *distribution.entry(label).or_insert(0) += 1;
288
289                if is_strong_hash(&hash.algorithm) {
290                    has_strong = true;
291                } else {
292                    has_weak = true;
293                }
294            }
295
296            if has_strong {
297                with_strong += 1;
298            } else if has_weak {
299                with_weak_only += 1;
300            }
301
302            if is_vendor {
303                vendor_with_hash += 1;
304                if has_strong {
305                    vendor_with_strong += 1;
306                }
307            }
308        }
309
310        Self {
311            components_with_any_hash: with_any,
312            components_with_strong_hash: with_strong,
313            components_with_weak_only: with_weak_only,
314            algorithm_distribution: distribution,
315            total_hashes,
316            vendor_components_total: vendor_total,
317            vendor_components_with_hash: vendor_with_hash,
318            vendor_components_with_strong_hash: vendor_with_strong,
319        }
320    }
321
322    /// Vendor-hash coverage (fraction of vendor-supplied components carrying
323    /// at least one hash). Returns `None` when there are no vendor components,
324    /// so the caller can suppress the violation rather than divide by zero.
325    #[must_use]
326    pub fn vendor_hash_coverage(&self) -> Option<f64> {
327        if self.vendor_components_total == 0 {
328            None
329        } else {
330            #[allow(clippy::cast_precision_loss)]
331            Some(self.vendor_components_with_hash as f64 / self.vendor_components_total as f64)
332        }
333    }
334
335    /// Vendor strong-hash coverage (fraction with at least one SHA-256+ hash).
336    #[must_use]
337    pub fn vendor_strong_hash_coverage(&self) -> Option<f64> {
338        if self.vendor_components_total == 0 {
339            None
340        } else {
341            #[allow(clippy::cast_precision_loss)]
342            Some(
343                self.vendor_components_with_strong_hash as f64
344                    / self.vendor_components_total as f64,
345            )
346        }
347    }
348
349    /// Calculate integrity quality score (0-100)
350    ///
351    /// Base 60% for any-hash coverage + 40% bonus for strong-hash coverage,
352    /// with a penalty for weak-only components.
353    #[must_use]
354    pub fn quality_score(&self, total_components: usize) -> f32 {
355        if total_components == 0 {
356            return 0.0;
357        }
358
359        let any_coverage = self.components_with_any_hash as f32 / total_components as f32;
360        let strong_coverage = self.components_with_strong_hash as f32 / total_components as f32;
361        let weak_only_ratio = self.components_with_weak_only as f32 / total_components as f32;
362
363        let base = any_coverage * 60.0;
364        let strong_bonus = strong_coverage * 40.0;
365        let weak_penalty = weak_only_ratio * 10.0;
366
367        (base + strong_bonus - weak_penalty).clamp(0.0, 100.0)
368    }
369}
370
371/// Whether a hash algorithm is considered cryptographically strong
372fn is_strong_hash(algo: &HashAlgorithm) -> bool {
373    matches!(
374        algo,
375        HashAlgorithm::Sha256
376            | HashAlgorithm::Sha384
377            | HashAlgorithm::Sha512
378            | HashAlgorithm::Sha3_256
379            | HashAlgorithm::Sha3_384
380            | HashAlgorithm::Sha3_512
381            | HashAlgorithm::Blake2b256
382            | HashAlgorithm::Blake2b384
383            | HashAlgorithm::Blake2b512
384            | HashAlgorithm::Blake3
385            | HashAlgorithm::Streebog256
386            | HashAlgorithm::Streebog512
387    )
388}
389
390/// Human-readable label for a hash algorithm
391fn hash_algorithm_label(algo: &HashAlgorithm) -> String {
392    match algo {
393        HashAlgorithm::Md5 => "MD5".to_string(),
394        HashAlgorithm::Sha1 => "SHA-1".to_string(),
395        HashAlgorithm::Sha256 => "SHA-256".to_string(),
396        HashAlgorithm::Sha384 => "SHA-384".to_string(),
397        HashAlgorithm::Sha512 => "SHA-512".to_string(),
398        HashAlgorithm::Sha3_256 => "SHA3-256".to_string(),
399        HashAlgorithm::Sha3_384 => "SHA3-384".to_string(),
400        HashAlgorithm::Sha3_512 => "SHA3-512".to_string(),
401        HashAlgorithm::Blake2b256 => "BLAKE2b-256".to_string(),
402        HashAlgorithm::Blake2b384 => "BLAKE2b-384".to_string(),
403        HashAlgorithm::Blake2b512 => "BLAKE2b-512".to_string(),
404        HashAlgorithm::Blake3 => "BLAKE3".to_string(),
405        HashAlgorithm::Streebog256 => "Streebog-256".to_string(),
406        HashAlgorithm::Streebog512 => "Streebog-512".to_string(),
407        HashAlgorithm::Other(s) => s.clone(),
408    }
409}
410
411// ============================================================================
412// Identifier quality metrics
413// ============================================================================
414
415/// Identifier quality metrics
416#[derive(Debug, Clone, Serialize, Deserialize)]
417pub struct IdentifierMetrics {
418    /// Components with valid PURLs
419    pub valid_purls: usize,
420    /// Components with invalid/malformed PURLs
421    pub invalid_purls: usize,
422    /// Components with at least one valid CPE
423    pub valid_cpes: usize,
424    /// Components with at least one invalid/malformed CPE
425    pub invalid_cpes: usize,
426    /// Components with SWID tags
427    pub with_swid: usize,
428    /// Components with at least one valid identifier of any kind. This is the
429    /// coverage numerator: summing the per-type counts would let one
430    /// multi-identifier component mask components with no identifier at all.
431    #[serde(default)]
432    pub components_with_valid_id: usize,
433    /// Unique ecosystems identified
434    pub ecosystems: Vec<String>,
435    /// Components missing all identifiers (only name)
436    pub missing_all_identifiers: usize,
437    /// File-typed inventory entries excluded from per-component counting
438    /// (denominator plumbing for `quality_score`; not part of the report).
439    #[serde(skip)]
440    pub file_components: usize,
441}
442
443impl IdentifierMetrics {
444    /// Calculate identifier metrics from an SBOM
445    #[must_use]
446    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
447        let mut valid_purls = 0;
448        let mut invalid_purls = 0;
449        let mut valid_cpes = 0;
450        let mut invalid_cpes = 0;
451        let mut with_swid = 0;
452        let mut with_valid_id = 0;
453        let mut missing_all = 0;
454        let mut file_components = 0;
455        let mut ecosystems = std::collections::HashSet::new();
456
457        for comp in sbom.components.values() {
458            // File/snippet inventory entries are not packages: they
459            // structurally lack purl/cpe/swid, and counting them cratered
460            // identifier coverage for file-cataloguing SBOMs (same exemption
461            // as CompletenessMetrics).
462            if matches!(comp.component_type, ComponentType::File) {
463                file_components += 1;
464                continue;
465            }
466
467            let has_purl = comp.identifiers.purl.is_some();
468            let has_cpe = !comp.identifiers.cpe.is_empty();
469            let has_swid = comp.identifiers.swid.is_some();
470
471            let mut purl_valid = false;
472            if let Some(ref purl) = comp.identifiers.purl {
473                if is_valid_purl(purl) {
474                    purl_valid = true;
475                    valid_purls += 1;
476                    // Extract ecosystem from PURL
477                    if let Some(eco) = extract_ecosystem_from_purl(purl) {
478                        ecosystems.insert(eco);
479                    }
480                } else {
481                    invalid_purls += 1;
482                }
483            }
484
485            // Per-COMPONENT, not per-entry: a component with several CPEs
486            // counts once, so it cannot mask components with no identifier.
487            let any_cpe_valid = comp.identifiers.cpe.iter().any(|c| is_valid_cpe(c));
488            let any_cpe_invalid = comp.identifiers.cpe.iter().any(|c| !is_valid_cpe(c));
489            if any_cpe_valid {
490                valid_cpes += 1;
491            }
492            if any_cpe_invalid {
493                invalid_cpes += 1;
494            }
495
496            if has_swid {
497                with_swid += 1;
498            }
499
500            if purl_valid || any_cpe_valid || has_swid {
501                with_valid_id += 1;
502            }
503
504            if !has_purl && !has_cpe && !has_swid {
505                missing_all += 1;
506            }
507        }
508
509        let mut ecosystem_list: Vec<String> = ecosystems.into_iter().collect();
510        ecosystem_list.sort();
511
512        Self {
513            valid_purls,
514            invalid_purls,
515            valid_cpes,
516            invalid_cpes,
517            with_swid,
518            components_with_valid_id: with_valid_id,
519            ecosystems: ecosystem_list,
520            missing_all_identifiers: missing_all,
521            file_components,
522        }
523    }
524
525    /// Calculate identifier quality score (0-100)
526    #[must_use]
527    pub fn quality_score(&self, total_components: usize) -> f32 {
528        // File entries are exempt from identifier counting (see from_sbom),
529        // so remove them from the denominator too — otherwise a file
530        // catalogue dilutes package identifier coverage.
531        let countable = total_components.saturating_sub(self.file_components);
532        if countable == 0 {
533            return 0.0;
534        }
535
536        // Coverage over components with at least one valid identifier — NOT
537        // the sum of per-type counts, which a single PURL+CPE+SWID component
538        // would inflate 3x (masking identifier-less components).
539        let coverage =
540            (self.components_with_valid_id.min(countable) as f32 / countable as f32) * 100.0;
541
542        // Penalize invalid identifiers
543        let invalid_count = self.invalid_purls + self.invalid_cpes;
544        let penalty = (invalid_count as f32 / countable as f32) * 20.0;
545
546        (coverage - penalty).clamp(0.0, 100.0)
547    }
548}
549
550/// License quality metrics
551#[derive(Debug, Clone, Serialize, Deserialize)]
552pub struct LicenseMetrics {
553    /// Components with at least one real (non-NOASSERTION) declared license
554    pub with_declared: usize,
555    /// Components with concluded licenses
556    pub with_concluded: usize,
557    /// Components whose declared licenses are all valid SPDX expressions
558    /// (subset of `with_declared`)
559    pub valid_spdx_expressions: usize,
560    /// Components with at least one non-standard declared license name
561    /// (subset of `with_declared`; disjoint with `valid_spdx_expressions`)
562    pub non_standard_licenses: usize,
563    /// Components with at least one NOASSERTION declared entry
564    pub noassertion_count: usize,
565    /// Components with at least one deprecated SPDX license identifier
566    pub deprecated_licenses: usize,
567    /// Components with restrictive/copyleft licenses (GPL family)
568    pub restrictive_licenses: usize,
569    /// Specific copyleft license identifiers found
570    pub copyleft_license_ids: Vec<String>,
571    /// Unique licenses found
572    pub unique_licenses: Vec<String>,
573    /// File-typed inventory entries excluded from per-component counting
574    /// (denominator plumbing for `quality_score`; not part of the report).
575    #[serde(skip)]
576    pub file_components: usize,
577}
578
579impl LicenseMetrics {
580    /// Calculate license metrics from an SBOM
581    #[must_use]
582    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
583        let mut with_declared = 0;
584        let mut with_concluded = 0;
585        let mut valid_spdx = 0;
586        let mut non_standard = 0;
587        let mut noassertion = 0;
588        let mut deprecated = 0;
589        let mut restrictive = 0;
590        let mut file_components = 0;
591        let mut licenses = HashSet::new();
592        let mut copyleft_ids = HashSet::new();
593
594        // All counters are PER-COMPONENT (matching the field docs). The old
595        // per-entry counting let a component with several declared licenses
596        // push valid_spdx_expressions above with_declared, blowing the SPDX
597        // ratio past 1.0 — and a NOASSERTION-only component counted as
598        // license-documented.
599        for comp in sbom.components.values() {
600            // File/snippet inventory entries are not packages: per-file
601            // license facts (e.g. SPDX LicenseInfoInFile) must not dilute
602            // per-component license counting (same exemption as
603            // CompletenessMetrics). Their license strings still feed the
604            // informational unique/copyleft lists below.
605            let is_file = matches!(comp.component_type, ComponentType::File);
606            if is_file {
607                file_components += 1;
608            }
609
610            let mut has_real_entry = false;
611            let mut has_noassertion = false;
612            let mut all_valid = true;
613            let mut any_deprecated = false;
614            let mut any_restrictive = false;
615
616            for lic in &comp.licenses.declared {
617                let expr = &lic.expression;
618                licenses.insert(expr.clone());
619
620                if expr == "NOASSERTION" {
621                    has_noassertion = true;
622                    continue;
623                }
624                has_real_entry = true;
625
626                // is_valid_spdx is computed at construction via the `spdx`
627                // crate (real expression parsing), unlike the old substring
628                // heuristic that accepted any string containing " OR ".
629                if !lic.is_valid_spdx {
630                    all_valid = false;
631                }
632                if is_deprecated_spdx_license(expr) {
633                    any_deprecated = true;
634                }
635                if is_restrictive_license(expr) {
636                    any_restrictive = true;
637                    copyleft_ids.insert(expr.clone());
638                }
639            }
640
641            if is_file {
642                // Exempt from all per-component counters (license strings
643                // were still collected above).
644                continue;
645            }
646
647            if has_noassertion {
648                noassertion += 1;
649            }
650            if has_real_entry {
651                with_declared += 1;
652                if all_valid {
653                    valid_spdx += 1;
654                } else {
655                    non_standard += 1;
656                }
657                if any_deprecated {
658                    deprecated += 1;
659                }
660                if any_restrictive {
661                    restrictive += 1;
662                }
663            }
664
665            if comp.licenses.concluded.is_some() {
666                with_concluded += 1;
667            }
668        }
669
670        let mut license_list: Vec<String> = licenses.into_iter().collect();
671        license_list.sort();
672
673        let mut copyleft_list: Vec<String> = copyleft_ids.into_iter().collect();
674        copyleft_list.sort();
675
676        Self {
677            with_declared,
678            with_concluded,
679            valid_spdx_expressions: valid_spdx,
680            non_standard_licenses: non_standard,
681            noassertion_count: noassertion,
682            deprecated_licenses: deprecated,
683            restrictive_licenses: restrictive,
684            copyleft_license_ids: copyleft_list,
685            unique_licenses: license_list,
686            file_components,
687        }
688    }
689
690    /// Calculate license quality score (0-100)
691    #[must_use]
692    pub fn quality_score(&self, total_components: usize) -> f32 {
693        // File entries are exempt from per-component license counting (see
694        // from_sbom), so remove them from the denominator too — otherwise a
695        // file catalogue dilutes package license coverage.
696        let countable = total_components.saturating_sub(self.file_components);
697        if countable == 0 {
698            return 0.0;
699        }
700
701        let coverage = (self.with_declared as f32 / countable as f32) * 60.0;
702
703        // Bonus for SPDX compliance
704        let spdx_ratio = if self.with_declared > 0 {
705            self.valid_spdx_expressions as f32 / self.with_declared as f32
706        } else {
707            0.0
708        };
709        let spdx_bonus = spdx_ratio * 30.0;
710
711        // Penalty for NOASSERTION
712        let noassertion_penalty = (self.noassertion_count as f32 / countable as f32) * 10.0;
713
714        // Penalty for deprecated licenses (2 points each, capped)
715        let deprecated_penalty = (self.deprecated_licenses as f32 * 2.0).min(10.0);
716
717        (coverage + spdx_bonus - noassertion_penalty - deprecated_penalty).clamp(0.0, 100.0)
718    }
719}
720
721/// Vulnerability information quality metrics
722#[derive(Debug, Clone, Serialize, Deserialize)]
723pub struct VulnerabilityMetrics {
724    /// Components with vulnerability information
725    pub components_with_vulns: usize,
726    /// Total vulnerabilities reported
727    pub total_vulnerabilities: usize,
728    /// Vulnerabilities with CVSS scores
729    pub with_cvss: usize,
730    /// Vulnerabilities with CWE information
731    pub with_cwe: usize,
732    /// Vulnerabilities with remediation info
733    pub with_remediation: usize,
734    /// Components with VEX status
735    pub with_vex_status: usize,
736}
737
738impl VulnerabilityMetrics {
739    /// Calculate vulnerability metrics from an SBOM
740    #[must_use]
741    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
742        let mut components_with_vulns = 0;
743        let mut total_vulns = 0;
744        let mut with_cvss = 0;
745        let mut with_cwe = 0;
746        let mut with_remediation = 0;
747        let mut with_vex = 0;
748
749        for comp in sbom.components.values() {
750            if !comp.vulnerabilities.is_empty() {
751                components_with_vulns += 1;
752            }
753
754            for vuln in &comp.vulnerabilities {
755                total_vulns += 1;
756
757                if !vuln.cvss.is_empty() {
758                    with_cvss += 1;
759                }
760                if !vuln.cwes.is_empty() {
761                    with_cwe += 1;
762                }
763                if vuln.remediation.is_some() {
764                    with_remediation += 1;
765                }
766            }
767
768            if comp.vex_status.is_some()
769                || comp.vulnerabilities.iter().any(|v| v.vex_status.is_some())
770            {
771                with_vex += 1;
772            }
773        }
774
775        Self {
776            components_with_vulns,
777            total_vulnerabilities: total_vulns,
778            with_cvss,
779            with_cwe,
780            with_remediation,
781            with_vex_status: with_vex,
782        }
783    }
784
785    /// Calculate vulnerability documentation quality score (0-100)
786    ///
787    /// Returns `None` when no vulnerability data exists, signaling that this
788    /// category should be excluded from the weighted score (N/A-aware).
789    /// This prevents inflating the overall score when vulnerability assessment
790    /// was not performed.
791    ///
792    /// Disclosing vulnerabilities at all earns a 40-point baseline; the
793    /// remaining 60 points reward per-vulnerability documentation quality
794    /// (CVSS 24, CWE 18, remediation 18). Without the baseline, a bare
795    /// disclosure scored 0 — LOWER than saying nothing (N/A redistributes the
796    /// category weight), which punished transparency: an author was better
797    /// off stripping vulnerability data than disclosing it undocumented.
798    #[must_use]
799    pub fn documentation_score(&self) -> Option<f32> {
800        if self.total_vulnerabilities == 0 {
801            return None; // No vulnerability data — treat as N/A
802        }
803
804        let cvss_ratio = self.with_cvss as f32 / self.total_vulnerabilities as f32;
805        let cwe_ratio = self.with_cwe as f32 / self.total_vulnerabilities as f32;
806        let remediation_ratio = self.with_remediation as f32 / self.total_vulnerabilities as f32;
807
808        let quality = remediation_ratio.mul_add(18.0, cvss_ratio.mul_add(24.0, cwe_ratio * 18.0));
809        Some((40.0 + quality).min(100.0))
810    }
811}
812
813// ============================================================================
814// Dependency graph quality metrics
815// ============================================================================
816
817/// Maximum edge count before skipping expensive graph analysis
818const MAX_EDGES_FOR_GRAPH_ANALYSIS: usize = 1_000_000;
819
820// ============================================================================
821// Software complexity index
822// ============================================================================
823
824/// Complexity level bands for the software complexity index
825#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
826#[non_exhaustive]
827pub enum ComplexityLevel {
828    /// Simplicity 75–100 (raw complexity 0–0.25)
829    Low,
830    /// Simplicity 50–74 (raw complexity 0.26–0.50)
831    Moderate,
832    /// Simplicity 25–49 (raw complexity 0.51–0.75)
833    High,
834    /// Simplicity 0–24 (raw complexity 0.76–1.00)
835    VeryHigh,
836}
837
838impl ComplexityLevel {
839    /// Determine complexity level from a simplicity score (0–100)
840    #[must_use]
841    pub const fn from_score(simplicity: f32) -> Self {
842        match simplicity as u32 {
843            75..=100 => Self::Low,
844            50..=74 => Self::Moderate,
845            25..=49 => Self::High,
846            _ => Self::VeryHigh,
847        }
848    }
849
850    /// Human-readable label
851    #[must_use]
852    pub const fn label(&self) -> &'static str {
853        match self {
854            Self::Low => "Low",
855            Self::Moderate => "Moderate",
856            Self::High => "High",
857            Self::VeryHigh => "Very High",
858        }
859    }
860}
861
862impl std::fmt::Display for ComplexityLevel {
863    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
864        f.write_str(self.label())
865    }
866}
867
868/// Breakdown of the five factors that compose the software complexity index.
869/// Each factor is normalized to 0.0–1.0 where higher = more complex.
870#[derive(Debug, Clone, Serialize, Deserialize)]
871pub struct ComplexityFactors {
872    /// Log-scaled edge density: `min(1.0, ln(1 + edges/components) / ln(20))`
873    pub dependency_volume: f32,
874    /// Depth ratio: `min(1.0, max_depth / 15.0)`
875    pub normalized_depth: f32,
876    /// Hub dominance: `min(1.0, max_out_degree / max(components * 0.25, 4))`
877    pub fanout_concentration: f32,
878    /// Cycle density: `min(1.0, cycle_count / max(1, components * 0.05))`
879    pub cycle_ratio: f32,
880    /// Extra disconnected subgraphs: `(islands - 1) / max(1, components - 1)`
881    pub fragmentation: f32,
882}
883
884/// Dependency graph quality metrics
885#[derive(Debug, Clone, Serialize, Deserialize)]
886pub struct DependencyMetrics {
887    /// Total dependency relationships
888    pub total_dependencies: usize,
889    /// Components with at least one dependency
890    pub components_with_deps: usize,
891    /// Maximum dependency depth (computed via BFS from roots)
892    pub max_depth: Option<usize>,
893    /// Average dependency depth across all reachable components
894    pub avg_depth: Option<f32>,
895    /// Orphan components (no incoming or outgoing deps)
896    pub orphan_components: usize,
897    /// Root components (no incoming deps, but has outgoing)
898    pub root_components: usize,
899    /// Number of dependency cycles detected (SCCs with more than one node, plus self-loops)
900    pub cycle_count: usize,
901    /// Number of disconnected subgraphs (islands)
902    pub island_count: usize,
903    /// Whether graph analysis was skipped due to size
904    pub graph_analysis_skipped: bool,
905    /// Maximum out-degree (most dependencies from a single component)
906    pub max_out_degree: usize,
907    /// Software complexity index (0–100, higher = simpler). `None` when graph analysis skipped.
908    pub software_complexity_index: Option<f32>,
909    /// Complexity level band. `None` when graph analysis skipped.
910    pub complexity_level: Option<ComplexityLevel>,
911    /// Factor breakdown. `None` when graph analysis skipped.
912    pub complexity_factors: Option<ComplexityFactors>,
913    /// File-typed inventory entries excluded from the coverage denominator in
914    /// `quality_score` (denominator plumbing; not part of the report).
915    #[serde(skip)]
916    pub file_components: usize,
917}
918
919impl DependencyMetrics {
920    /// Calculate dependency metrics from an SBOM
921    #[must_use]
922    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
923        use crate::model::CanonicalId;
924
925        let total_deps = sbom.edges.len();
926
927        // File/snippet inventory entries are not dependency-graph members:
928        // nobody "depends on" a file, so they are excluded from the coverage
929        // denominator in quality_score (same exemption as
930        // CompletenessMetrics). Cycle/orphan penalties for real packages are
931        // unchanged.
932        let file_components = sbom
933            .components
934            .values()
935            .filter(|c| matches!(c.component_type, ComponentType::File))
936            .count();
937
938        // Build adjacency lists using CanonicalId.value() for string keys
939        let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
940        let mut has_outgoing: HashSet<&str> = HashSet::new();
941        let mut has_incoming: HashSet<&str> = HashSet::new();
942
943        for edge in &sbom.edges {
944            children
945                .entry(edge.from.value())
946                .or_default()
947                .push(edge.to.value());
948            has_outgoing.insert(edge.from.value());
949            has_incoming.insert(edge.to.value());
950        }
951
952        let all_ids: Vec<&str> = sbom.components.keys().map(CanonicalId::value).collect();
953
954        let orphans = all_ids
955            .iter()
956            .filter(|c| !has_outgoing.contains(*c) && !has_incoming.contains(*c))
957            .count();
958
959        let roots: Vec<&str> = has_outgoing
960            .iter()
961            .filter(|c| !has_incoming.contains(*c))
962            .copied()
963            .collect();
964        let root_count = roots.len();
965
966        // Compute max out-degree (single pass over adjacency, O(V))
967        let max_out_degree = children.values().map(Vec::len).max().unwrap_or(0);
968
969        // Skip expensive graph analysis for very large graphs
970        if total_deps > MAX_EDGES_FOR_GRAPH_ANALYSIS {
971            return Self {
972                total_dependencies: total_deps,
973                components_with_deps: has_outgoing.len(),
974                max_depth: None,
975                avg_depth: None,
976                orphan_components: orphans,
977                root_components: root_count,
978                cycle_count: 0,
979                island_count: 0,
980                graph_analysis_skipped: true,
981                max_out_degree,
982                software_complexity_index: None,
983                complexity_level: None,
984                complexity_factors: None,
985                file_components,
986            };
987        }
988
989        // BFS from roots to compute depth
990        let (max_depth, avg_depth) = compute_depth(&roots, &children);
991
992        // Iterative Tarjan SCC cycle detection
993        let cycle_count = detect_cycles(&all_ids, &children);
994
995        // Union-Find for island/subgraph detection
996        let island_count = count_islands(&all_ids, &sbom.edges);
997
998        // Compute software complexity index
999        let component_count = all_ids.len();
1000        let (complexity_index, complexity_lvl, factors) = compute_complexity(
1001            total_deps,
1002            component_count,
1003            max_depth.unwrap_or(0),
1004            max_out_degree,
1005            cycle_count,
1006            orphans,
1007            island_count,
1008        );
1009
1010        Self {
1011            total_dependencies: total_deps,
1012            components_with_deps: has_outgoing.len(),
1013            max_depth,
1014            avg_depth,
1015            orphan_components: orphans,
1016            root_components: root_count,
1017            cycle_count,
1018            island_count,
1019            graph_analysis_skipped: false,
1020            max_out_degree,
1021            software_complexity_index: Some(complexity_index),
1022            complexity_level: Some(complexity_lvl),
1023            complexity_factors: Some(factors),
1024            file_components,
1025        }
1026    }
1027
1028    /// Calculate dependency graph quality score (0-100)
1029    #[must_use]
1030    pub fn quality_score(&self, total_components: usize) -> f32 {
1031        if total_components == 0 {
1032            return 0.0;
1033        }
1034
1035        // File entries are not dependency-graph members (see from_sbom), so
1036        // they are excluded from the coverage denominator — otherwise a file
1037        // catalogue dilutes package dependency coverage.
1038        let countable = total_components.saturating_sub(self.file_components);
1039
1040        // Score based on how many components have dependency info. Clamp to
1041        // 100 BEFORE subtracting penalties: with an N/(N-1) denominator a
1042        // fully-cyclic graph reaches ~125% coverage, which silently absorbed
1043        // the cycle/orphan penalties below.
1044        let coverage = if countable > 1 {
1045            ((self.components_with_deps as f32 / (countable - 1) as f32) * 100.0).min(100.0)
1046        } else {
1047            100.0 // Single package (or pure file inventory)
1048        };
1049
1050        // Slight penalty for orphan components
1051        let orphan_ratio = self.orphan_components as f32 / total_components as f32;
1052        let orphan_penalty = orphan_ratio * 10.0;
1053
1054        // Penalty for cycles (5 points each, capped at 20)
1055        let cycle_penalty = (self.cycle_count as f32 * 5.0).min(20.0);
1056
1057        // Penalty for excessive islands (>3 in multi-component SBOMs)
1058        let island_penalty = if total_components > 5 && self.island_count > 3 {
1059            ((self.island_count - 3) as f32 * 3.0).min(15.0)
1060        } else {
1061            0.0
1062        };
1063
1064        (coverage - orphan_penalty - cycle_penalty - island_penalty).clamp(0.0, 100.0)
1065    }
1066}
1067
1068/// BFS from roots to compute max and average depth
1069fn compute_depth(
1070    roots: &[&str],
1071    children: &HashMap<&str, Vec<&str>>,
1072) -> (Option<usize>, Option<f32>) {
1073    use std::collections::VecDeque;
1074
1075    if roots.is_empty() {
1076        return (None, None);
1077    }
1078
1079    let mut visited: HashSet<&str> = HashSet::new();
1080    let mut queue: VecDeque<(&str, usize)> = VecDeque::new();
1081    let mut max_d: usize = 0;
1082    let mut total_depth: usize = 0;
1083    let mut count: usize = 0;
1084
1085    for &root in roots {
1086        if visited.insert(root) {
1087            queue.push_back((root, 0));
1088        }
1089    }
1090
1091    while let Some((node, depth)) = queue.pop_front() {
1092        max_d = max_d.max(depth);
1093        total_depth += depth;
1094        count += 1;
1095
1096        if let Some(kids) = children.get(node) {
1097            for &kid in kids {
1098                if visited.insert(kid) {
1099                    queue.push_back((kid, depth + 1));
1100                }
1101            }
1102        }
1103    }
1104
1105    let avg = if count > 0 {
1106        Some(total_depth as f32 / count as f32)
1107    } else {
1108        None
1109    };
1110
1111    (Some(max_d), avg)
1112}
1113
1114/// Iterative Tarjan SCC-based cycle detection.
1115///
1116/// Counts each strongly connected component with more than one node as a
1117/// single cycle, plus single-node components with a self-loop. Uses explicit
1118/// stacks instead of recursion so arbitrarily deep graphs cannot overflow
1119/// the call stack.
1120fn detect_cycles(all_nodes: &[&str], children: &HashMap<&str, Vec<&str>>) -> usize {
1121    let mut index_of: HashMap<&str, usize> = HashMap::with_capacity(all_nodes.len());
1122    for &node in all_nodes {
1123        let next = index_of.len();
1124        index_of.entry(node).or_insert(next);
1125    }
1126    for (&from, kids) in children {
1127        let next = index_of.len();
1128        index_of.entry(from).or_insert(next);
1129        for &kid in kids {
1130            let next = index_of.len();
1131            index_of.entry(kid).or_insert(next);
1132        }
1133    }
1134
1135    let node_count = index_of.len();
1136    let mut adjacency: Vec<Vec<usize>> = vec![Vec::new(); node_count];
1137    let mut has_self_loop = vec![false; node_count];
1138    for (from, kids) in children {
1139        let from_idx = index_of[from];
1140        for kid in kids {
1141            let kid_idx = index_of[kid];
1142            if from_idx == kid_idx {
1143                has_self_loop[from_idx] = true;
1144            }
1145            adjacency[from_idx].push(kid_idx);
1146        }
1147    }
1148
1149    const UNVISITED: usize = usize::MAX;
1150    let mut order = vec![UNVISITED; node_count];
1151    let mut lowlink = vec![0usize; node_count];
1152    let mut on_stack = vec![false; node_count];
1153    let mut scc_stack: Vec<usize> = Vec::new();
1154    let mut call_stack: Vec<(usize, usize)> = Vec::new();
1155    let mut next_order = 0usize;
1156    let mut cycles = 0usize;
1157
1158    for start in 0..node_count {
1159        if order[start] != UNVISITED {
1160            continue;
1161        }
1162        call_stack.push((start, 0));
1163        while let Some(frame) = call_stack.last_mut() {
1164            let node = frame.0;
1165            if frame.1 == 0 {
1166                order[node] = next_order;
1167                lowlink[node] = next_order;
1168                next_order += 1;
1169                scc_stack.push(node);
1170                on_stack[node] = true;
1171            }
1172            if let Some(&target) = adjacency[node].get(frame.1) {
1173                frame.1 += 1;
1174                if order[target] == UNVISITED {
1175                    call_stack.push((target, 0));
1176                } else if on_stack[target] {
1177                    lowlink[node] = lowlink[node].min(order[target]);
1178                }
1179            } else {
1180                call_stack.pop();
1181                if let Some(&(parent, _)) = call_stack.last() {
1182                    lowlink[parent] = lowlink[parent].min(lowlink[node]);
1183                }
1184                if lowlink[node] == order[node] {
1185                    let mut scc_size = 0usize;
1186                    while let Some(member) = scc_stack.pop() {
1187                        on_stack[member] = false;
1188                        scc_size += 1;
1189                        if member == node {
1190                            break;
1191                        }
1192                    }
1193                    if scc_size > 1 || has_self_loop[node] {
1194                        cycles += 1;
1195                    }
1196                }
1197            }
1198        }
1199    }
1200
1201    cycles
1202}
1203
1204/// Union-Find to count disconnected subgraphs (islands)
1205fn count_islands(all_nodes: &[&str], edges: &[crate::model::DependencyEdge]) -> usize {
1206    if all_nodes.is_empty() {
1207        return 0;
1208    }
1209
1210    // Map node IDs to indices
1211    let node_idx: HashMap<&str, usize> =
1212        all_nodes.iter().enumerate().map(|(i, &n)| (n, i)).collect();
1213
1214    let mut parent: Vec<usize> = (0..all_nodes.len()).collect();
1215    let mut rank: Vec<u8> = vec![0; all_nodes.len()];
1216
1217    fn find(parent: &mut Vec<usize>, x: usize) -> usize {
1218        if parent[x] != x {
1219            parent[x] = find(parent, parent[x]); // path compression
1220        }
1221        parent[x]
1222    }
1223
1224    fn union(parent: &mut Vec<usize>, rank: &mut [u8], a: usize, b: usize) {
1225        let ra = find(parent, a);
1226        let rb = find(parent, b);
1227        if ra != rb {
1228            if rank[ra] < rank[rb] {
1229                parent[ra] = rb;
1230            } else if rank[ra] > rank[rb] {
1231                parent[rb] = ra;
1232            } else {
1233                parent[rb] = ra;
1234                rank[ra] += 1;
1235            }
1236        }
1237    }
1238
1239    for edge in edges {
1240        if let (Some(&a), Some(&b)) = (
1241            node_idx.get(edge.from.value()),
1242            node_idx.get(edge.to.value()),
1243        ) {
1244            union(&mut parent, &mut rank, a, b);
1245        }
1246    }
1247
1248    // Count unique roots
1249    let mut roots = HashSet::new();
1250    for i in 0..all_nodes.len() {
1251        roots.insert(find(&mut parent, i));
1252    }
1253
1254    roots.len()
1255}
1256
1257/// Compute the software complexity index and factor breakdown.
1258///
1259/// Returns `(simplicity_index, complexity_level, factors)`.
1260/// `simplicity_index` is 0–100 where 100 = simplest.
1261fn compute_complexity(
1262    edges: usize,
1263    components: usize,
1264    max_depth: usize,
1265    max_out_degree: usize,
1266    cycle_count: usize,
1267    _orphans: usize,
1268    islands: usize,
1269) -> (f32, ComplexityLevel, ComplexityFactors) {
1270    if components == 0 {
1271        let factors = ComplexityFactors {
1272            dependency_volume: 0.0,
1273            normalized_depth: 0.0,
1274            fanout_concentration: 0.0,
1275            cycle_ratio: 0.0,
1276            fragmentation: 0.0,
1277        };
1278        return (100.0, ComplexityLevel::Low, factors);
1279    }
1280
1281    // Factor 1: dependency volume — log-scaled edge density
1282    let edge_ratio = edges as f64 / components as f64;
1283    let dependency_volume = ((1.0 + edge_ratio).ln() / 20.0_f64.ln()).min(1.0) as f32;
1284
1285    // Factor 2: normalized depth
1286    let normalized_depth = (max_depth as f32 / 15.0).min(1.0);
1287
1288    // Factor 3: fanout concentration — hub dominance
1289    // Floor of 4.0 prevents small graphs from being penalized for max_out_degree of 1
1290    let fanout_denom = (components as f32 * 0.25).max(4.0);
1291    let fanout_concentration = (max_out_degree as f32 / fanout_denom).min(1.0);
1292
1293    // Factor 4: cycle ratio
1294    let cycle_threshold = (components as f32 * 0.05).max(1.0);
1295    let cycle_ratio = (cycle_count as f32 / cycle_threshold).min(1.0);
1296
1297    // Factor 5: fragmentation — extra disconnected subgraphs beyond the ideal of 1
1298    // Uses (islands - 1) because orphans are already counted as individual islands.
1299    let extra_islands = islands.saturating_sub(1);
1300    let fragmentation = if components > 1 {
1301        (extra_islands as f32 / (components - 1) as f32).min(1.0)
1302    } else {
1303        0.0
1304    };
1305
1306    let factors = ComplexityFactors {
1307        dependency_volume,
1308        normalized_depth,
1309        fanout_concentration,
1310        cycle_ratio,
1311        fragmentation,
1312    };
1313
1314    let raw_complexity = 0.30 * dependency_volume
1315        + 0.20 * normalized_depth
1316        + 0.20 * fanout_concentration
1317        + 0.20 * cycle_ratio
1318        + 0.10 * fragmentation;
1319
1320    let simplicity_index = (100.0 - raw_complexity * 100.0).clamp(0.0, 100.0);
1321    let level = ComplexityLevel::from_score(simplicity_index);
1322
1323    (simplicity_index, level, factors)
1324}
1325
1326// ============================================================================
1327// Provenance metrics
1328// ============================================================================
1329
1330/// Document provenance and authorship quality metrics
1331#[derive(Debug, Clone, Serialize, Deserialize)]
1332pub struct ProvenanceMetrics {
1333    /// Whether the SBOM was created by an identified tool
1334    pub has_tool_creator: bool,
1335    /// Whether the tool creator includes version information
1336    pub has_tool_version: bool,
1337    /// Whether an organization is identified as creator
1338    pub has_org_creator: bool,
1339    /// Whether any creator has a contact email
1340    pub has_contact_email: bool,
1341    /// Whether the document has a serial number / namespace
1342    pub has_serial_number: bool,
1343    /// Whether the document has a name
1344    pub has_document_name: bool,
1345    /// Age of the SBOM in days (since creation timestamp). Meaningful only
1346    /// when `timestamp_known` is true; a missing timestamp is not "very old".
1347    pub timestamp_age_days: u32,
1348    /// Whether the document carries a real creation timestamp (vs. the epoch
1349    /// sentinel parsers substitute for a missing/invalid one).
1350    #[serde(default = "default_timestamp_known")]
1351    pub timestamp_known: bool,
1352    /// Whether the SBOM is considered fresh (< 90 days old). False when the
1353    /// timestamp is unknown — a missing timestamp is not fresh.
1354    pub is_fresh: bool,
1355    /// Whether a primary/described component is identified
1356    pub has_primary_component: bool,
1357    /// SBOM lifecycle phase (from CycloneDX 1.5+ metadata)
1358    pub lifecycle_phase: Option<String>,
1359    /// Self-declared completeness level of the SBOM
1360    pub completeness_declaration: CompletenessDeclaration,
1361    /// Whether the SBOM has a digital signature
1362    pub has_signature: bool,
1363    /// Whether the SBOM has data provenance citations (CycloneDX 1.7+)
1364    pub has_citations: bool,
1365    /// Number of data provenance citations
1366    pub citations_count: usize,
1367}
1368
1369/// Freshness threshold in days
1370const FRESHNESS_THRESHOLD_DAYS: u32 = 90;
1371
1372/// serde default for `ProvenanceMetrics::timestamp_known` when deserializing
1373/// older records that predate the field: assume the timestamp was known
1374/// (the field only distinguishes the epoch-sentinel case introduced later).
1375const fn default_timestamp_known() -> bool {
1376    true
1377}
1378
1379impl ProvenanceMetrics {
1380    /// Calculate provenance metrics from an SBOM
1381    #[must_use]
1382    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
1383        let doc = &sbom.document;
1384
1385        let has_tool_creator = doc
1386            .creators
1387            .iter()
1388            .any(|c| c.creator_type == CreatorType::Tool);
1389        let has_tool_version = doc.creators.iter().any(|c| {
1390            c.creator_type == CreatorType::Tool
1391                && (c.name.contains(' ')
1392                    || c.name.contains('/')
1393                    || c.name.contains('@')
1394                    // SPDX 2.3 §6.8 mandates the hyphen-joined
1395                    // "toolidentifier-version" form (e.g. "LicenseFind-1.0"),
1396                    // which the separator heuristics above never match.
1397                    || c.name
1398                        .match_indices('-')
1399                        .any(|(i, _)| c.name[i + 1..].starts_with(|ch: char| ch.is_ascii_digit())))
1400        });
1401        let has_org_creator = doc
1402            .creators
1403            .iter()
1404            .any(|c| c.creator_type == CreatorType::Organization);
1405        let has_contact_email = doc.creators.iter().any(|c| c.email.is_some());
1406
1407        let timestamp_known = doc.has_known_timestamp();
1408        // Signed age: negative means the document is dated in the future.
1409        let age_days_signed = (chrono::Utc::now() - doc.created).num_days();
1410        let age_days = age_days_signed.max(0) as u32;
1411
1412        Self {
1413            has_tool_creator,
1414            has_tool_version,
1415            has_org_creator,
1416            has_contact_email,
1417            has_serial_number: doc.serial_number.is_some(),
1418            has_document_name: doc.name.is_some(),
1419            // Report 0 for an unknown timestamp rather than ~20000 days;
1420            // consumers gate the display on timestamp_known.
1421            timestamp_age_days: if timestamp_known { age_days } else { 0 },
1422            timestamp_known,
1423            // A missing timestamp is not fresh, and neither is a FUTURE-dated
1424            // document (negative signed age): a bogus forward date must not
1425            // read as "recently generated". Display-only — freshness is
1426            // deliberately NOT part of quality_score (see below).
1427            is_fresh: timestamp_known
1428                && (0..i64::from(FRESHNESS_THRESHOLD_DAYS)).contains(&age_days_signed),
1429            has_primary_component: sbom.primary_component_id.is_some(),
1430            lifecycle_phase: doc.lifecycle_phase.clone(),
1431            completeness_declaration: doc.completeness_declaration.clone(),
1432            has_signature: doc.signature.is_some(),
1433            has_citations: doc.citations_count > 0,
1434            citations_count: doc.citations_count,
1435        }
1436    }
1437
1438    /// Calculate provenance quality score (0-100)
1439    ///
1440    /// Weighted checklist: tool creator (15%), tool version (5%), org creator (12%),
1441    /// contact email (8%), serial number (8%), document name (5%),
1442    /// primary component (12%), completeness declaration (8%), signature (5%),
1443    /// lifecycle phase (10% CDX-only).
1444    ///
1445    /// Freshness (`is_fresh`) is deliberately NOT scored: it is computed from
1446    /// the live wall clock, so identical SBOM bytes would score differently
1447    /// across days (and flip at midnight). It remains available as display
1448    /// metadata; the score itself is a pure function of the document.
1449    #[must_use]
1450    pub fn quality_score(&self, is_cyclonedx: bool) -> f32 {
1451        let mut score = 0.0;
1452        let mut total_weight = 0.0;
1453
1454        let completeness_declared =
1455            self.completeness_declaration != CompletenessDeclaration::Unknown;
1456
1457        let checks: &[(bool, f32)] = &[
1458            (self.has_tool_creator, 15.0),
1459            (self.has_tool_version, 5.0),
1460            (self.has_org_creator, 12.0),
1461            (self.has_contact_email, 8.0),
1462            (self.has_serial_number, 8.0),
1463            (self.has_document_name, 5.0),
1464            (self.has_primary_component, 12.0),
1465            (completeness_declared, 8.0),
1466            (self.has_signature, 5.0),
1467        ];
1468
1469        for &(present, weight) in checks {
1470            if present {
1471                score += weight;
1472            }
1473            total_weight += weight;
1474        }
1475
1476        // Lifecycle phase: only applicable for CycloneDX 1.5+
1477        if is_cyclonedx {
1478            let weight = 10.0;
1479            if self.lifecycle_phase.is_some() {
1480                score += weight;
1481            }
1482            total_weight += weight;
1483
1484            // Data provenance citations bonus (CycloneDX 1.7+)
1485            let citations_weight = 5.0;
1486            if self.has_citations {
1487                score += citations_weight;
1488            }
1489            total_weight += citations_weight;
1490        }
1491
1492        if total_weight > 0.0 {
1493            (score / total_weight) * 100.0
1494        } else {
1495            0.0
1496        }
1497    }
1498}
1499
1500// ============================================================================
1501// Auditability metrics
1502// ============================================================================
1503
1504/// External reference and auditability quality metrics
1505#[derive(Debug, Clone, Serialize, Deserialize)]
1506pub struct AuditabilityMetrics {
1507    /// Components with VCS (version control) references
1508    pub components_with_vcs: usize,
1509    /// Components with website references
1510    pub components_with_website: usize,
1511    /// Components with security advisory references
1512    pub components_with_advisories: usize,
1513    /// Components with any external reference
1514    pub components_with_any_external_ref: usize,
1515    /// Whether the document has a security contact
1516    pub has_security_contact: bool,
1517    /// Whether the document has a vulnerability disclosure URL
1518    pub has_vuln_disclosure_url: bool,
1519}
1520
1521impl AuditabilityMetrics {
1522    /// Calculate auditability metrics from an SBOM
1523    #[must_use]
1524    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
1525        let mut with_vcs = 0;
1526        let mut with_website = 0;
1527        let mut with_advisories = 0;
1528        let mut with_any = 0;
1529
1530        for comp in sbom.components.values() {
1531            if comp.external_refs.is_empty() {
1532                continue;
1533            }
1534            with_any += 1;
1535
1536            let has_vcs = comp
1537                .external_refs
1538                .iter()
1539                .any(|r| r.ref_type == ExternalRefType::Vcs);
1540            let has_website = comp
1541                .external_refs
1542                .iter()
1543                .any(|r| r.ref_type == ExternalRefType::Website);
1544            let has_advisories = comp
1545                .external_refs
1546                .iter()
1547                .any(|r| r.ref_type == ExternalRefType::Advisories);
1548
1549            if has_vcs {
1550                with_vcs += 1;
1551            }
1552            if has_website {
1553                with_website += 1;
1554            }
1555            if has_advisories {
1556                with_advisories += 1;
1557            }
1558        }
1559
1560        Self {
1561            components_with_vcs: with_vcs,
1562            components_with_website: with_website,
1563            components_with_advisories: with_advisories,
1564            components_with_any_external_ref: with_any,
1565            has_security_contact: sbom.document.security_contact.is_some(),
1566            has_vuln_disclosure_url: sbom.document.vulnerability_disclosure_url.is_some(),
1567        }
1568    }
1569
1570    /// Calculate auditability quality score (0-100)
1571    ///
1572    /// Component-level coverage (60%) + document-level security metadata (40%).
1573    #[must_use]
1574    pub fn quality_score(&self, total_components: usize) -> f32 {
1575        if total_components == 0 {
1576            return 0.0;
1577        }
1578
1579        // Component-level: external ref coverage
1580        let ref_coverage =
1581            (self.components_with_any_external_ref as f32 / total_components as f32) * 40.0;
1582        let vcs_coverage = (self.components_with_vcs as f32 / total_components as f32) * 20.0;
1583
1584        // Document-level security metadata
1585        let security_contact_score = if self.has_security_contact { 20.0 } else { 0.0 };
1586        let disclosure_score = if self.has_vuln_disclosure_url {
1587            20.0
1588        } else {
1589            0.0
1590        };
1591
1592        (ref_coverage + vcs_coverage + security_contact_score + disclosure_score).min(100.0)
1593    }
1594}
1595
1596// ============================================================================
1597// Lifecycle metrics
1598// ============================================================================
1599
1600/// Component lifecycle quality metrics (requires enrichment data)
1601#[derive(Debug, Clone, Serialize, Deserialize)]
1602pub struct LifecycleMetrics {
1603    /// Components that have reached end-of-life
1604    pub eol_components: usize,
1605    /// Components classified as stale (no updates for 1+ years)
1606    pub stale_components: usize,
1607    /// Components explicitly marked as deprecated
1608    pub deprecated_components: usize,
1609    /// Components with archived repositories
1610    pub archived_components: usize,
1611    /// Components with a newer version available
1612    pub outdated_components: usize,
1613    /// Components that had lifecycle enrichment data
1614    pub enriched_components: usize,
1615    /// Enrichment coverage percentage (0-100)
1616    pub enrichment_coverage: f32,
1617}
1618
1619impl LifecycleMetrics {
1620    /// Calculate lifecycle metrics from an SBOM
1621    ///
1622    /// These metrics are only meaningful after enrichment. When
1623    /// `enrichment_coverage == 0`, the lifecycle score should be
1624    /// treated as N/A and excluded from the weighted total.
1625    #[must_use]
1626    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
1627        let total = sbom.components.len();
1628        let mut eol = 0;
1629        let mut stale = 0;
1630        let mut deprecated = 0;
1631        let mut archived = 0;
1632        let mut outdated = 0;
1633        let mut enriched = 0;
1634
1635        for comp in sbom.components.values() {
1636            let has_lifecycle_data = comp.eol.is_some() || comp.staleness.is_some();
1637            if has_lifecycle_data {
1638                enriched += 1;
1639            }
1640
1641            if let Some(ref eol_info) = comp.eol
1642                && eol_info.status == EolStatus::EndOfLife
1643            {
1644                eol += 1;
1645            }
1646
1647            if let Some(ref stale_info) = comp.staleness {
1648                if matches!(
1649                    stale_info.level,
1650                    StalenessLevel::Stale | StalenessLevel::Abandoned
1651                ) {
1652                    stale += 1;
1653                }
1654                // The enrichment sets level=Deprecated AND is_deprecated=true
1655                // together (same for Archived), so counting both branches
1656                // double-counted every deprecated/archived component in the
1657                // normal case. Count each component at most once per state.
1658                if stale_info.level == StalenessLevel::Deprecated || stale_info.is_deprecated {
1659                    deprecated += 1;
1660                }
1661                if stale_info.level == StalenessLevel::Archived || stale_info.is_archived {
1662                    archived += 1;
1663                }
1664                if stale_info.latest_version.is_some() {
1665                    outdated += 1;
1666                }
1667            }
1668        }
1669
1670        let coverage = if total > 0 {
1671            (enriched as f32 / total as f32) * 100.0
1672        } else {
1673            0.0
1674        };
1675
1676        Self {
1677            eol_components: eol,
1678            stale_components: stale,
1679            deprecated_components: deprecated,
1680            archived_components: archived,
1681            outdated_components: outdated,
1682            enriched_components: enriched,
1683            enrichment_coverage: coverage,
1684        }
1685    }
1686
1687    /// Whether enrichment data is available for scoring
1688    #[must_use]
1689    pub fn has_data(&self) -> bool {
1690        self.enriched_components > 0
1691    }
1692
1693    /// Calculate lifecycle quality score (0-100)
1694    ///
1695    /// Starts at 100, subtracts penalties for problematic components.
1696    /// Returns `None` if no enrichment data is available.
1697    #[must_use]
1698    pub fn quality_score(&self) -> Option<f32> {
1699        if !self.has_data() {
1700            return None;
1701        }
1702
1703        let mut score = 100.0_f32;
1704
1705        // EOL: severe penalty (15 points each, capped at 60)
1706        score -= (self.eol_components as f32 * 15.0).min(60.0);
1707        // Stale: moderate penalty (5 points each, capped at 30)
1708        score -= (self.stale_components as f32 * 5.0).min(30.0);
1709        // Deprecated/archived: moderate penalty (3 points each, capped at 20)
1710        score -= ((self.deprecated_components + self.archived_components) as f32 * 3.0).min(20.0);
1711        // Outdated: mild penalty (1 point each, capped at 10)
1712        score -= (self.outdated_components as f32 * 1.0).min(10.0);
1713
1714        Some(score.clamp(0.0, 100.0))
1715    }
1716}
1717
1718// ============================================================================
1719// Cryptography Metrics
1720// ============================================================================
1721
1722/// Cryptographic asset metrics for quantum readiness and crypto hygiene assessment.
1723///
1724/// Computed from components with `component_type == Cryptographic` and
1725/// populated `crypto_properties`. Returns `None` for quality score when
1726/// no crypto components are present (N/A-aware).
1727#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1728pub struct CryptographyMetrics {
1729    /// Total number of cryptographic-asset components
1730    pub total_crypto_components: usize,
1731    /// Number of algorithm assets
1732    pub algorithms_count: usize,
1733    /// Number of certificate assets
1734    pub certificates_count: usize,
1735    /// Number of key material assets
1736    pub keys_count: usize,
1737    /// Number of protocol assets
1738    pub protocols_count: usize,
1739    /// Algorithms with `nistQuantumSecurityLevel > 0`
1740    pub quantum_safe_count: usize,
1741    /// Algorithms with `nistQuantumSecurityLevel == 0`
1742    pub quantum_vulnerable_count: usize,
1743    /// Algorithms flagged as weak/broken (MD5, SHA-1, DES, etc.)
1744    pub weak_algorithm_count: usize,
1745    /// Hybrid PQC combiner algorithms
1746    pub hybrid_pqc_count: usize,
1747    /// Certificates past `notValidAfter`
1748    pub expired_certificates: usize,
1749    /// Certificates expiring within 90 days
1750    pub expiring_soon_certificates: usize,
1751    /// Key material in `compromised` state
1752    pub compromised_keys: usize,
1753    /// Symmetric keys < 128 bits or asymmetric keys below recommended minimum
1754    pub inadequate_key_sizes: usize,
1755    /// Names of weak/broken algorithms found
1756    pub weak_algorithm_names: Vec<String>,
1757
1758    // --- Algorithm completeness (slot 1: Crpt) ---
1759    /// Algorithms with an OID identifier
1760    pub algorithms_with_oid: usize,
1761    /// Algorithms with `algorithm_family` set
1762    pub algorithms_with_family: usize,
1763    /// Algorithms with a recognized primitive (not `Other`)
1764    pub algorithms_with_primitive: usize,
1765    /// Algorithms with classical or quantum security level set
1766    pub algorithms_with_security_level: usize,
1767
1768    // --- Cross-reference resolution (slot 4: Refs) ---
1769    /// Certificates with `signature_algorithm_ref` set
1770    pub certs_with_signature_algo_ref: usize,
1771    /// Keys with `algorithm_ref` set
1772    pub keys_with_algorithm_ref: usize,
1773    /// Protocols with at least one cipher suite
1774    pub protocols_with_cipher_suites: usize,
1775
1776    // --- Key lifecycle (slot 5: Life) ---
1777    /// Keys with `state` tracked
1778    pub keys_with_state: usize,
1779    /// Keys with `secured_by` protection
1780    pub keys_with_protection: usize,
1781    /// Keys with `creation_date` or `activation_date`
1782    pub keys_with_lifecycle_dates: usize,
1783
1784    // --- Certificate health (slot 5: Life) ---
1785    /// Certificates with both `not_valid_before` and `not_valid_after`
1786    pub certs_with_validity_dates: usize,
1787}
1788
1789impl CryptographyMetrics {
1790    /// Compute cryptography metrics from an SBOM.
1791    #[must_use]
1792    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
1793        let mut m = Self::default();
1794
1795        for comp in sbom.components.values() {
1796            if comp.component_type != ComponentType::Cryptographic {
1797                continue;
1798            }
1799            let Some(cp) = &comp.crypto_properties else {
1800                // A Cryptographic component with no cryptoProperties carries no
1801                // evaluable crypto data. Counting it toward total_crypto_
1802                // components would make has_data() true and let the CBOM
1803                // sub-scores return 100 on empty denominators (grade A for
1804                // undocumented crypto), so only count documented assets.
1805                continue;
1806            };
1807            m.total_crypto_components += 1;
1808
1809            match cp.asset_type {
1810                CryptoAssetType::Algorithm => {
1811                    m.algorithms_count += 1;
1812                    if cp.oid.is_some() {
1813                        m.algorithms_with_oid += 1;
1814                    }
1815                    if let Some(algo) = &cp.algorithm_properties {
1816                        if algo.algorithm_family.is_some() {
1817                            m.algorithms_with_family += 1;
1818                        }
1819                        if !matches!(algo.primitive, CryptoPrimitive::Other(_)) {
1820                            m.algorithms_with_primitive += 1;
1821                        }
1822                        if algo.classical_security_level.is_some()
1823                            || algo.nist_quantum_security_level.is_some()
1824                        {
1825                            m.algorithms_with_security_level += 1;
1826                        }
1827                        // A classical public-key family (RSA/ECDSA/DH/…) is
1828                        // quantum-vulnerable on the family alone — real CBOMs
1829                        // rarely set nistQuantumSecurityLevel=0, so counting
1830                        // only Some(0) let classical crypto escape the penalty.
1831                        // NOTE: the compliance checkers use the richer shared
1832                        // classifier `crate::model::classify_algorithm` (OID,
1833                        // name, curve, alias normalization); these
1834                        // family-string helpers are kept here so the metrics
1835                        // scoring stays stable.
1836                        if algo.is_classical_quantum_vulnerable()
1837                            || algo.nist_quantum_security_level == Some(0)
1838                        {
1839                            m.quantum_vulnerable_count += 1;
1840                        } else if algo.is_quantum_safe() {
1841                            m.quantum_safe_count += 1;
1842                        }
1843                        if algo.is_weak_by_name(&comp.name) {
1844                            m.weak_algorithm_count += 1;
1845                            m.weak_algorithm_names.push(comp.name.clone());
1846                        }
1847                        if algo.is_hybrid_pqc() {
1848                            m.hybrid_pqc_count += 1;
1849                        }
1850                    }
1851                }
1852                CryptoAssetType::Certificate => {
1853                    m.certificates_count += 1;
1854                    if let Some(cert) = &cp.certificate_properties {
1855                        if cert.not_valid_before.is_some() && cert.not_valid_after.is_some() {
1856                            m.certs_with_validity_dates += 1;
1857                        }
1858                        if cert.signature_algorithm_ref.is_some() {
1859                            m.certs_with_signature_algo_ref += 1;
1860                        }
1861                        if cert.is_expired() {
1862                            m.expired_certificates += 1;
1863                        } else if cert.is_expiring_soon(90) {
1864                            m.expiring_soon_certificates += 1;
1865                        }
1866                    }
1867                }
1868                CryptoAssetType::RelatedCryptoMaterial => {
1869                    m.keys_count += 1;
1870                    if let Some(mat) = &cp.related_crypto_material_properties {
1871                        if mat.state.is_some() {
1872                            m.keys_with_state += 1;
1873                        }
1874                        if mat.secured_by.is_some() {
1875                            m.keys_with_protection += 1;
1876                        }
1877                        if mat.creation_date.is_some() || mat.activation_date.is_some() {
1878                            m.keys_with_lifecycle_dates += 1;
1879                        }
1880                        if mat.algorithm_ref.is_some() {
1881                            m.keys_with_algorithm_ref += 1;
1882                        }
1883                        if mat.state == Some(CryptoMaterialState::Compromised) {
1884                            m.compromised_keys += 1;
1885                        }
1886                        // Flag inadequate key sizes. A key size is a BIT-LENGTH,
1887                        // and its adequacy is key-type-dependent: an ECC curve
1888                        // bit-length (P-256 → 256) provides ~size/2-bit security,
1889                        // so 256-bit ECC is strong, whereas RSA/finite-field
1890                        // needs ≥2048. The old flat `<2048` rule false-failed
1891                        // every standard ECC key. Recognize the strong ECC curve
1892                        // sizes; otherwise apply the finite-field ≥2048 rule.
1893                        if let Some(size) = mat.size {
1894                            let is_symmetric = matches!(
1895                                mat.material_type,
1896                                crate::model::CryptoMaterialType::SymmetricKey
1897                                    | crate::model::CryptoMaterialType::SecretKey
1898                            );
1899                            // Curve bit-lengths giving ≥128-bit security:
1900                            // Curve25519(255), P-256, P-384, Curve448, P-521.
1901                            // 512 is deliberately EXCLUDED: a 512-bit RSA/DSA key
1902                            // is trivially factorable, and matching it here would
1903                            // false-PASS it; a 512-bit ECC curve (brainpoolP512r1,
1904                            // rare) instead takes the finite-field path and is
1905                            // flagged — an acceptable over-caution vs. a false pass.
1906                            const STRONG_ECC_SIZES: &[u32] = &[255, 256, 384, 448, 521];
1907                            let inadequate = if is_symmetric {
1908                                size < 128
1909                            } else if STRONG_ECC_SIZES.contains(&size) {
1910                                false
1911                            } else {
1912                                size < 2048
1913                            };
1914                            if inadequate {
1915                                m.inadequate_key_sizes += 1;
1916                            }
1917                        }
1918                    }
1919                }
1920                CryptoAssetType::Protocol => {
1921                    m.protocols_count += 1;
1922                    if let Some(proto) = &cp.protocol_properties
1923                        && !proto.cipher_suites.is_empty()
1924                    {
1925                        m.protocols_with_cipher_suites += 1;
1926                    }
1927                }
1928                _ => {}
1929            }
1930        }
1931
1932        m
1933    }
1934
1935    /// Whether any crypto components exist (i.e., CBOM data is present).
1936    #[must_use]
1937    pub fn has_data(&self) -> bool {
1938        self.total_crypto_components > 0
1939    }
1940
1941    /// Percentage of algorithms that are quantum-safe (0-100).
1942    /// Returns `None` when no algorithms are present: a 0/0 readiness is
1943    /// absence of evidence, not a perfect score, and every renderer shows
1944    /// it as "n/a" rather than a vacuous 100.
1945    #[must_use]
1946    pub fn quantum_readiness_score(&self) -> Option<f32> {
1947        if self.algorithms_count == 0 {
1948            return None;
1949        }
1950        Some((self.quantum_safe_count as f32 / self.algorithms_count as f32) * 100.0)
1951    }
1952
1953    /// Quality score (0-100) based on crypto hygiene. Returns `None` if no crypto data.
1954    #[must_use]
1955    pub fn quality_score(&self) -> Option<f32> {
1956        if !self.has_data() {
1957            return None;
1958        }
1959
1960        // Documented crypto assets exist but NONE are classifiable as an
1961        // algorithm/certificate/key/protocol, so no penalty below can ever
1962        // trigger and the 100 baseline would be vacuous. The compliance
1963        // checkers treat such an inventory as unverifiable (the CNSA2/PQC
1964        // "evaluable assets" gates fail it), so the score reads fully
1965        // degraded instead of perfect.
1966        let classified = self.algorithms_count
1967            + self.certificates_count
1968            + self.keys_count
1969            + self.protocols_count;
1970        if classified == 0 {
1971            return Some(0.0);
1972        }
1973
1974        let mut score = 100.0_f32;
1975
1976        // Weak algorithms: severe penalty (15 each, capped at 50)
1977        score -= (self.weak_algorithm_count as f32 * 15.0).min(50.0);
1978        // Quantum-vulnerable: moderate penalty (8 each, capped at 40)
1979        score -= (self.quantum_vulnerable_count as f32 * 8.0).min(40.0);
1980        // Expired certs: moderate penalty (10 each, capped at 30)
1981        score -= (self.expired_certificates as f32 * 10.0).min(30.0);
1982        // Compromised keys: severe penalty (20 each, capped at 40)
1983        score -= (self.compromised_keys as f32 * 20.0).min(40.0);
1984        // Inadequate key sizes: mild penalty (5 each, capped at 20)
1985        score -= (self.inadequate_key_sizes as f32 * 5.0).min(20.0);
1986        // Expiring-soon certs: mild penalty (3 each, capped at 15)
1987        score -= (self.expiring_soon_certificates as f32 * 3.0).min(15.0);
1988        // Unresolved crypto references: cert/key/protocol assets whose
1989        // algorithm linkage can't be resolved are documented-but-unverifiable
1990        // (the compliance checkers warn on them, and none of the penalties
1991        // above can ever fire for them), so an inventory of opaque refs must
1992        // not read as perfect hygiene. Proportional, mirroring
1993        // `crypto_dependency_score`.
1994        let linkable = self.certificates_count + self.keys_count + self.protocols_count;
1995        if linkable > 0 {
1996            let resolved = self.certs_with_signature_algo_ref
1997                + self.keys_with_algorithm_ref
1998                + self.protocols_with_cipher_suites;
1999            let unresolved_pct = 1.0 - (resolved as f32 / linkable as f32);
2000            score -= unresolved_pct * 30.0;
2001        }
2002        // Hybrid PQC bonus: +2 each (capped at +10)
2003        score += (self.hybrid_pqc_count as f32 * 2.0).min(10.0);
2004
2005        Some(score.clamp(0.0, 100.0))
2006    }
2007
2008    // ----- Per-category scores for CBOM ScoringProfile -----
2009
2010    /// Crypto completeness: how fully documented are the crypto assets?
2011    #[must_use]
2012    pub fn crypto_completeness_score(&self) -> f32 {
2013        if self.algorithms_count == 0 {
2014            return 100.0;
2015        }
2016        let family_pct = self.algorithms_with_family as f32 / self.algorithms_count as f32;
2017        let primitive_pct = self.algorithms_with_primitive as f32 / self.algorithms_count as f32;
2018        let level_pct = self.algorithms_with_security_level as f32 / self.algorithms_count as f32;
2019        (family_pct * 40.0 + primitive_pct * 30.0 + level_pct * 30.0).clamp(0.0, 100.0)
2020    }
2021
2022    /// Crypto identifier quality: OID coverage.
2023    #[must_use]
2024    pub fn crypto_identifier_score(&self) -> f32 {
2025        if self.algorithms_count == 0 {
2026            return 100.0;
2027        }
2028        let oid_pct = self.algorithms_with_oid as f32 / self.algorithms_count as f32;
2029        (oid_pct * 100.0).clamp(0.0, 100.0)
2030    }
2031
2032    /// Algorithm strength: penalizes broken/weak/quantum-vulnerable algorithms.
2033    #[must_use]
2034    pub fn algorithm_strength_score(&self) -> f32 {
2035        if self.algorithms_count == 0 {
2036            return 100.0;
2037        }
2038        let mut score = 100.0_f32;
2039        score -= (self.weak_algorithm_count as f32 * 15.0).min(60.0);
2040        score -= (self.inadequate_key_sizes as f32 * 8.0).min(30.0);
2041        if self.algorithms_count > 0 {
2042            let vuln_pct = self.quantum_vulnerable_count as f32 / self.algorithms_count as f32;
2043            score -= vuln_pct * 30.0;
2044        }
2045        score.clamp(0.0, 100.0)
2046    }
2047
2048    /// Crypto dependency references: how well are cert/key/protocol -> algorithm refs resolved?
2049    #[must_use]
2050    pub fn crypto_dependency_score(&self) -> f32 {
2051        let linkable = self.certificates_count + self.keys_count + self.protocols_count;
2052        if linkable == 0 {
2053            return 100.0;
2054        }
2055        let resolved = self.certs_with_signature_algo_ref
2056            + self.keys_with_algorithm_ref
2057            + self.protocols_with_cipher_suites;
2058        let pct = resolved as f32 / linkable as f32;
2059        (pct * 100.0).clamp(0.0, 100.0)
2060    }
2061
2062    /// Crypto lifecycle: merged key management + certificate health.
2063    #[must_use]
2064    pub fn crypto_lifecycle_score(&self) -> f32 {
2065        let mut score = 100.0_f32;
2066
2067        if self.keys_count > 0 {
2068            let state_pct = self.keys_with_state as f32 / self.keys_count as f32;
2069            let protection_pct = self.keys_with_protection as f32 / self.keys_count as f32;
2070            let lifecycle_pct = self.keys_with_lifecycle_dates as f32 / self.keys_count as f32;
2071            let key_completeness =
2072                (state_pct * 0.4 + protection_pct * 0.3 + lifecycle_pct * 0.3) * 100.0;
2073            score = score * 0.5 + key_completeness * 0.5;
2074            score -= (self.compromised_keys as f32 * 20.0).min(40.0);
2075            score -= (self.inadequate_key_sizes as f32 * 5.0).min(20.0);
2076        }
2077
2078        if self.certificates_count > 0 {
2079            let validity_pct =
2080                self.certs_with_validity_dates as f32 / self.certificates_count as f32;
2081            score -= (1.0 - validity_pct) * 15.0;
2082            score -= (self.expired_certificates as f32 * 15.0).min(45.0);
2083            score -= (self.expiring_soon_certificates as f32 * 5.0).min(20.0);
2084        }
2085
2086        score.clamp(0.0, 100.0)
2087    }
2088
2089    /// PQC readiness: quantum migration preparedness.
2090    /// Returns `None` when no algorithms are present — there is nothing to
2091    /// be ready about, so the scorer redistributes this category's weight
2092    /// (mirroring `vulnerability_score`) instead of granting a vacuous 100.
2093    #[must_use]
2094    pub fn pqc_readiness_score(&self) -> Option<f32> {
2095        if self.algorithms_count == 0 {
2096            return None;
2097        }
2098        let mut score = 0.0_f32;
2099        let qs_pct = self.quantum_safe_count as f32 / self.algorithms_count as f32;
2100        score += qs_pct * 60.0;
2101        if self.hybrid_pqc_count > 0 {
2102            score += 15.0;
2103        }
2104        if self.weak_algorithm_count == 0 {
2105            score += 25.0;
2106        } else {
2107            score += (25.0 - self.weak_algorithm_count as f32 * 5.0).max(0.0);
2108        }
2109        Some(score.clamp(0.0, 100.0))
2110    }
2111
2112    /// Percentage of algorithms that are quantum-safe (for overview display).
2113    #[must_use]
2114    pub fn quantum_readiness_pct(&self) -> f32 {
2115        if self.algorithms_count == 0 {
2116            return 0.0;
2117        }
2118        (self.quantum_safe_count as f32 / self.algorithms_count as f32) * 100.0
2119    }
2120
2121    /// Category labels for CBOM quality chart.
2122    #[must_use]
2123    pub const fn cbom_category_labels() -> [&'static str; 8] {
2124        ["Crpt", "OIDs", "Algo", "Refs", "Life", "PQC", "Prov", "Lic"]
2125    }
2126
2127    /// Full category names for CBOM quality scoring, in the same slot order
2128    /// as [`Self::cbom_category_labels`] (the short chart labels) and as the
2129    /// scorer's CBOM slot substitution (`scorer.rs`). These are the names
2130    /// serialized into diff `QualityDelta` categories for CBOM pairs and the
2131    /// names the TUI's CBOM category rows display — one category, one name.
2132    #[must_use]
2133    pub const fn cbom_category_names() -> [&'static str; 8] {
2134        [
2135            "Crypto Compl",
2136            "OIDs",
2137            "Algo Strength",
2138            "Crypto Refs",
2139            "Crypto Life",
2140            "PQC Readiness",
2141            "Provenance",
2142            "Licenses",
2143        ]
2144    }
2145}
2146
2147// ============================================================================
2148// Helper functions
2149// ============================================================================
2150
2151fn is_valid_purl(purl: &str) -> bool {
2152    // Basic PURL validation: pkg:type/namespace/name@version
2153    purl.starts_with("pkg:") && purl.contains('/')
2154}
2155
2156fn extract_ecosystem_from_purl(purl: &str) -> Option<String> {
2157    // Extract type from pkg:type/...
2158    if let Some(rest) = purl.strip_prefix("pkg:")
2159        && let Some(slash_idx) = rest.find('/')
2160    {
2161        return Some(rest[..slash_idx].to_string());
2162    }
2163    None
2164}
2165
2166fn is_valid_cpe(cpe: &str) -> bool {
2167    // Basic CPE validation
2168    cpe.starts_with("cpe:2.3:") || cpe.starts_with("cpe:/")
2169}
2170
2171/// Whether a license identifier is on the SPDX deprecated list.
2172///
2173/// These are license IDs that SPDX has deprecated in favor of more specific
2174/// identifiers (e.g., `GPL-2.0` → `GPL-2.0-only` or `GPL-2.0-or-later`).
2175fn is_deprecated_spdx_license(expr: &str) -> bool {
2176    const DEPRECATED: &[&str] = &[
2177        "GPL-2.0",
2178        "GPL-2.0+",
2179        "GPL-3.0",
2180        "GPL-3.0+",
2181        "LGPL-2.0",
2182        "LGPL-2.0+",
2183        "LGPL-2.1",
2184        "LGPL-2.1+",
2185        "LGPL-3.0",
2186        "LGPL-3.0+",
2187        "AGPL-1.0",
2188        "AGPL-3.0",
2189        "GFDL-1.1",
2190        "GFDL-1.2",
2191        "GFDL-1.3",
2192        "BSD-2-Clause-FreeBSD",
2193        "BSD-2-Clause-NetBSD",
2194        "eCos-2.0",
2195        "Nunit",
2196        "StandardML-NJ",
2197        "wxWindows",
2198    ];
2199    let trimmed = expr.trim();
2200    DEPRECATED.contains(&trimmed)
2201}
2202
2203/// Whether a license is considered restrictive/copyleft (GPL family).
2204///
2205/// This is informational — restrictive licenses are not inherently a quality
2206/// issue, but organizations need to know about them for compliance.
2207fn is_restrictive_license(expr: &str) -> bool {
2208    let trimmed = expr.trim().to_uppercase();
2209    trimmed.starts_with("GPL")
2210        || trimmed.starts_with("LGPL")
2211        || trimmed.starts_with("AGPL")
2212        || trimmed.starts_with("EUPL")
2213        || trimmed.starts_with("SSPL")
2214        || trimmed.starts_with("OSL")
2215        || trimmed.starts_with("CPAL")
2216        || trimmed.starts_with("CC-BY-SA")
2217        || trimmed.starts_with("CC-BY-NC")
2218}
2219
2220#[cfg(test)]
2221mod tests {
2222    use super::*;
2223
2224    /// File inventory entries (SPDX files/snippets, CycloneDX type=file)
2225    /// must not dilute package completeness percentages: files structurally
2226    /// lack version/supplier/purl, and a file-cataloguing SBOM with
2227    /// thousands of files would otherwise report ~0% coverage on an
2228    /// otherwise complete document.
2229    #[test]
2230    fn file_components_do_not_dilute_completeness() {
2231        use crate::model::{Component, ComponentType, NormalizedSbom};
2232        let mut sbom = NormalizedSbom::default();
2233        let pkg =
2234            Component::new("app".to_string(), "app@1".to_string()).with_version("1.0".to_string());
2235        sbom.add_component(pkg);
2236        for i in 0..10 {
2237            let mut f = Component::new(format!("file-{i}"), format!("file-{i}@x"));
2238            f.component_type = ComponentType::File;
2239            sbom.add_component(f);
2240        }
2241
2242        let m = CompletenessMetrics::from_sbom(&sbom);
2243        assert!(
2244            (m.components_with_version - 100.0).abs() < f32::EPSILON,
2245            "10 files must not dilute the package's 100% version coverage, got {}",
2246            m.components_with_version
2247        );
2248    }
2249
2250    /// Same File exemption for identifier scoring: files structurally lack
2251    /// purl/cpe/swid, so a file catalogue must not dilute package identifier
2252    /// coverage.
2253    #[test]
2254    fn file_components_do_not_dilute_identifier_score() {
2255        use crate::model::{Component, ComponentType, NormalizedSbom};
2256        let mut sbom = NormalizedSbom::default();
2257        let mut pkg = Component::new("app".to_string(), "app@1".to_string());
2258        pkg.identifiers.purl = Some("pkg:cargo/app@1.0.0".to_string());
2259        sbom.add_component(pkg);
2260        for i in 0..30 {
2261            let mut f = Component::new(format!("file-{i}"), format!("file-{i}@x"));
2262            f.component_type = ComponentType::File;
2263            sbom.add_component(f);
2264        }
2265
2266        let im = IdentifierMetrics::from_sbom(&sbom);
2267        assert_eq!(im.file_components, 30);
2268        assert_eq!(
2269            im.missing_all_identifiers, 0,
2270            "exempt files must not count as identifier-less"
2271        );
2272        let score = im.quality_score(sbom.components.len());
2273        assert!(
2274            (score - 100.0).abs() < 0.01,
2275            "30 files must not dilute the package's identifier coverage, got {score}"
2276        );
2277    }
2278
2279    /// Same File exemption for license scoring: per-file license facts are
2280    /// not package license documentation, so a file catalogue must not dilute
2281    /// package license coverage (license strings still reach the lists).
2282    #[test]
2283    fn file_components_do_not_dilute_license_score() {
2284        use crate::model::{Component, ComponentType, LicenseExpression, NormalizedSbom};
2285        let mut sbom = NormalizedSbom::default();
2286        let mut pkg = Component::new("app".to_string(), "app@1".to_string());
2287        pkg.licenses
2288            .add_declared(LicenseExpression::new("MIT".to_string()));
2289        sbom.add_component(pkg);
2290        for i in 0..30 {
2291            let mut f = Component::new(format!("file-{i}"), format!("file-{i}@x"));
2292            f.component_type = ComponentType::File;
2293            f.licenses
2294                .add_declared(LicenseExpression::new("GPL-2.0-only".to_string()));
2295            sbom.add_component(f);
2296        }
2297
2298        let lm = LicenseMetrics::from_sbom(&sbom);
2299        assert_eq!(lm.file_components, 30);
2300        assert_eq!(lm.with_declared, 1, "files are exempt from counters");
2301        assert!(
2302            lm.unique_licenses.contains(&"GPL-2.0-only".to_string()),
2303            "file license strings still feed the informational lists"
2304        );
2305        // Coverage 1/1 * 60 + full SPDX bonus 30 = 90 for the one package.
2306        let score = lm.quality_score(sbom.components.len());
2307        assert!(
2308            (score - 90.0).abs() < 0.01,
2309            "30 files must not dilute the package's license coverage, got {score}"
2310        );
2311    }
2312
2313    /// Same File exemption for the dependency-coverage denominator: files
2314    /// (typically attached via CONTAINS) are not dependency-graph members.
2315    #[test]
2316    fn file_components_do_not_dilute_dependency_coverage() {
2317        use crate::model::{
2318            Component, ComponentType, DependencyEdge, DependencyType, NormalizedSbom,
2319        };
2320        let mut sbom = NormalizedSbom::default();
2321        let app = Component::new("app".to_string(), "app@1".to_string());
2322        let lib = Component::new("lib".to_string(), "lib@1".to_string());
2323        let app_id = app.canonical_id.clone();
2324        let lib_id = lib.canonical_id.clone();
2325        sbom.add_component(app);
2326        sbom.add_component(lib);
2327        sbom.add_edge(DependencyEdge::new(
2328            app_id.clone(),
2329            lib_id,
2330            DependencyType::DependsOn,
2331        ));
2332        for i in 0..30 {
2333            let mut f = Component::new(format!("file-{i}"), format!("file-{i}@x"));
2334            f.component_type = ComponentType::File;
2335            let file_id = f.canonical_id.clone();
2336            sbom.add_component(f);
2337            // SPDX-style package CONTAINS file relationship.
2338            sbom.add_edge(DependencyEdge::new(
2339                app_id.clone(),
2340                file_id,
2341                DependencyType::Contains,
2342            ));
2343        }
2344
2345        let dm = DependencyMetrics::from_sbom(&sbom);
2346        assert_eq!(dm.file_components, 30);
2347        let score = dm.quality_score(sbom.components.len());
2348        assert!(
2349            (score - 100.0).abs() < 0.01,
2350            "30 contained files must not dilute dependency coverage, got {score}"
2351        );
2352    }
2353
2354    /// A Cryptographic component with NO cryptoProperties must not count as
2355    /// crypto inventory — otherwise has_data() is true and the CBOM sub-scores
2356    /// return 100 (grade A) for undocumented crypto.
2357    #[test]
2358    fn property_less_crypto_component_is_not_crypto_inventory() {
2359        use crate::model::{Component, ComponentType, NormalizedSbom};
2360        let mut sbom = NormalizedSbom::default();
2361        let mut c = Component::new("mystery-crypto".to_string(), "mc@1".to_string());
2362        c.component_type = ComponentType::Cryptographic;
2363        // No crypto_properties set.
2364        sbom.add_component(c);
2365
2366        let m = CryptographyMetrics::from_sbom(&sbom);
2367        assert_eq!(
2368            m.total_crypto_components, 0,
2369            "undocumented crypto is not inventory"
2370        );
2371        assert!(
2372            !m.has_data(),
2373            "has_data must be false → CBOM scores are N/A, not 100"
2374        );
2375    }
2376
2377    /// A CBOM whose documented crypto assets are ALL unclassifiable (asset
2378    /// types outside algorithm/certificate/key/protocol) has nothing
2379    /// evaluable, so no hygiene penalty can ever trigger — the score must
2380    /// read fully degraded, not a vacuous 100 (matching the CNSA2/PQC
2381    /// "evaluable assets" compliance gates, which fail such inventories).
2382    #[test]
2383    fn unclassifiable_crypto_inventory_scores_degraded_not_perfect() {
2384        use crate::model::{
2385            Component, ComponentType, CryptoAssetType, CryptoProperties, NormalizedSbom,
2386        };
2387        let mut sbom = NormalizedSbom::default();
2388        let mut c = Component::new("mystery-asset".to_string(), "ma@1".to_string());
2389        c.component_type = ComponentType::Cryptographic;
2390        c.crypto_properties = Some(CryptoProperties::new(CryptoAssetType::Other(
2391            "unknown".to_string(),
2392        )));
2393        sbom.add_component(c);
2394
2395        let m = CryptographyMetrics::from_sbom(&sbom);
2396        assert!(m.has_data(), "documented crypto assets are inventory");
2397        assert_eq!(m.algorithms_count, 0);
2398        assert_eq!(
2399            m.quality_score(),
2400            Some(0.0),
2401            "all-unclassifiable inventory must read degraded, not 100"
2402        );
2403    }
2404
2405    /// Certificates/protocols whose algorithm references cannot be resolved
2406    /// are documented-but-unverifiable: none of the hygiene penalties can
2407    /// fire for them, so without the unresolved-reference penalty an
2408    /// inventory of opaque refs would read a perfect 100 while the
2409    /// compliance checkers warn on every asset.
2410    #[test]
2411    fn unresolved_crypto_references_degrade_the_quality_score() {
2412        use crate::model::{
2413            CertificateProperties, Component, ComponentType, CryptoAssetType, CryptoProperties,
2414            NormalizedSbom,
2415        };
2416        let mut sbom = NormalizedSbom::default();
2417        let mut c = Component::new("opaque-cert".to_string(), "oc@1".to_string());
2418        c.component_type = ComponentType::Cryptographic;
2419        // Certificate with no signatureAlgorithmRef: classified, unlinked.
2420        c.crypto_properties = Some(
2421            CryptoProperties::new(CryptoAssetType::Certificate)
2422                .with_certificate_properties(CertificateProperties::new()),
2423        );
2424        sbom.add_component(c);
2425
2426        let m = CryptographyMetrics::from_sbom(&sbom);
2427        assert_eq!(m.certificates_count, 1);
2428        assert_eq!(m.certs_with_signature_algo_ref, 0);
2429        let score = m.quality_score().expect("has data");
2430        assert!(
2431            score <= 70.0,
2432            "fully-unresolved references must degrade the score, got {score}"
2433        );
2434    }
2435
2436    /// Standard ECC keys (P-256/384/etc.) must NOT be flagged as inadequate —
2437    /// their size is the curve bit-length, giving ~size/2-bit security. The old
2438    /// flat `<2048` rule false-failed every ECC key. RSA-1024 stays flagged.
2439    #[test]
2440    fn ecc_key_sizes_are_not_falsely_inadequate() {
2441        use crate::model::{
2442            Component, ComponentType, CryptoAssetType, CryptoMaterialType, CryptoProperties,
2443            NormalizedSbom, RelatedCryptoMaterialProperties,
2444        };
2445        let key = |name: &str, mtype: CryptoMaterialType, size: u32| {
2446            let mut c = Component::new(name.to_string(), format!("{name}@1"));
2447            c.component_type = ComponentType::Cryptographic;
2448            let mat = RelatedCryptoMaterialProperties::new(mtype).with_size(size);
2449            c.crypto_properties = Some(
2450                CryptoProperties::new(CryptoAssetType::RelatedCryptoMaterial)
2451                    .with_related_crypto_material_properties(mat),
2452            );
2453            c
2454        };
2455        let mut sbom = NormalizedSbom::default();
2456        sbom.add_component(key("ecc-p256", CryptoMaterialType::PublicKey, 256));
2457        sbom.add_component(key("ecc-p384", CryptoMaterialType::PublicKey, 384));
2458        sbom.add_component(key("ecc-p521", CryptoMaterialType::PublicKey, 521));
2459        sbom.add_component(key("rsa-1024", CryptoMaterialType::PublicKey, 1024));
2460        // A 512-bit key must be inadequate — it would be a factorable RSA/DSA
2461        // key; 512 must NOT be treated as a "strong ECC" size.
2462        sbom.add_component(key("weak-512", CryptoMaterialType::PublicKey, 512));
2463        sbom.add_component(key("aes-256", CryptoMaterialType::SymmetricKey, 256));
2464
2465        let m = CryptographyMetrics::from_sbom(&sbom);
2466        assert_eq!(
2467            m.inadequate_key_sizes, 2,
2468            "RSA-1024 and the 512-bit key are inadequate; strong ECC (256/384/521) is not"
2469        );
2470    }
2471
2472    /// A NOASSERTION-only component must not count as "has license" — the
2473    /// CycloneDX parser emits declared=["NOASSERTION"] for empty license
2474    /// objects, which carries zero license information.
2475    #[test]
2476    fn noassertion_only_component_is_not_licensed() {
2477        use crate::model::{Component, LicenseExpression, NormalizedSbom};
2478        let mut sbom = NormalizedSbom::default();
2479        let mut c = Component::new("no-info".to_string(), "ni@1".to_string());
2480        c.licenses
2481            .add_declared(LicenseExpression::new("NOASSERTION".to_string()));
2482        sbom.add_component(c);
2483        let mut licensed = Component::new("real".to_string(), "real@1".to_string());
2484        licensed
2485            .licenses
2486            .add_declared(LicenseExpression::new("MIT".to_string()));
2487        sbom.add_component(licensed);
2488
2489        let completeness = CompletenessMetrics::from_sbom(&sbom);
2490        assert!(
2491            (completeness.components_with_licenses - 50.0).abs() < 0.01,
2492            "1 of 2 components has real license info, got {}",
2493            completeness.components_with_licenses
2494        );
2495
2496        let lm = LicenseMetrics::from_sbom(&sbom);
2497        assert_eq!(
2498            lm.with_declared, 1,
2499            "NOASSERTION-only must not count as declared"
2500        );
2501        assert_eq!(lm.noassertion_count, 1);
2502        assert_eq!(lm.valid_spdx_expressions, 1);
2503    }
2504
2505    /// spdx_ratio must never exceed 1.0: a component with several valid
2506    /// declared licenses previously pushed the per-entry numerator above the
2507    /// per-component denominator, blowing the 30-pt SPDX bonus past its cap.
2508    #[test]
2509    fn multi_license_component_does_not_inflate_spdx_bonus() {
2510        use crate::model::{Component, LicenseExpression, NormalizedSbom};
2511        let mut sbom = NormalizedSbom::default();
2512        let mut multi = Component::new("multi".to_string(), "m@1".to_string());
2513        for id in ["MIT", "Apache-2.0", "BSD-3-Clause"] {
2514            multi
2515                .licenses
2516                .add_declared(LicenseExpression::new(id.to_string()));
2517        }
2518        sbom.add_component(multi);
2519        // A second component with NO license at all.
2520        sbom.add_component(Component::new("bare".to_string(), "b@1".to_string()));
2521
2522        let lm = LicenseMetrics::from_sbom(&sbom);
2523        assert_eq!(lm.with_declared, 1);
2524        assert_eq!(lm.valid_spdx_expressions, 1, "per-component, not per-entry");
2525        assert!(
2526            lm.valid_spdx_expressions <= lm.with_declared,
2527            "spdx ratio numerator must not exceed its denominator"
2528        );
2529        // coverage 50% of 60 = 30, bonus 1.0*30 = 30 → 60. The old per-entry
2530        // count gave ratio 3.0 → bonus 90 → clamped 100 despite 50% coverage.
2531        let score = lm.quality_score(2);
2532        assert!(
2533            (score - 60.0).abs() < 0.01,
2534            "expected 60 (half coverage + full SPDX bonus), got {score}"
2535        );
2536    }
2537
2538    /// One component with many CPEs must not mask components with no
2539    /// identifier at all in the coverage score.
2540    #[test]
2541    fn multi_cpe_component_does_not_mask_identifierless_ones() {
2542        use crate::model::{Component, NormalizedSbom};
2543        let mut sbom = NormalizedSbom::default();
2544        let mut multi = Component::new("multi-cpe".to_string(), "mc@1".to_string());
2545        for i in 0..3 {
2546            multi
2547                .identifiers
2548                .cpe
2549                .push(format!("cpe:2.3:a:vendor:product{i}:1.0:*:*:*:*:*:*:*"));
2550        }
2551        sbom.add_component(multi);
2552        sbom.add_component(Component::new("bare-1".to_string(), "b1@1".to_string()));
2553        sbom.add_component(Component::new("bare-2".to_string(), "b2@1".to_string()));
2554
2555        let im = IdentifierMetrics::from_sbom(&sbom);
2556        assert_eq!(im.components_with_valid_id, 1);
2557        assert_eq!(im.valid_cpes, 1, "per-component CPE count");
2558        assert_eq!(im.missing_all_identifiers, 2);
2559        let score = im.quality_score(3);
2560        assert!(
2561            (score - 33.33).abs() < 0.1,
2562            "1/3 coverage expected, got {score} (old per-entry count gave 100)"
2563        );
2564    }
2565
2566    /// Deprecated/archived components are counted once, not twice, when the
2567    /// enrichment sets both the StalenessLevel and the boolean flag.
2568    #[test]
2569    fn lifecycle_does_not_double_count_deprecated() {
2570        use crate::model::{Component, NormalizedSbom, StalenessInfo, StalenessLevel};
2571        let mut sbom = NormalizedSbom::default();
2572        let mut c = Component::new("old-pkg".to_string(), "op@1".to_string());
2573        c.staleness = Some(StalenessInfo {
2574            level: StalenessLevel::Deprecated,
2575            last_published: None,
2576            is_deprecated: true, // enrichment sets both together
2577            is_archived: false,
2578            deprecation_message: None,
2579            days_since_update: None,
2580            latest_version: None,
2581        });
2582        sbom.add_component(c);
2583
2584        let lm = LifecycleMetrics::from_sbom(&sbom);
2585        assert_eq!(
2586            lm.deprecated_components, 1,
2587            "one deprecated component must count once, not twice"
2588        );
2589    }
2590
2591    /// Disclosing a bare vulnerability (no CVSS/CWE/remediation) earns the
2592    /// 40-point disclosure baseline, not 0 — scoring 0 made an SBOM better
2593    /// off stripping vulnerability data than disclosing it (non-monotonic).
2594    #[test]
2595    fn bare_vuln_disclosure_earns_baseline_credit() {
2596        use crate::model::{Component, NormalizedSbom, VulnerabilityRef, VulnerabilitySource};
2597        let mut sbom = NormalizedSbom::default();
2598        let mut c = Component::new("app".to_string(), "app@1".to_string());
2599        c.vulnerabilities.push(VulnerabilityRef::new(
2600            "CVE-2024-0001".to_string(),
2601            VulnerabilitySource::Osv,
2602        ));
2603        sbom.add_component(c);
2604
2605        let vm = VulnerabilityMetrics::from_sbom(&sbom);
2606        let score = vm.documentation_score().expect("vuln data present");
2607        assert!(
2608            (score - 40.0).abs() < 0.01,
2609            "bare disclosure must earn the 40-pt baseline, got {score}"
2610        );
2611
2612        // No vulnerability data at all stays N/A (None), not 40.
2613        let empty = NormalizedSbom::default();
2614        assert!(
2615            VulnerabilityMetrics::from_sbom(&empty)
2616                .documentation_score()
2617                .is_none()
2618        );
2619    }
2620
2621    /// The provenance score must be a pure function of the document — no
2622    /// wall-clock term. Freshness is display-only metadata.
2623    #[test]
2624    fn provenance_score_has_no_wall_clock_term() {
2625        let fresh = ProvenanceMetrics {
2626            is_fresh: true,
2627            ..base_provenance()
2628        };
2629        let stale = ProvenanceMetrics {
2630            is_fresh: false,
2631            ..base_provenance()
2632        };
2633        assert!(
2634            (fresh.quality_score(true) - stale.quality_score(true)).abs() < f32::EPSILON,
2635            "is_fresh must not affect the score"
2636        );
2637    }
2638
2639    fn base_provenance() -> ProvenanceMetrics {
2640        ProvenanceMetrics {
2641            has_tool_creator: true,
2642            has_tool_version: false,
2643            has_org_creator: false,
2644            has_contact_email: false,
2645            has_serial_number: false,
2646            has_document_name: false,
2647            timestamp_age_days: 0,
2648            timestamp_known: true,
2649            is_fresh: false,
2650            has_primary_component: false,
2651            lifecycle_phase: None,
2652            completeness_declaration: CompletenessDeclaration::Unknown,
2653            has_signature: false,
2654            has_citations: false,
2655            citations_count: 0,
2656        }
2657    }
2658
2659    /// A fully-cyclic dependency graph reaches >100% raw coverage via the
2660    /// N/(N-1) denominator; the clamp must land BEFORE the penalties so the
2661    /// cycle penalty is not silently absorbed.
2662    #[test]
2663    fn cyclic_graph_coverage_does_not_absorb_cycle_penalty() {
2664        use crate::model::{Component, DependencyEdge, DependencyType, NormalizedSbom};
2665        let mut sbom = NormalizedSbom::default();
2666        let n = 5;
2667        let mut ids = Vec::new();
2668        for i in 0..n {
2669            let c = Component::new(format!("c{i}"), format!("c{i}@1"));
2670            ids.push(c.canonical_id.clone());
2671            sbom.add_component(c);
2672        }
2673        // Ring: c0→c1→...→c4→c0 — every node has an outgoing edge.
2674        for i in 0..n {
2675            sbom.add_edge(DependencyEdge::new(
2676                ids[i].clone(),
2677                ids[(i + 1) % n].clone(),
2678                DependencyType::DependsOn,
2679            ));
2680        }
2681        let dm = DependencyMetrics::from_sbom(&sbom);
2682        assert!(dm.cycle_count >= 1, "ring must be detected as a cycle");
2683        let score = dm.quality_score(n);
2684        assert!(
2685            score <= 95.0,
2686            "cycle penalty must survive the clamp (raw coverage 125% \
2687             previously absorbed it), got {score}"
2688        );
2689    }
2690
2691    #[test]
2692    fn test_purl_validation() {
2693        assert!(is_valid_purl("pkg:npm/@scope/name@1.0.0"));
2694        assert!(is_valid_purl("pkg:maven/group/artifact@1.0"));
2695        assert!(!is_valid_purl("npm:something"));
2696        assert!(!is_valid_purl("invalid"));
2697    }
2698
2699    #[test]
2700    fn test_cpe_validation() {
2701        assert!(is_valid_cpe("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*"));
2702        assert!(is_valid_cpe("cpe:/a:vendor:product:1.0"));
2703        assert!(!is_valid_cpe("something:else"));
2704    }
2705
2706    /// License validity comes from the model's spdx-crate parse (stored in
2707    /// `LicenseExpression.is_valid_spdx`), not the old substring heuristic
2708    /// that accepted any string containing " OR "/" AND "/" WITH ".
2709    #[test]
2710    fn test_spdx_license_validation() {
2711        use crate::model::LicenseExpression;
2712        let valid = |e: &str| LicenseExpression::new(e.to_string()).is_valid_spdx;
2713        assert!(valid("MIT"));
2714        assert!(valid("Apache-2.0"));
2715        assert!(valid("MIT AND Apache-2.0"));
2716        assert!(valid("GPL-2.0 OR MIT"));
2717        assert!(valid("GPL-2.0-only WITH Classpath-exception-2.0"));
2718        assert!(valid("Zlib"));
2719        // The substring heuristic accepted these; real parsing must not.
2720        assert!(!valid("GARBAGE OR NONSENSE"));
2721        assert!(!valid("foo AND bar"));
2722        assert!(!valid("NOASSERTION"));
2723    }
2724
2725    #[test]
2726    fn test_strong_hash_classification() {
2727        assert!(is_strong_hash(&HashAlgorithm::Sha256));
2728        assert!(is_strong_hash(&HashAlgorithm::Sha3_256));
2729        assert!(is_strong_hash(&HashAlgorithm::Blake3));
2730        assert!(!is_strong_hash(&HashAlgorithm::Md5));
2731        assert!(!is_strong_hash(&HashAlgorithm::Sha1));
2732        assert!(!is_strong_hash(&HashAlgorithm::Other("custom".to_string())));
2733    }
2734
2735    #[test]
2736    fn test_deprecated_license_detection() {
2737        assert!(is_deprecated_spdx_license("GPL-2.0"));
2738        assert!(is_deprecated_spdx_license("LGPL-2.1"));
2739        assert!(is_deprecated_spdx_license("AGPL-3.0"));
2740        assert!(!is_deprecated_spdx_license("GPL-2.0-only"));
2741        assert!(!is_deprecated_spdx_license("MIT"));
2742        assert!(!is_deprecated_spdx_license("Apache-2.0"));
2743    }
2744
2745    #[test]
2746    fn test_restrictive_license_detection() {
2747        assert!(is_restrictive_license("GPL-3.0-only"));
2748        assert!(is_restrictive_license("LGPL-2.1-or-later"));
2749        assert!(is_restrictive_license("AGPL-3.0-only"));
2750        assert!(is_restrictive_license("EUPL-1.2"));
2751        assert!(is_restrictive_license("CC-BY-SA-4.0"));
2752        assert!(!is_restrictive_license("MIT"));
2753        assert!(!is_restrictive_license("Apache-2.0"));
2754        assert!(!is_restrictive_license("BSD-3-Clause"));
2755    }
2756
2757    #[test]
2758    fn test_hash_quality_score_no_components() {
2759        let metrics = HashQualityMetrics {
2760            components_with_any_hash: 0,
2761            components_with_strong_hash: 0,
2762            components_with_weak_only: 0,
2763            algorithm_distribution: BTreeMap::new(),
2764            total_hashes: 0,
2765            vendor_components_total: 0,
2766            vendor_components_with_hash: 0,
2767            vendor_components_with_strong_hash: 0,
2768        };
2769        assert_eq!(metrics.quality_score(0), 0.0);
2770    }
2771
2772    #[test]
2773    fn test_hash_quality_score_all_strong() {
2774        let metrics = HashQualityMetrics {
2775            components_with_any_hash: 10,
2776            components_with_strong_hash: 10,
2777            components_with_weak_only: 0,
2778            algorithm_distribution: BTreeMap::new(),
2779            total_hashes: 10,
2780            vendor_components_total: 0,
2781            vendor_components_with_hash: 0,
2782            vendor_components_with_strong_hash: 0,
2783        };
2784        assert_eq!(metrics.quality_score(10), 100.0);
2785    }
2786
2787    #[test]
2788    fn test_hash_quality_score_weak_only_penalty() {
2789        let metrics = HashQualityMetrics {
2790            components_with_any_hash: 10,
2791            components_with_strong_hash: 0,
2792            components_with_weak_only: 10,
2793            algorithm_distribution: BTreeMap::new(),
2794            total_hashes: 10,
2795            vendor_components_total: 0,
2796            vendor_components_with_hash: 0,
2797            vendor_components_with_strong_hash: 0,
2798        };
2799        // 60 (any) + 0 (strong) - 10 (weak penalty) = 50
2800        assert_eq!(metrics.quality_score(10), 50.0);
2801    }
2802
2803    #[test]
2804    fn test_lifecycle_no_enrichment_returns_none() {
2805        let metrics = LifecycleMetrics {
2806            eol_components: 0,
2807            stale_components: 0,
2808            deprecated_components: 0,
2809            archived_components: 0,
2810            outdated_components: 0,
2811            enriched_components: 0,
2812            enrichment_coverage: 0.0,
2813        };
2814        assert!(!metrics.has_data());
2815        assert!(metrics.quality_score().is_none());
2816    }
2817
2818    #[test]
2819    fn test_lifecycle_with_eol_penalty() {
2820        let metrics = LifecycleMetrics {
2821            eol_components: 2,
2822            stale_components: 0,
2823            deprecated_components: 0,
2824            archived_components: 0,
2825            outdated_components: 0,
2826            enriched_components: 10,
2827            enrichment_coverage: 100.0,
2828        };
2829        // 100 - 30 (2 * 15) = 70
2830        assert_eq!(metrics.quality_score(), Some(70.0));
2831    }
2832
2833    #[test]
2834    fn test_cycle_detection_no_cycles() {
2835        let children: HashMap<&str, Vec<&str>> =
2836            HashMap::from([("a", vec!["b"]), ("b", vec!["c"])]);
2837        let all_nodes = vec!["a", "b", "c"];
2838        // Linear chain has no SCC with more than one node
2839        assert_eq!(detect_cycles(&all_nodes, &children), 0);
2840    }
2841
2842    #[test]
2843    fn test_cycle_detection_with_cycle() {
2844        let children: HashMap<&str, Vec<&str>> =
2845            HashMap::from([("a", vec!["b"]), ("b", vec!["c"]), ("c", vec!["a"])]);
2846        let all_nodes = vec!["a", "b", "c"];
2847        // a→b→c→a forms a single 3-node SCC = one cycle
2848        assert_eq!(detect_cycles(&all_nodes, &children), 1);
2849    }
2850
2851    #[test]
2852    fn test_cycle_detection_deep_linear_chain_no_overflow() {
2853        let n = 40_000usize;
2854        let names: Vec<String> = (0..n).map(|i| format!("node-{i}")).collect();
2855        let all_nodes: Vec<&str> = names.iter().map(String::as_str).collect();
2856        let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
2857        for w in all_nodes.windows(2) {
2858            children.entry(w[0]).or_default().push(w[1]);
2859        }
2860        assert_eq!(detect_cycles(&all_nodes, &children), 0);
2861    }
2862
2863    #[test]
2864    fn test_cycle_detection_diamond_dag() {
2865        let children: HashMap<&str, Vec<&str>> =
2866            HashMap::from([("a", vec!["b", "c"]), ("b", vec!["d"]), ("c", vec!["d"])]);
2867        let all_nodes = vec!["a", "b", "c", "d"];
2868        assert_eq!(detect_cycles(&all_nodes, &children), 0);
2869    }
2870
2871    #[test]
2872    fn test_cycle_detection_multi_back_edge_scc_counts_once() {
2873        let children: HashMap<&str, Vec<&str>> = HashMap::from([
2874            ("a", vec!["b", "c"]),
2875            ("b", vec!["a", "c"]),
2876            ("c", vec!["a", "b"]),
2877        ]);
2878        let all_nodes = vec!["a", "b", "c"];
2879        // One SCC with multiple back edges still counts as a single cycle
2880        assert_eq!(detect_cycles(&all_nodes, &children), 1);
2881    }
2882
2883    #[test]
2884    fn test_cycle_detection_self_loop_counts_once() {
2885        let children: HashMap<&str, Vec<&str>> =
2886            HashMap::from([("a", vec!["a", "b"]), ("b", vec!["c"])]);
2887        let all_nodes = vec!["a", "b", "c"];
2888        assert_eq!(detect_cycles(&all_nodes, &children), 1);
2889    }
2890
2891    #[test]
2892    fn test_quality_scorer_deep_chain_end_to_end() {
2893        use crate::model::{Component, DependencyEdge, DependencyType};
2894        use crate::quality::{QualityScorer, ScoringProfile};
2895
2896        let n = 40_000usize;
2897        let mut sbom = NormalizedSbom::default();
2898        let mut ids = Vec::with_capacity(n);
2899        for i in 0..n {
2900            let component = Component::new(format!("node-{i}"), format!("ref-{i}"));
2901            ids.push(component.canonical_id.clone());
2902            sbom.add_component(component);
2903        }
2904        for w in ids.windows(2) {
2905            sbom.add_edge(DependencyEdge::new(
2906                w[0].clone(),
2907                w[1].clone(),
2908                DependencyType::DependsOn,
2909            ));
2910        }
2911
2912        let report = QualityScorer::new(ScoringProfile::Standard).score(&sbom);
2913        assert!(!report.dependency_metrics.graph_analysis_skipped);
2914        assert_eq!(report.dependency_metrics.cycle_count, 0);
2915        assert_eq!(report.dependency_metrics.max_depth, Some(n - 1));
2916    }
2917
2918    #[test]
2919    fn test_depth_computation() {
2920        let children: HashMap<&str, Vec<&str>> =
2921            HashMap::from([("root", vec!["a", "b"]), ("a", vec!["c"])]);
2922        let roots = vec!["root"];
2923        let (max_d, avg_d) = compute_depth(&roots, &children);
2924        assert_eq!(max_d, Some(2)); // root -> a -> c
2925        assert!(avg_d.is_some());
2926    }
2927
2928    #[test]
2929    fn test_depth_empty_roots() {
2930        let children: HashMap<&str, Vec<&str>> = HashMap::new();
2931        let roots: Vec<&str> = vec![];
2932        let (max_d, avg_d) = compute_depth(&roots, &children);
2933        assert_eq!(max_d, None);
2934        assert_eq!(avg_d, None);
2935    }
2936
2937    #[test]
2938    fn test_provenance_quality_score() {
2939        let metrics = ProvenanceMetrics {
2940            has_tool_creator: true,
2941            has_tool_version: true,
2942            has_org_creator: true,
2943            has_contact_email: true,
2944            has_serial_number: true,
2945            has_document_name: true,
2946            timestamp_age_days: 10,
2947            timestamp_known: true,
2948            is_fresh: true,
2949            has_primary_component: true,
2950            lifecycle_phase: Some("build".to_string()),
2951            completeness_declaration: CompletenessDeclaration::Complete,
2952            has_signature: true,
2953            has_citations: true,
2954            citations_count: 3,
2955        };
2956        // All checks pass for CycloneDX
2957        assert_eq!(metrics.quality_score(true), 100.0);
2958    }
2959
2960    #[test]
2961    fn test_provenance_score_without_cyclonedx() {
2962        let metrics = ProvenanceMetrics {
2963            has_tool_creator: true,
2964            has_tool_version: true,
2965            has_org_creator: true,
2966            has_contact_email: true,
2967            has_serial_number: true,
2968            has_document_name: true,
2969            timestamp_age_days: 10,
2970            timestamp_known: true,
2971            is_fresh: true,
2972            has_primary_component: true,
2973            lifecycle_phase: None,
2974            completeness_declaration: CompletenessDeclaration::Complete,
2975            has_signature: true,
2976            has_citations: false,
2977            citations_count: 0,
2978        };
2979        // Lifecycle phase and citations excluded for non-CDX
2980        assert_eq!(metrics.quality_score(false), 100.0);
2981    }
2982
2983    #[test]
2984    fn test_complexity_empty_graph() {
2985        let (simplicity, level, factors) = compute_complexity(0, 0, 0, 0, 0, 0, 0);
2986        assert_eq!(simplicity, 100.0);
2987        assert_eq!(level, ComplexityLevel::Low);
2988        assert_eq!(factors.dependency_volume, 0.0);
2989    }
2990
2991    #[test]
2992    fn test_complexity_single_node() {
2993        // 1 component, no edges, no cycles, 1 orphan, 1 island
2994        let (simplicity, level, _) = compute_complexity(0, 1, 0, 0, 0, 1, 1);
2995        assert!(
2996            simplicity >= 80.0,
2997            "Single node simplicity {simplicity} should be >= 80"
2998        );
2999        assert_eq!(level, ComplexityLevel::Low);
3000    }
3001
3002    #[test]
3003    fn test_complexity_monotonic_edges() {
3004        // More edges should never increase simplicity
3005        let (s1, _, _) = compute_complexity(5, 10, 2, 3, 0, 1, 1);
3006        let (s2, _, _) = compute_complexity(20, 10, 2, 3, 0, 1, 1);
3007        assert!(
3008            s2 <= s1,
3009            "More edges should not increase simplicity: {s2} vs {s1}"
3010        );
3011    }
3012
3013    #[test]
3014    fn test_complexity_monotonic_cycles() {
3015        let (s1, _, _) = compute_complexity(10, 10, 2, 3, 0, 1, 1);
3016        let (s2, _, _) = compute_complexity(10, 10, 2, 3, 3, 1, 1);
3017        assert!(
3018            s2 <= s1,
3019            "More cycles should not increase simplicity: {s2} vs {s1}"
3020        );
3021    }
3022
3023    #[test]
3024    fn test_complexity_monotonic_depth() {
3025        let (s1, _, _) = compute_complexity(10, 10, 2, 3, 0, 1, 1);
3026        let (s2, _, _) = compute_complexity(10, 10, 10, 3, 0, 1, 1);
3027        assert!(
3028            s2 <= s1,
3029            "More depth should not increase simplicity: {s2} vs {s1}"
3030        );
3031    }
3032
3033    #[test]
3034    fn test_complexity_graph_skipped() {
3035        // When graph_analysis_skipped, DependencyMetrics should have None complexity fields.
3036        // We test compute_complexity separately; the from_sbom integration handles the None case.
3037        let (simplicity, _, _) = compute_complexity(100, 50, 5, 10, 2, 5, 3);
3038        assert!(simplicity >= 0.0 && simplicity <= 100.0);
3039    }
3040
3041    #[test]
3042    fn test_complexity_level_bands() {
3043        assert_eq!(ComplexityLevel::from_score(100.0), ComplexityLevel::Low);
3044        assert_eq!(ComplexityLevel::from_score(75.0), ComplexityLevel::Low);
3045        assert_eq!(ComplexityLevel::from_score(74.0), ComplexityLevel::Moderate);
3046        assert_eq!(ComplexityLevel::from_score(50.0), ComplexityLevel::Moderate);
3047        assert_eq!(ComplexityLevel::from_score(49.0), ComplexityLevel::High);
3048        assert_eq!(ComplexityLevel::from_score(25.0), ComplexityLevel::High);
3049        assert_eq!(ComplexityLevel::from_score(24.0), ComplexityLevel::VeryHigh);
3050        assert_eq!(ComplexityLevel::from_score(0.0), ComplexityLevel::VeryHigh);
3051    }
3052
3053    #[test]
3054    fn test_completeness_declaration_display() {
3055        assert_eq!(CompletenessDeclaration::Complete.to_string(), "complete");
3056        assert_eq!(
3057            CompletenessDeclaration::IncompleteFirstPartyOnly.to_string(),
3058            "incomplete (first-party only)"
3059        );
3060        assert_eq!(CompletenessDeclaration::Unknown.to_string(), "unknown");
3061    }
3062
3063    // ── CryptographyMetrics scoring tests ──
3064
3065    #[test]
3066    fn crypto_completeness_all_documented() {
3067        let m = CryptographyMetrics {
3068            algorithms_count: 4,
3069            algorithms_with_family: 4,
3070            algorithms_with_primitive: 4,
3071            algorithms_with_security_level: 4,
3072            ..Default::default()
3073        };
3074        let score = m.crypto_completeness_score();
3075        assert!(
3076            (score - 100.0).abs() < 0.1,
3077            "fully documented → 100, got {score}"
3078        );
3079    }
3080
3081    #[test]
3082    fn crypto_completeness_partial() {
3083        let m = CryptographyMetrics {
3084            algorithms_count: 4,
3085            algorithms_with_family: 2,         // 50%
3086            algorithms_with_primitive: 4,      // 100%
3087            algorithms_with_security_level: 0, // 0%
3088            ..Default::default()
3089        };
3090        // 0.5*40 + 1.0*30 + 0.0*30 = 20+30+0 = 50
3091        let score = m.crypto_completeness_score();
3092        assert!((score - 50.0).abs() < 0.1, "partial → 50, got {score}");
3093    }
3094
3095    #[test]
3096    fn crypto_identifier_full_oid_coverage() {
3097        let m = CryptographyMetrics {
3098            algorithms_count: 5,
3099            algorithms_with_oid: 5,
3100            ..Default::default()
3101        };
3102        assert!((m.crypto_identifier_score() - 100.0).abs() < 0.1);
3103    }
3104
3105    #[test]
3106    fn crypto_identifier_no_oids() {
3107        let m = CryptographyMetrics {
3108            algorithms_count: 5,
3109            algorithms_with_oid: 0,
3110            ..Default::default()
3111        };
3112        assert!((m.crypto_identifier_score() - 0.0).abs() < 0.1);
3113    }
3114
3115    #[test]
3116    fn algorithm_strength_weak_penalty() {
3117        let m = CryptographyMetrics {
3118            algorithms_count: 5,
3119            weak_algorithm_count: 2,
3120            ..Default::default()
3121        };
3122        // 100 - 2*15 = 70
3123        let score = m.algorithm_strength_score();
3124        assert!((score - 70.0).abs() < 0.1, "2 weak → 70, got {score}");
3125    }
3126
3127    #[test]
3128    fn algorithm_strength_quantum_vulnerable() {
3129        let m = CryptographyMetrics {
3130            algorithms_count: 10,
3131            quantum_vulnerable_count: 10,
3132            ..Default::default()
3133        };
3134        // 100 - (10/10)*30 = 70
3135        let score = m.algorithm_strength_score();
3136        assert!(
3137            (score - 70.0).abs() < 0.1,
3138            "all quantum vuln → 70, got {score}"
3139        );
3140    }
3141
3142    #[test]
3143    fn crypto_lifecycle_compromised_keys() {
3144        let m = CryptographyMetrics {
3145            keys_count: 3,
3146            keys_with_state: 3,
3147            keys_with_protection: 3,
3148            keys_with_lifecycle_dates: 3,
3149            compromised_keys: 1,
3150            ..Default::default()
3151        };
3152        let score = m.crypto_lifecycle_score();
3153        // With full key completeness: 100*0.5 + 100*0.5 = 100, then -20 penalty
3154        assert!(score < 85.0);
3155        assert!(score > 50.0);
3156    }
3157
3158    #[test]
3159    fn crypto_lifecycle_expired_certs() {
3160        let m = CryptographyMetrics {
3161            certificates_count: 4,
3162            certs_with_validity_dates: 4,
3163            expired_certificates: 2,
3164            expiring_soon_certificates: 1,
3165            ..Default::default()
3166        };
3167        let score = m.crypto_lifecycle_score();
3168        // 100 - 2*15 - 1*5 = 100 - 30 - 5 = 65
3169        assert!(score < 70.0);
3170    }
3171
3172    #[test]
3173    fn pqc_readiness_all_quantum_safe() {
3174        let m = CryptographyMetrics {
3175            algorithms_count: 5,
3176            quantum_safe_count: 5,
3177            hybrid_pqc_count: 2,
3178            weak_algorithm_count: 0,
3179            ..Default::default()
3180        };
3181        // (5/5)*60 + 15 + 25 = 100
3182        let score = m.pqc_readiness_score().expect("algorithms present");
3183        assert!(
3184            (score - 100.0).abs() < 0.1,
3185            "all safe + hybrid → 100, got {score}"
3186        );
3187    }
3188
3189    #[test]
3190    fn pqc_readiness_no_quantum_safe() {
3191        let m = CryptographyMetrics {
3192            algorithms_count: 5,
3193            quantum_safe_count: 0,
3194            hybrid_pqc_count: 0,
3195            weak_algorithm_count: 0,
3196            ..Default::default()
3197        };
3198        // 0*60 + 0 + 25 = 25
3199        let score = m.pqc_readiness_score().expect("algorithms present");
3200        assert!(
3201            (score - 25.0).abs() < 0.1,
3202            "no safe, no weak → 25, got {score}"
3203        );
3204    }
3205
3206    /// Zero algorithms is absence of evidence, not readiness: both quantum
3207    /// readiness scores must be `None` so renderers show "n/a" and the CBOM
3208    /// scorer redistributes the PQC weight instead of granting a free 100.
3209    #[test]
3210    fn readiness_scores_none_with_zero_algorithms() {
3211        let m = CryptographyMetrics {
3212            algorithms_count: 0,
3213            certificates_count: 2, // crypto data exists, just no algorithms
3214            ..Default::default()
3215        };
3216        assert!(m.quantum_readiness_score().is_none());
3217        assert!(m.pqc_readiness_score().is_none());
3218    }
3219
3220    #[test]
3221    fn crypto_dependency_all_resolved() {
3222        let m = CryptographyMetrics {
3223            certificates_count: 2,
3224            keys_count: 3,
3225            protocols_count: 1,
3226            certs_with_signature_algo_ref: 2,
3227            keys_with_algorithm_ref: 3,
3228            protocols_with_cipher_suites: 1,
3229            ..Default::default()
3230        };
3231        assert!((m.crypto_dependency_score() - 100.0).abs() < 0.1);
3232    }
3233
3234    #[test]
3235    fn crypto_dependency_none_resolved() {
3236        let m = CryptographyMetrics {
3237            certificates_count: 2,
3238            keys_count: 3,
3239            protocols_count: 1,
3240            ..Default::default()
3241        };
3242        assert!((m.crypto_dependency_score() - 0.0).abs() < 0.1);
3243    }
3244
3245    #[test]
3246    fn quality_score_none_when_no_crypto() {
3247        let m = CryptographyMetrics::default();
3248        assert!(m.quality_score().is_none());
3249    }
3250
3251    #[test]
3252    fn quantum_readiness_pct_zero_algorithms() {
3253        let m = CryptographyMetrics::default();
3254        assert!((m.quantum_readiness_pct() - 0.0).abs() < 0.01);
3255    }
3256
3257    /// A document with no timestamp (epoch sentinel) must NOT be counted as
3258    /// fresh, and its age must report as unknown (0), not ~20000 days — the
3259    /// old Utc::now() fallback silently granted freshness credit.
3260    #[test]
3261    fn provenance_missing_timestamp_is_not_fresh() {
3262        let cdx = r#"{"bomFormat":"CycloneDX","specVersion":"1.5",
3263            "components":[{"type":"library","name":"a","version":"1.0"}]}"#;
3264        let sbom = crate::parsers::parse_sbom_str(cdx).expect("parse");
3265        assert!(
3266            !sbom.document.has_known_timestamp(),
3267            "fixture must have no timestamp"
3268        );
3269        let prov = ProvenanceMetrics::from_sbom(&sbom);
3270        assert!(!prov.timestamp_known);
3271        assert!(!prov.is_fresh, "a timestamp-less SBOM must not be fresh");
3272        assert_eq!(
3273            prov.timestamp_age_days, 0,
3274            "unknown age must not leak a huge number"
3275        );
3276
3277        // A recently-timestamped document is fresh.
3278        let now = chrono::Utc::now().to_rfc3339();
3279        let cdx_ts = format!(
3280            r#"{{"bomFormat":"CycloneDX","specVersion":"1.5",
3281            "metadata":{{"timestamp":"{now}"}},
3282            "components":[{{"type":"library","name":"a","version":"1.0"}}]}}"#
3283        );
3284        let sbom_ts = crate::parsers::parse_sbom_str(&cdx_ts).expect("parse");
3285        let prov_ts = ProvenanceMetrics::from_sbom(&sbom_ts);
3286        assert!(prov_ts.timestamp_known && prov_ts.is_fresh);
3287
3288        // A FUTURE-dated document is known but must NOT read as fresh —
3289        // a bogus forward date is not "recently generated".
3290        let cdx_future = r#"{"bomFormat":"CycloneDX","specVersion":"1.5",
3291            "metadata":{"timestamp":"3999-01-01T00:00:00Z"},
3292            "components":[{"type":"library","name":"a","version":"1.0"}]}"#;
3293        let sbom_future = crate::parsers::parse_sbom_str(cdx_future).expect("parse");
3294        let prov_future = ProvenanceMetrics::from_sbom(&sbom_future);
3295        assert!(prov_future.timestamp_known);
3296        assert!(!prov_future.is_fresh, "future-dated must not be fresh");
3297    }
3298}