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 CveIdError {
11 Empty,
13 InvalidPrefix,
15 InvalidFormat,
17 InvalidYear,
19 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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
43pub struct CveYear(u16);
44
45impl CveYear {
46 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 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
78pub struct CveSequence(String);
79
80impl CveSequence {
81 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 #[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#[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 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 #[must_use]
147 pub fn as_str(&self) -> &str {
148 &self.value
149 }
150
151 #[must_use]
153 pub const fn year(&self) -> CveYear {
154 self.year
155 }
156
157 #[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#[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 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
218pub struct CveReference(String);
219
220impl CveReference {
221 pub fn new(input: impl AsRef<str>) -> Result<Self, CveTextError> {
223 non_empty(input.as_ref()).map(Self)
224 }
225
226 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
241pub struct CveSource(String);
242
243impl CveSource {
244 pub fn new(input: impl AsRef<str>) -> Result<Self, CveTextError> {
246 non_empty(input.as_ref()).map(Self)
247 }
248
249 #[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#[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 #[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#[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}