uv-sbom 2.2.0

SBOM generation tool for uv projects - Generate CycloneDX SBOMs from uv.lock files
Documentation
use crate::shared::Result;

/// CVSS Score value object with validation
///
/// Ensures that CVSS scores are always valid (0.0-10.0, not NaN).
/// This provides type safety and guarantees that a Vulnerability can only hold a valid score.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CvssScore(f32);

impl CvssScore {
    /// Creates a new CvssScore with validation
    ///
    /// # Arguments
    /// * `score` - CVSS score value
    ///
    /// # Errors
    /// Returns error if:
    /// - Score is NaN
    /// - Score is outside valid range (0.0-10.0)
    pub fn new(score: f32) -> Result<Self> {
        if score.is_nan() {
            anyhow::bail!("CVSS score cannot be NaN");
        }

        if !(0.0..=10.0).contains(&score) {
            anyhow::bail!("CVSS score must be between 0.0 and 10.0, got: {}", score);
        }

        Ok(Self(score))
    }

    /// Returns the raw CVSS score value
    pub fn value(&self) -> f32 {
        self.0
    }
}

/// Represents a single vulnerability (CVE) affecting a package
#[derive(Debug, Clone, PartialEq)]
pub struct Vulnerability {
    /// Vulnerability ID (e.g., "CVE-2024-1234", "GHSA-xxxx-xxxx-xxxx")
    id: String,

    /// CVSS score (validated, 0.0 to 10.0)
    cvss_score: Option<CvssScore>,

    /// Severity level (CRITICAL, HIGH, MEDIUM, LOW)
    severity: Severity,

    /// Version that fixes this vulnerability
    fixed_version: Option<String>,

    /// Brief summary of the vulnerability
    summary: Option<String>,
}

impl Vulnerability {
    /// Creates a new Vulnerability with validation
    ///
    /// # Arguments
    /// * `id` - Vulnerability identifier (must not be empty)
    /// * `cvss_score` - CVSS score (optional, pre-validated)
    /// * `severity` - Severity level
    /// * `fixed_version` - Version that fixes the vulnerability (optional)
    /// * `summary` - Brief description (optional)
    ///
    /// # Errors
    /// Returns error if:
    /// - ID is empty
    ///
    /// # Notes
    /// CVSS score validation is handled by CvssScore::new(), so this method
    /// only needs to validate the ID.
    pub fn new(
        id: String,
        cvss_score: Option<CvssScore>,
        severity: Severity,
        fixed_version: Option<String>,
        summary: Option<String>,
    ) -> Result<Self> {
        // Validate ID
        if id.is_empty() {
            anyhow::bail!("Vulnerability ID cannot be empty");
        }

        Ok(Self {
            id,
            cvss_score,
            severity,
            fixed_version,
            summary,
        })
    }

    /// Returns the vulnerability ID
    pub fn id(&self) -> &str {
        &self.id
    }

    /// Returns the CVSS score if available
    pub fn cvss_score(&self) -> Option<CvssScore> {
        self.cvss_score
    }

    /// Returns the severity level
    pub fn severity(&self) -> Severity {
        self.severity
    }

    /// Returns the fixed version if available
    pub fn fixed_version(&self) -> Option<&str> {
        self.fixed_version.as_deref()
    }
}

/// Severity levels based on CVSS scores
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
    None,     // CVSS 0.0 or unknown
    Low,      // CVSS 0.1-3.9
    Medium,   // CVSS 4.0-6.9
    High,     // CVSS 7.0-8.9
    Critical, // CVSS 9.0-10.0
}

impl Severity {
    /// Converts CVSS score to severity level
    ///
    /// CVSS Score Ranges:
    /// - 0.0: None
    /// - 0.1-3.9: Low
    /// - 4.0-6.9: Medium
    /// - 7.0-8.9: High
    /// - 9.0-10.0: Critical
    pub fn from_cvss_score(score: CvssScore) -> Self {
        let value = score.value();
        match value {
            0.0 => Self::None,
            s if s < 4.0 => Self::Low,
            s if s < 7.0 => Self::Medium,
            s if s < 9.0 => Self::High,
            _ => Self::Critical,
        }
    }
}

/// Represents vulnerability information for a specific package
#[derive(Debug, Clone)]
pub struct PackageVulnerabilities {
    /// Package name
    package_name: String,

    /// Current version
    current_version: String,

    /// List of vulnerabilities affecting this package version
    vulnerabilities: Vec<Vulnerability>,
}

impl PackageVulnerabilities {
    /// Creates a new PackageVulnerabilities
    ///
    /// # Arguments
    /// * `package_name` - Name of the package
    /// * `current_version` - Current version of the package
    /// * `vulnerabilities` - List of vulnerabilities affecting this version
    pub fn new(
        package_name: String,
        current_version: String,
        vulnerabilities: Vec<Vulnerability>,
    ) -> Self {
        Self {
            package_name,
            current_version,
            vulnerabilities,
        }
    }

    /// Returns the package name
    pub fn package_name(&self) -> &str {
        &self.package_name
    }

    /// Returns the current version
    pub fn current_version(&self) -> &str {
        &self.current_version
    }

