Skip to main content

deprot_core/
score.rs

1//! Aggregation: turn a set of [`Signal`]s into a final [`Score`] — a 0–100 value, a letter
2//! [`Grade`], and a [`Tier`] verdict.
3//!
4//! The base value is a weight-normalized average of whatever signals were computable. On top of
5//! that, a few conditions **force** the verdict to `Risky` regardless of the average, because they
6//! represent facts a good average should never wash out: an unfixed high/critical vulnerability, a
7//! registry deprecation, or an archived upstream.
8
9use crate::facts::{Facts, Severity};
10use crate::signals::{self, Signal};
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13
14/// Letter grade derived from the 0–100 score.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16pub enum Grade {
17    A,
18    B,
19    C,
20    D,
21    F,
22}
23
24impl Grade {
25    fn from_value(v: u8) -> Grade {
26        match v {
27            90..=100 => Grade::A,
28            75..=89 => Grade::B,
29            60..=74 => Grade::C,
30            40..=59 => Grade::D,
31            _ => Grade::F,
32        }
33    }
34
35    /// Single-character label.
36    pub fn as_str(self) -> &'static str {
37        match self {
38            Grade::A => "A",
39            Grade::B => "B",
40            Grade::C => "C",
41            Grade::D => "D",
42            Grade::F => "F",
43        }
44    }
45}
46
47/// The headline verdict tier. This is what `--fail-on` gates against and what the summary banner
48/// reports.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
50#[serde(rename_all = "lowercase")]
51pub enum Tier {
52    Ok,
53    Caution,
54    Risky,
55}
56
57impl Tier {
58    /// Lowercase label.
59    pub fn as_str(self) -> &'static str {
60        match self {
61            Tier::Ok => "ok",
62            Tier::Caution => "caution",
63            Tier::Risky => "risky",
64        }
65    }
66
67    /// Parse from a CLI string (`ok` / `caution` / `risky`).
68    pub fn parse(s: &str) -> Option<Tier> {
69        match s.trim().to_lowercase().as_str() {
70            "ok" => Some(Tier::Ok),
71            "caution" => Some(Tier::Caution),
72            "risky" => Some(Tier::Risky),
73            _ => None,
74        }
75    }
76}
77
78/// The full result of scoring one dependency.
79#[derive(Debug, Clone, PartialEq)]
80pub struct Score {
81    /// 0–100 health value.
82    pub value: u8,
83    /// Letter grade derived from `value`.
84    pub grade: Grade,
85    /// Headline verdict (may be forced worse than `value` alone implies).
86    pub tier: Tier,
87    /// The signals that produced this score, in display order.
88    pub signals: Vec<Signal>,
89    /// Human-readable reasons the tier was *forced* (empty when the tier follows the value).
90    pub forced_reasons: Vec<String>,
91}
92
93/// Score value thresholds for the (unforced) tier mapping.
94const CAUTION_BELOW: u8 = 80;
95const RISKY_BELOW: u8 = 55;
96
97/// Score floor for a mature, popular, otherwise-clean package (the maturity dampener). Lands at a
98/// low `C` / `CAUTION` — "stable but stale", never a false `OK`, never a rotting `F`.
99const MATURE_FLOOR: u8 = 70;
100
101/// Score one dependency from its [`Facts`] at reference time `now`.
102///
103/// Pure and deterministic: no I/O, no ambient clock. Pass the same inputs, get the same output —
104/// which is exactly what the engine's unit tests and replay fixtures rely on.
105pub fn score(facts: &Facts, now: DateTime<Utc>) -> Score {
106    let signals = signals::all(facts, now);
107
108    let total_weight: f64 = signals.iter().map(|s| s.weight).sum();
109    let mut value = if total_weight <= 0.0 {
110        50 // no signals at all: neutral, not a false "perfect"
111    } else {
112        let weighted: f64 = signals.iter().map(|s| s.score * s.weight).sum();
113        (weighted / total_weight * 100.0).round() as u8
114    };
115
116    // Maturity dampener. A widely-used, complete library that simply hasn't shipped a release
117    // lately is *stable*, not *rotting* — yet staleness + a missing OpenSSF Scorecard alone can drag
118    // such a package to a D/F. When nothing is actively wrong (no advisories, not deprecated, not
119    // archived, and the package resolved), a mature/popular package earns a score floor so those
120    // two soft signals can't, by themselves, read as failing. It only ever lifts a score.
121    let clean =
122        !facts.is_unresolved() && !facts.deprecated && !facts.archived && facts.vulns.is_empty();
123    let mature = facts.total_versions.unwrap_or(0) >= 10 || facts.stars.unwrap_or(0) >= 1_000;
124    if clean && mature {
125        value = value.max(MATURE_FLOOR);
126    }
127
128    // Base tier from the value.
129    let mut tier = if value < RISKY_BELOW {
130        Tier::Risky
131    } else if value < CAUTION_BELOW {
132        Tier::Caution
133    } else {
134        Tier::Ok
135    };
136
137    // Forced escalations — facts that must not be averaged away.
138    let mut forced_reasons = Vec::new();
139    // A package we could not resolve at all (unknown/typosquatted name, a registry 404, or a
140    // failed lookup) must never be presented as healthy: absence of data is not absence of risk.
141    // Force RISKY with an explicit reason so a CI gate (`--fail-on risky`) surfaces it instead of
142    // the remaining default signals averaging into a reassuring grade.
143    if facts.is_unresolved() {
144        forced_reasons.push(
145            "no registry data — package unknown or lookup failed; risk not assessable".to_string(),
146        );
147    }
148    if facts.deprecated {
149        forced_reasons.push("package is deprecated".to_string());
150    }
151    if facts.archived {
152        forced_reasons.push("source repository is archived".to_string());
153    }
154    if let Some(v) = facts.vulns.iter().find(|v| v.severity() >= Severity::High) {
155        forced_reasons.push(format!("unresolved high/critical advisory {}", v.id));
156    }
157    if !forced_reasons.is_empty() {
158        tier = Tier::Risky;
159    }
160
161    Score {
162        value,
163        grade: Grade::from_value(value),
164        tier,
165        signals,
166        forced_reasons,
167    }
168}