#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
#![allow(clippy::module_name_repetitions)]
use core::{fmt, str::FromStr};
use std::error::Error;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CveIdError {
Empty,
InvalidPrefix,
InvalidFormat,
InvalidYear,
InvalidSequence,
}
impl fmt::Display for CveIdError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => formatter.write_str("CVE identifier cannot be empty"),
Self::InvalidPrefix => {
formatter.write_str("CVE identifier must start with uppercase CVE")
}
Self::InvalidFormat => formatter.write_str("CVE identifier must match CVE-YYYY-NNNN"),
Self::InvalidYear => formatter.write_str("CVE year must be exactly four digits"),
Self::InvalidSequence => {
formatter.write_str("CVE sequence must be at least four digits")
}
}
}
}
impl Error for CveIdError {}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct CveYear(u16);
impl CveYear {
pub fn new(value: u16) -> Result<Self, CveIdError> {
if (1000..=9999).contains(&value) {
Ok(Self(value))
} else {
Err(CveIdError::InvalidYear)
}
}
#[must_use]
pub const fn value(self) -> u16 {
self.0
}
}
impl fmt::Display for CveYear {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{:04}", self.0)
}
}
impl FromStr for CveYear {
type Err = CveIdError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
parse_year(input)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct CveSequence(String);
impl CveSequence {
pub fn new(input: impl AsRef<str>) -> Result<Self, CveIdError> {
let trimmed = input.as_ref().trim();
if trimmed.len() < 4 || !trimmed.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(CveIdError::InvalidSequence);
}
Ok(Self(trimmed.to_owned()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for CveSequence {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for CveSequence {
type Err = CveIdError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
Self::new(input)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct CveId {
value: String,
year: CveYear,
sequence: CveSequence,
}
impl CveId {
pub fn new(input: impl AsRef<str>) -> Result<Self, CveIdError> {
let trimmed = input.as_ref().trim();
if trimmed.is_empty() {
return Err(CveIdError::Empty);
}
let mut parts = trimmed.split('-');
let prefix = parts.next().ok_or(CveIdError::InvalidFormat)?;
let year = parts.next().ok_or(CveIdError::InvalidFormat)?;
let sequence = parts.next().ok_or(CveIdError::InvalidFormat)?;
if parts.next().is_some() {
return Err(CveIdError::InvalidFormat);
}
if prefix != "CVE" {
return Err(CveIdError::InvalidPrefix);
}
let year = parse_year(year)?;
let sequence = CveSequence::new(sequence)?;
Ok(Self {
value: trimmed.to_owned(),
year,
sequence,
})
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.value
}
#[must_use]
pub const fn year(&self) -> CveYear {
self.year
}
#[must_use]
pub const fn sequence(&self) -> &CveSequence {
&self.sequence
}
}
impl fmt::Display for CveId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for CveId {
type Err = CveIdError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
Self::new(input)
}
}
impl TryFrom<&str> for CveId {
type Error = CveIdError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum CveStatus {
Reserved,
Published,
Rejected,
Disputed,
Unknown,
}
impl CveStatus {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Reserved => "reserved",
Self::Published => "published",
Self::Rejected => "rejected",
Self::Disputed => "disputed",
Self::Unknown => "unknown",
}
}
}
impl fmt::Display for CveStatus {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct CveReference(String);
impl CveReference {
pub fn new(input: impl AsRef<str>) -> Result<Self, CveTextError> {
non_empty(input.as_ref()).map(Self)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for CveReference {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct CveSource(String);
impl CveSource {
pub fn new(input: impl AsRef<str>) -> Result<Self, CveTextError> {
non_empty(input.as_ref()).map(Self)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for CveSource {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum CveRecordKind {
Vulnerability,
Rejection,
Advisory,
Reference,
}
impl CveRecordKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Vulnerability => "vulnerability",
Self::Rejection => "rejection",
Self::Advisory => "advisory",
Self::Reference => "reference",
}
}
}
impl fmt::Display for CveRecordKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CveTextError {
Empty,
}
impl fmt::Display for CveTextError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("CVE metadata text cannot be empty")
}
}
impl Error for CveTextError {}
fn parse_year(input: &str) -> Result<CveYear, CveIdError> {
if input.len() != 4 || !input.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(CveIdError::InvalidYear);
}
let value = input
.parse::<u16>()
.map_err(|_error| CveIdError::InvalidYear)?;
CveYear::new(value)
}
fn non_empty(input: &str) -> Result<String, CveTextError> {
let trimmed = input.trim();
if trimmed.is_empty() {
Err(CveTextError::Empty)
} else {
Ok(trimmed.to_owned())
}
}
#[cfg(test)]
mod tests {
use super::{CveId, CveIdError, CveRecordKind, CveSequence, CveStatus, CveYear};
#[test]
fn parses_valid_cve_id() {
let id: CveId = "CVE-2024-12345".parse().expect("valid CVE should parse");
assert_eq!(id.as_str(), "CVE-2024-12345");
assert_eq!(id.year().value(), 2024);
assert_eq!(id.sequence().as_str(), "12345");
assert_eq!(id.to_string(), "CVE-2024-12345");
}
#[test]
fn rejects_invalid_cve_ids() {
assert_eq!(CveId::new(""), Err(CveIdError::Empty));
assert_eq!(CveId::new("cve-2024-1234"), Err(CveIdError::InvalidPrefix));
assert_eq!(CveId::new("CVE-24-1234"), Err(CveIdError::InvalidYear));
assert_eq!(CveId::new("CVE-2024-123"), Err(CveIdError::InvalidSequence));
assert_eq!(
CveId::new("CVE-2024-12A4"),
Err(CveIdError::InvalidSequence)
);
}
#[test]
fn parses_components() {
assert_eq!(CveYear::new(2024).expect("year").to_string(), "2024");
assert_eq!(CveSequence::new("0001").expect("sequence").as_str(), "0001");
}
#[test]
fn displays_status_and_record_kind() {
assert_eq!(CveStatus::Published.to_string(), "published");
assert_eq!(CveRecordKind::Vulnerability.to_string(), "vulnerability");
}
}