Skip to main content

use_cve/
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 CVE identifier is invalid.
9#[derive(Clone, Copy, Debug, Eq, PartialEq)]
10pub enum CveIdError {
11    /// The identifier is empty after trimming.
12    Empty,
13    /// The identifier does not start with uppercase `CVE`.
14    InvalidPrefix,
15    /// The identifier does not have the expected `CVE-YYYY-NNNN` shape.
16    InvalidFormat,
17    /// The year is not exactly four ASCII digits.
18    InvalidYear,
19    /// The sequence is not at least four ASCII digits.
20    InvalidSequence,
21}
22
23impl fmt::Display for CveIdError {
24    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
25        match self {
26            Self::Empty => formatter.write_str("CVE identifier cannot be empty"),
27            Self::InvalidPrefix => {
28                formatter.write_str("CVE identifier must start with uppercase CVE")
29            }
30            Self::InvalidFormat => formatter.write_str("CVE identifier must match CVE-YYYY-NNNN"),
31            Self::InvalidYear => formatter.write_str("CVE year must be exactly four digits"),
32            Self::InvalidSequence => {
33                formatter.write_str("CVE sequence must be at least four digits")
34            }
35        }
36    }
37}
38
39impl Error for CveIdError {}
40
41/// A four-digit CVE year.
42#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
43pub struct CveYear(u16);
44
45impl CveYear {
46    /// Creates a CVE year from a four-digit year value.
47    pub fn new(value: u16) -> Result<Self, CveIdError> {
48        if (1000..=9999).contains(&value) {
49            Ok(Self(value))
50        } else {
51            Err(CveIdError::InvalidYear)
52        }
53    }
54
55    /// Returns the numeric year.
56    #[must_use]
57    pub const fn value(self) -> u16 {
58        self.0
59    }
60}
61
62impl fmt::Display for CveYear {
63    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
64        write!(formatter, "{:04}", self.0)
65    }
66}
67
68impl FromStr for CveYear {
69    type Err = CveIdError;
70
71    fn from_str(input: &str) -> Result<Self, Self::Err> {
72        parse_year(input)
73    }
74}
75
76/// A CVE sequence component with at least four digits.
77#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
78pub struct CveSequence(String);
79
80impl CveSequence {
81    /// Creates a CVE sequence from ASCII digits.
82    pub fn new(input: impl AsRef<str>) -> Result<Self, CveIdError> {
83        let trimmed = input.as_ref().trim();
84        if trimmed.len() < 4 || !trimmed.bytes().all(|byte| byte.is_ascii_digit()) {
85            return Err(CveIdError::InvalidSequence);
86        }
87        Ok(Self(trimmed.to_owned()))
88    }
89
90    /// Returns the stored sequence.
91    #[must_use]
92    pub fn as_str(&self) -> &str {
93        &self.0
94    }
95}
96
97impl fmt::Display for CveSequence {
98    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
99        formatter.write_str(self.as_str())
100    }
101}
102
103impl FromStr for CveSequence {
104    type Err = CveIdError;
105
106    fn from_str(input: &str) -> Result<Self, Self::Err> {
107        Self::new(input)
108    }
109}
110
111/// A validated CVE identifier such as `CVE-2024-12345`.
112#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
113pub struct CveId {
114    value: String,
115    year: CveYear,
116    sequence: CveSequence,
117}
118
119impl CveId {
120    /// Creates a validated CVE identifier.
121    pub fn new(input: impl AsRef<str>) -> Result<Self, CveIdError> {
122        let trimmed = input.as_ref().trim();
123        if trimmed.is_empty() {
124            return Err(CveIdError::Empty);
125        }
126        let mut parts = trimmed.split('-');
127        let prefix = parts.next().ok_or(CveIdError::InvalidFormat)?;
128        let year = parts.next().ok_or(CveIdError::InvalidFormat)?;
129        let sequence = parts.next().ok_or(CveIdError::InvalidFormat)?;
130        if parts.next().is_some() {
131            return Err(CveIdError::InvalidFormat);
132        }
133        if prefix != "CVE" {
134            return Err(CveIdError::InvalidPrefix);
135        }
136        let year = parse_year(year)?;
137        let sequence = CveSequence::new(sequence)?;
138        Ok(Self {
139            value: trimmed.to_owned(),
140            year,
141            sequence,
142        })
143    }
144
145    /// Returns the canonical identifier string.
146    #[must_use]
147    pub fn as_str(&self) -> &str {
148        &self.value
149    }
150
151    /// Returns the parsed CVE year.
152    #[must_use]
153    pub const fn year(&self) -> CveYear {
154        self.year
155    }
156
157    /// Returns the parsed CVE sequence.
158    #[must_use]
159    pub const fn sequence(&self) -> &CveSequence {
160        &self.sequence
161    }
162}
163
164impl fmt::Display for CveId {
165    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
166        formatter.write_str(self.as_str())
167    }
168}
169
170impl FromStr for CveId {
171    type Err = CveIdError;
172
173    fn from_str(input: &str) -> Result<Self, Self::Err> {
174        Self::new(input)
175    }
176}
177
178impl TryFrom<&str> for CveId {
179    type Error = CveIdError;
180
181    fn try_from(value: &str) -> Result<Self, Self::Error> {
182        Self::new(value)
183    }
184}
185
186/// CVE publication status metadata.
187#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
188pub enum CveStatus {
189    Reserved,
190    Published,
191    Rejected,
192    Disputed,
193    Unknown,
194}
195
196impl CveStatus {
197    /// Returns the stable status label.
198    #[must_use]
199    pub const fn as_str(self) -> &'static str {
200        match self {
201            Self::Reserved => "reserved",
202            Self::Published => "published",
203            Self::Rejected => "rejected",
204            Self::Disputed => "disputed",
205            Self::Unknown => "unknown",
206        }
207    }
208}
209
210impl fmt::Display for CveStatus {
211    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
212        formatter.write_str(self.as_str())
213    }
214}
215
216/// A lightweight CVE reference URL or label.
217#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
218pub struct CveReference(String);
219
220impl CveReference {
221    /// Creates a non-empty CVE reference label.
222    pub fn new(input: impl AsRef<str>) -> Result<Self, CveTextError> {
223        non_empty(input.as_ref()).map(Self)
224    }
225
226    /// Returns the stored reference label.
227    #[must_use]
228    pub fn as_str(&self) -> &str {
229        &self.0
230    }
231}
232
233impl fmt::Display for CveReference {
234    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
235        formatter.write_str(self.as_str())
236    }
237}
238
239/// A lightweight source label for CVE metadata.
240#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
241pub struct CveSource(String);
242
243impl CveSource {
244    /// Creates a non-empty CVE source label.
245    pub fn new(input: impl AsRef<str>) -> Result<Self, CveTextError> {
246        non_empty(input.as_ref()).map(Self)
247    }
248
249    /// Returns the stored source label.
250    #[must_use]
251    pub fn as_str(&self) -> &str {
252        &self.0
253    }
254}
255
256impl fmt::Display for CveSource {
257    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
258        formatter.write_str(self.as_str())
259    }
260}
261
262/// CVE record kind metadata.
263#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
264pub enum CveRecordKind {
265    Vulnerability,
266    Rejection,
267    Advisory,
268    Reference,
269}
270
271impl CveRecordKind {
272    /// Returns the stable record-kind label.
273    #[must_use]
274    pub const fn as_str(self) -> &'static str {
275        match self {
276            Self::Vulnerability => "vulnerability",
277            Self::Rejection => "rejection",
278            Self::Advisory => "advisory",
279            Self::Reference => "reference",
280        }
281    }
282}
283
284impl fmt::Display for CveRecordKind {
285    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
286        formatter.write_str(self.as_str())
287    }
288}
289
290/// Error returned when CVE text metadata is empty.
291#[derive(Clone, Copy, Debug, Eq, PartialEq)]
292pub enum CveTextError {
293    Empty,
294}
295
296impl fmt::Display for CveTextError {
297    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
298        formatter.write_str("CVE metadata text cannot be empty")
299    }
300}
301
302impl Error for CveTextError {}
303
304fn parse_year(input: &str) -> Result<CveYear, CveIdError> {
305    if input.len() != 4 || !input.bytes().all(|byte| byte.is_ascii_digit()) {
306        return Err(CveIdError::InvalidYear);
307    }
308    let value = input
309        .parse::<u16>()
310        .map_err(|_error| CveIdError::InvalidYear)?;
311    CveYear::new(value)
312}
313
314fn non_empty(input: &str) -> Result<String, CveTextError> {
315    let trimmed = input.trim();
316    if trimmed.is_empty() {
317        Err(CveTextError::Empty)
318    } else {
319        Ok(trimmed.to_owned())
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::{CveId, CveIdError, CveRecordKind, CveSequence, CveStatus, CveYear};
326
327    #[test]
328    fn parses_valid_cve_id() {
329        let id: CveId = "CVE-2024-12345".parse().expect("valid CVE should parse");
330
331        assert_eq!(id.as_str(), "CVE-2024-12345");
332        assert_eq!(id.year().value(), 2024);
333        assert_eq!(id.sequence().as_str(), "12345");
334        assert_eq!(id.to_string(), "CVE-2024-12345");
335    }
336
337    #[test]
338    fn rejects_invalid_cve_ids() {
339        assert_eq!(CveId::new(""), Err(CveIdError::Empty));
340        assert_eq!(CveId::new("cve-2024-1234"), Err(CveIdError::InvalidPrefix));
341        assert_eq!(CveId::new("CVE-24-1234"), Err(CveIdError::InvalidYear));
342        assert_eq!(CveId::new("CVE-2024-123"), Err(CveIdError::InvalidSequence));
343        assert_eq!(
344            CveId::new("CVE-2024-12A4"),
345            Err(CveIdError::InvalidSequence)
346        );
347    }
348
349    #[test]
350    fn parses_components() {
351        assert_eq!(CveYear::new(2024).expect("year").to_string(), "2024");
352        assert_eq!(CveSequence::new("0001").expect("sequence").as_str(), "0001");
353    }
354
355    #[test]
356    fn displays_status_and_record_kind() {
357        assert_eq!(CveStatus::Published.to_string(), "published");
358        assert_eq!(CveRecordKind::Vulnerability.to_string(), "vulnerability");
359    }
360}