Skip to main content

use_cvss/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3#![allow(clippy::module_name_repetitions)]
4
5use core::{fmt, str::FromStr};
6use std::error::Error;
7
8/// Error returned when a CVSS score is invalid.
9#[derive(Clone, Copy, Debug, Eq, PartialEq)]
10pub enum CvssScoreError {
11    NonFinite,
12    OutOfRange,
13}
14
15impl fmt::Display for CvssScoreError {
16    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
17        match self {
18            Self::NonFinite => formatter.write_str("CVSS score must be finite"),
19            Self::OutOfRange => formatter.write_str("CVSS score must be between 0.0 and 10.0"),
20        }
21    }
22}
23
24impl Error for CvssScoreError {}
25
26/// Error returned when CVSS text metadata is invalid.
27#[derive(Clone, Copy, Debug, Eq, PartialEq)]
28pub enum CvssTextError {
29    Empty,
30}
31
32impl fmt::Display for CvssTextError {
33    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
34        formatter.write_str("CVSS metadata text cannot be empty")
35    }
36}
37
38impl Error for CvssTextError {}
39
40/// Error returned when a CVSS label cannot be parsed.
41#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42pub enum CvssParseError {
43    Empty,
44    Unknown,
45}
46
47impl fmt::Display for CvssParseError {
48    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self {
50            Self::Empty => formatter.write_str("CVSS label cannot be empty"),
51            Self::Unknown => formatter.write_str("unknown CVSS label"),
52        }
53    }
54}
55
56impl Error for CvssParseError {}
57
58/// CVSS version labels.
59#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
60pub enum CvssVersion {
61    V2,
62    V3_0,
63    V3_1,
64    V4_0,
65}
66
67/// CVSS severity labels.
68#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
69pub enum CvssSeverity {
70    None,
71    Low,
72    Medium,
73    High,
74    Critical,
75}
76
77/// CVSS attack-vector labels.
78#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
79pub enum CvssAttackVector {
80    Network,
81    Adjacent,
82    Local,
83    Physical,
84}
85
86/// CVSS attack-complexity labels.
87#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
88pub enum CvssAttackComplexity {
89    Low,
90    High,
91}
92
93/// CVSS privileges-required labels.
94#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
95pub enum CvssPrivilegesRequired {
96    None,
97    Low,
98    High,
99}
100
101/// CVSS user-interaction labels.
102#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
103pub enum CvssUserInteraction {
104    None,
105    Required,
106}
107
108/// CVSS scope labels.
109#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
110pub enum CvssScope {
111    Unchanged,
112    Changed,
113}
114
115/// CVSS impact-level labels.
116#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
117pub enum CvssImpactLevel {
118    None,
119    Low,
120    High,
121}
122
123macro_rules! label_enum {
124    ($name:ident { $($variant:ident => $label:literal),+ $(,)? }) => {
125        impl $name {
126            /// Returns the stable label.
127            #[must_use]
128            pub const fn as_str(self) -> &'static str {
129                match self {
130                    $(Self::$variant => $label,)+
131                }
132            }
133        }
134
135        impl fmt::Display for $name {
136            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
137                formatter.write_str(self.as_str())
138            }
139        }
140
141        impl FromStr for $name {
142            type Err = CvssParseError;
143
144            fn from_str(input: &str) -> Result<Self, Self::Err> {
145                let trimmed = input.trim();
146                if trimmed.is_empty() {
147                    return Err(CvssParseError::Empty);
148                }
149                let normalized = trimmed.to_ascii_lowercase();
150                match normalized.as_str() {
151                    $($label => Ok(Self::$variant),)+
152                    _ => Err(CvssParseError::Unknown),
153                }
154            }
155        }
156    };
157}
158
159label_enum!(CvssVersion {
160    V2 => "2.0",
161    V3_0 => "3.0",
162    V3_1 => "3.1",
163    V4_0 => "4.0",
164});
165
166label_enum!(CvssSeverity {
167    None => "none",
168    Low => "low",
169    Medium => "medium",
170    High => "high",
171    Critical => "critical",
172});
173
174label_enum!(CvssAttackVector {
175    Network => "network",
176    Adjacent => "adjacent",
177    Local => "local",
178    Physical => "physical",
179});
180
181label_enum!(CvssAttackComplexity {
182    Low => "low",
183    High => "high",
184});
185
186label_enum!(CvssPrivilegesRequired {
187    None => "none",
188    Low => "low",
189    High => "high",
190});
191
192label_enum!(CvssUserInteraction {
193    None => "none",
194    Required => "required",
195});
196
197label_enum!(CvssScope {
198    Unchanged => "unchanged",
199    Changed => "changed",
200});
201
202label_enum!(CvssImpactLevel {
203    None => "none",
204    Low => "low",
205    High => "high",
206});
207
208/// A validated CVSS base score.
209#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
210pub struct CvssScore(f32);
211
212impl CvssScore {
213    /// Creates a score in the inclusive `0.0..=10.0` range.
214    pub fn new(value: f32) -> Result<Self, CvssScoreError> {
215        if !value.is_finite() {
216            return Err(CvssScoreError::NonFinite);
217        }
218        if !(0.0..=10.0).contains(&value) {
219            return Err(CvssScoreError::OutOfRange);
220        }
221        Ok(Self(value))
222    }
223
224    /// Returns the stored score.
225    #[must_use]
226    pub const fn value(self) -> f32 {
227        self.0
228    }
229}
230
231impl fmt::Display for CvssScore {
232    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
233        write!(formatter, "{:.1}", self.0)
234    }
235}
236
237/// Returns the CVSS severity bucket for a validated score.
238#[must_use]
239pub fn severity_from_score(score: CvssScore) -> CvssSeverity {
240    let value = score.value();
241    if value == 0.0 {
242        CvssSeverity::None
243    } else if value < 4.0 {
244        CvssSeverity::Low
245    } else if value < 7.0 {
246        CvssSeverity::Medium
247    } else if value < 9.0 {
248        CvssSeverity::High
249    } else {
250        CvssSeverity::Critical
251    }
252}
253
254macro_rules! text_newtype {
255    ($name:ident) => {
256        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
257        pub struct $name(String);
258
259        impl $name {
260            /// Creates non-empty CVSS text metadata.
261            pub fn new(input: impl AsRef<str>) -> Result<Self, CvssTextError> {
262                let trimmed = input.as_ref().trim();
263                if trimmed.is_empty() {
264                    Err(CvssTextError::Empty)
265                } else {
266                    Ok(Self(trimmed.to_owned()))
267                }
268            }
269
270            /// Returns the stored text.
271            #[must_use]
272            pub fn as_str(&self) -> &str {
273                &self.0
274            }
275        }
276
277        impl fmt::Display for $name {
278            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
279                formatter.write_str(self.as_str())
280            }
281        }
282
283        impl FromStr for $name {
284            type Err = CvssTextError;
285
286            fn from_str(input: &str) -> Result<Self, Self::Err> {
287                Self::new(input)
288            }
289        }
290
291        impl TryFrom<&str> for $name {
292            type Error = CvssTextError;
293
294            fn try_from(value: &str) -> Result<Self, Self::Error> {
295                Self::new(value)
296            }
297        }
298    };
299}
300
301text_newtype!(CvssVector);
302text_newtype!(CvssMetricName);
303text_newtype!(CvssMetricValue);
304
305#[cfg(test)]
306mod tests {
307    use super::{
308        CvssAttackVector, CvssScore, CvssScoreError, CvssSeverity, CvssVector, severity_from_score,
309    };
310
311    #[test]
312    fn validates_score_range() {
313        assert_eq!(CvssScore::new(0.0).expect("score").value(), 0.0);
314        assert_eq!(CvssScore::new(10.0).expect("score").value(), 10.0);
315        assert_eq!(CvssScore::new(-0.1), Err(CvssScoreError::OutOfRange));
316        assert_eq!(CvssScore::new(10.1), Err(CvssScoreError::OutOfRange));
317        assert_eq!(CvssScore::new(f32::NAN), Err(CvssScoreError::NonFinite));
318    }
319
320    #[test]
321    fn maps_severity_from_score() {
322        assert_eq!(
323            severity_from_score(CvssScore::new(0.0).expect("score")),
324            CvssSeverity::None
325        );
326        assert_eq!(
327            severity_from_score(CvssScore::new(3.9).expect("score")),
328            CvssSeverity::Low
329        );
330        assert_eq!(
331            severity_from_score(CvssScore::new(6.9).expect("score")),
332            CvssSeverity::Medium
333        );
334        assert_eq!(
335            severity_from_score(CvssScore::new(8.9).expect("score")),
336            CvssSeverity::High
337        );
338        assert_eq!(
339            severity_from_score(CvssScore::new(9.0).expect("score")),
340            CvssSeverity::Critical
341        );
342    }
343
344    #[test]
345    fn validates_vector_text() {
346        let vector = CvssVector::new("CVSS:3.1/AV:N/AC:L").expect("vector");
347
348        assert_eq!(vector.as_str(), "CVSS:3.1/AV:N/AC:L");
349        assert!(CvssVector::new(" ").is_err());
350    }
351
352    #[test]
353    fn parses_and_displays_labels() {
354        assert_eq!(
355            "network".parse::<CvssAttackVector>().expect("label"),
356            CvssAttackVector::Network
357        );
358        assert_eq!(CvssSeverity::Critical.to_string(), "critical");
359    }
360}