    /// Returns the list of vulnerabilities
    pub fn vulnerabilities(&self) -> &[Vulnerability] {
        &self.vulnerabilities
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_cvss_score_new_valid() {
        let score = CvssScore::new(5.0);
        assert!(score.is_ok());
        assert_eq!(score.unwrap().value(), 5.0);

        // Boundary values
        assert!(CvssScore::new(0.0).is_ok());
        assert!(CvssScore::new(10.0).is_ok());
    }

    #[test]
    fn test_cvss_score_invalid_too_high() {
        let result = CvssScore::new(11.0);
        assert!(result.is_err());
        assert!(result
            .unwrap_err()
            .to_string()
            .contains("between 0.0 and 10.0"));
    }

    #[test]
    fn test_cvss_score_invalid_negative() {
        let result = CvssScore::new(-1.0);
        assert!(result.is_err());
        assert!(result
            .unwrap_err()
            .to_string()
            .contains("between 0.0 and 10.0"));
    }

    #[test]
    fn test_cvss_score_invalid_nan() {
        let result = CvssScore::new(f32::NAN);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("cannot be NaN"));
    }

    #[test]
    fn test_vulnerability_new_valid() {
        let cvss = CvssScore::new(9.8).unwrap();
        let vuln = Vulnerability::new(
            "CVE-2024-1234".to_string(),
            Some(cvss),
            Severity::Critical,
            Some("2.0.0".to_string()),
            Some("SQL injection vulnerability".to_string()),
        );
        assert!(vuln.is_ok());

        let vuln = vuln.unwrap();
        assert_eq!(vuln.id(), "CVE-2024-1234");
        assert_eq!(vuln.cvss_score().map(|s| s.value()), Some(9.8));
        assert_eq!(vuln.severity(), Severity::Critical);
        assert_eq!(vuln.fixed_version(), Some("2.0.0"));
    }

    #[test]
    fn test_vulnerability_empty_id() {
        let cvss = CvssScore::new(5.0).unwrap();
        let result = Vulnerability::new("".to_string(), Some(cvss), Severity::Medium, None, None);
        assert!(result.is_err());
        assert!(result
            .unwrap_err()
            .to_string()
            .contains("ID cannot be empty"));
    }

    #[test]
    fn test_vulnerability_without_optional_fields() {
        let vuln = Vulnerability::new(
            "GHSA-xxxx-yyyy-zzzz".to_string(),
            None,
            Severity::High,
            None,
            None,
        );
        assert!(vuln.is_ok());

        let vuln = vuln.unwrap();
        assert_eq!(vuln.id(), "GHSA-xxxx-yyyy-zzzz");
        assert_eq!(vuln.cvss_score(), None);
        assert_eq!(vuln.fixed_version(), None);
    }

    #[test]
    fn test_severity_from_cvss_score() {
        assert_eq!(
            Severity::from_cvss_score(CvssScore::new(0.0).unwrap()),
            Severity::None
        );
        assert_eq!(
            Severity::from_cvss_score(CvssScore::new(0.1).unwrap()),
            Severity::Low
        );
        assert_eq!(
            Severity::from_cvss_score(CvssScore::new(3.9).unwrap()),
            Severity::Low
        );
        assert_eq!(
            Severity::from_cvss_score(CvssScore::new(4.0).unwrap()),
            Severity::Medium
        );
        assert_eq!(
            Severity::from_cvss_score(CvssScore::new(6.9).unwrap()),
            Severity::Medium
        );
        assert_eq!(
            Severity::from_cvss_score(CvssScore::new(7.0).unwrap()),
            Severity::High
        );
        assert_eq!(
            Severity::from_cvss_score(CvssScore::new(8.9).unwrap()),
            Severity::High
        );
        assert_eq!(
            Severity::from_cvss_score(CvssScore::new(9.0).unwrap()),
            Severity::Critical
        );
        assert_eq!(
            Severity::from_cvss_score(CvssScore::new(10.0).unwrap()),
            Severity::Critical
        );
    }

    #[test]
    fn test_severity_ordering() {
        assert!(Severity::Critical > Severity::High);
        assert!(Severity::High > Severity::Medium);
        assert!(Severity::Medium > Severity::Low);
        assert!(Severity::Low > Severity::None);
    }

    #[test]
    fn test_package_vulnerabilities_new() {
        let vuln1 = Vulnerability::new(
            "CVE-2024-1234".to_string(),
            Some(CvssScore::new(9.8).unwrap()),
            Severity::Critical,
            Some("2.0.0".to_string()),
            None,
        )
        .unwrap();

        let vuln2 = Vulnerability::new(
            "CVE-2024-5678".to_string(),
            Some(CvssScore::new(5.0).unwrap()),
            Severity::Medium,
            Some("2.1.0".to_string()),
            None,
        )
        .unwrap();

        let pkg_vulns = PackageVulnerabilities::new(
            "requests".to_string(),
            "1.0.0".to_string(),
            vec![vuln1, vuln2],
        );

        assert_eq!(pkg_vulns.package_name(), "requests");
        assert_eq!(pkg_vulns.current_version(), "1.0.0");
        assert_eq!(pkg_vulns.vulnerabilities().len(), 2);
    }
}