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#[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#[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#[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#[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#[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
79pub enum CvssAttackVector {
80 Network,
81 Adjacent,
82 Local,
83 Physical,
84}
85
86#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
88pub enum CvssAttackComplexity {
89 Low,
90 High,
91}
92
93#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
95pub enum CvssPrivilegesRequired {
96 None,
97 Low,
98 High,
99}
100
101#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
103pub enum CvssUserInteraction {
104 None,
105 Required,
106}
107
108#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
110pub enum CvssScope {
111 Unchanged,
112 Changed,
113}
114
115#[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 #[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#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
210pub struct CvssScore(f32);
211
212impl CvssScore {
213 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 #[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#[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 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 #[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}