use serde::{Deserialize, Serialize};
use std::fmt;
use std::hash::{Hash, Hasher};
#[derive(Debug, Clone, Eq, Serialize, Deserialize)]
pub struct CanonicalId {
value: String,
source: IdSource,
#[serde(default)]
stable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum IdSource {
Purl,
Cpe,
Swid,
NameVersion,
Synthetic,
FormatSpecific,
}
impl IdSource {
#[must_use]
pub const fn is_stable(&self) -> bool {
matches!(
self,
Self::Purl | Self::Cpe | Self::Swid | Self::NameVersion | Self::Synthetic
)
}
#[must_use]
pub const fn reliability_rank(&self) -> u8 {
match self {
Self::Purl => 0,
Self::Cpe => 1,
Self::Swid => 2,
Self::NameVersion => 3,
Self::Synthetic => 4,
Self::FormatSpecific => 5,
}
}
}
impl CanonicalId {
#[must_use]
pub fn from_purl(purl: &str) -> Self {
Self {
value: Self::normalize_purl(purl),
source: IdSource::Purl,
stable: true,
}
}
#[must_use]
pub fn from_name_version(name: &str, version: Option<&str>) -> Self {
let value = version.map_or_else(
|| name.to_lowercase(),
|v| format!("{}@{}", name.to_lowercase(), v),
);
Self {
value,
source: IdSource::NameVersion,
stable: true,
}
}
#[must_use]
pub fn synthetic(group: Option<&str>, name: &str, version: Option<&str>) -> Self {
let value = match (group, version) {
(Some(g), Some(v)) => format!("{}:{}@{}", g.to_lowercase(), name.to_lowercase(), v),
(Some(g), None) => format!("{}:{}", g.to_lowercase(), name.to_lowercase()),
(None, Some(v)) => format!("{}@{}", name.to_lowercase(), v),
(None, None) => name.to_lowercase(),
};
Self {
value,
source: IdSource::Synthetic,
stable: true,
}
}
#[must_use]
pub fn from_format_id(id: &str) -> Self {
let looks_like_uuid = id.len() == 36
&& id.chars().filter(|c| *c == '-').count() == 4
&& id.chars().all(|c| c.is_ascii_hexdigit() || c == '-');
Self {
value: id.to_string(),
source: IdSource::FormatSpecific,
stable: !looks_like_uuid,
}
}
#[must_use]
pub fn from_cpe(cpe: &str) -> Self {
Self {
value: cpe.to_lowercase(),
source: IdSource::Cpe,
stable: true,
}
}
#[must_use]
pub fn from_swid(swid: &str) -> Self {
Self {
value: swid.to_string(),
source: IdSource::Swid,
stable: true,
}
}
#[must_use]
pub fn value(&self) -> &str {
&self.value
}
#[must_use]
pub const fn source(&self) -> &IdSource {
&self.source
}
#[must_use]
pub const fn is_stable(&self) -> bool {
self.stable
}
fn normalize_purl(purl: &str) -> String {
let mut normalized = purl.to_lowercase();
if normalized.starts_with("pkg:pypi/") {
normalized = normalized.replace(['_', '.'], "-");
} else if normalized.starts_with("pkg:npm/") {
normalized = normalized.replace("%40", "@");
}
normalized
}
}
impl PartialEq for CanonicalId {
fn eq(&self, other: &Self) -> bool {
self.value == other.value
}
}
impl Hash for CanonicalId {
fn hash<H: Hasher>(&self, state: &mut H) {
self.value.hash(state);
}
}
impl fmt::Display for CanonicalId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.value)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ComponentIdentifiers {
pub purl: Option<String>,
pub cpe: Vec<String>,
pub swid: Option<String>,
pub format_id: String,
pub aliases: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct CanonicalIdResult {
pub id: CanonicalId,
pub warning: Option<String>,
}
impl ComponentIdentifiers {
#[must_use]
pub fn new(format_id: String) -> Self {
Self {
format_id,
..Default::default()
}
}
#[must_use]
pub fn canonical_id(&self) -> CanonicalId {
self.purl.as_ref().map_or_else(
|| {
self.cpe.first().map_or_else(
|| {
self.swid.as_ref().map_or_else(
|| CanonicalId::from_format_id(&self.format_id),
|swid| CanonicalId::from_swid(swid),
)
},
|cpe| CanonicalId::from_cpe(cpe),
)
},
|purl| CanonicalId::from_purl(purl),
)
}
#[must_use]
pub fn canonical_id_with_context(
&self,
name: &str,
version: Option<&str>,
group: Option<&str>,
) -> CanonicalIdResult {
if let Some(purl) = &self.purl {
return CanonicalIdResult {
id: CanonicalId::from_purl(purl),
warning: None,
};
}
if let Some(cpe) = self.cpe.first() {
return CanonicalIdResult {
id: CanonicalId::from_cpe(cpe),
warning: None,
};
}
if let Some(swid) = &self.swid {
return CanonicalIdResult {
id: CanonicalId::from_swid(swid),
warning: None,
};
}
if !name.is_empty() {
return CanonicalIdResult {
id: CanonicalId::synthetic(group, name, version),
warning: Some(format!(
"Component '{name}' lacks PURL/CPE/SWID identifiers; using synthetic ID. \
Consider enriching SBOM with package URLs for accurate diffing."
)),
};
}
let id = CanonicalId::from_format_id(&self.format_id);
let warning = if id.is_stable() {
Some(format!(
"Component uses format-specific ID '{}' without standard identifiers.",
self.format_id
))
} else {
Some(format!(
"Component uses unstable format-specific ID '{}'. \
This may cause inaccurate diff results across SBOM regenerations.",
self.format_id
))
};
CanonicalIdResult { id, warning }
}
#[must_use]
pub fn has_stable_id(&self) -> bool {
self.purl.is_some() || !self.cpe.is_empty() || self.swid.is_some()
}
#[must_use]
pub fn id_reliability(&self) -> IdReliability {
if self.purl.is_some() {
IdReliability::High
} else if !self.cpe.is_empty() || self.swid.is_some() {
IdReliability::Medium
} else {
IdReliability::Low
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum IdReliability {
High,
Medium,
Low,
}
impl fmt::Display for IdReliability {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::High => write!(f, "high"),
Self::Medium => write!(f, "medium"),
Self::Low => write!(f, "low"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Ecosystem {
Npm,
PyPi,
Cargo,
Maven,
Golang,
Nuget,
RubyGems,
Composer,
CocoaPods,
Swift,
Hex,
Pub,
Hackage,
Cpan,
Cran,
Conda,
Conan,
Deb,
Rpm,
Apk,
Generic,
Unknown(String),
}
impl Ecosystem {
#[must_use]
pub fn from_purl_type(purl_type: &str) -> Self {
match purl_type.to_lowercase().as_str() {
"npm" => Self::Npm,
"pypi" => Self::PyPi,
"cargo" => Self::Cargo,
"maven" => Self::Maven,
"golang" | "go" => Self::Golang,
"nuget" => Self::Nuget,
"gem" => Self::RubyGems,
"composer" => Self::Composer,
"cocoapods" => Self::CocoaPods,
"swift" => Self::Swift,
"hex" => Self::Hex,
"pub" => Self::Pub,
"hackage" => Self::Hackage,
"cpan" => Self::Cpan,
"cran" => Self::Cran,
"conda" => Self::Conda,
"conan" => Self::Conan,
"deb" => Self::Deb,
"rpm" => Self::Rpm,
"apk" => Self::Apk,
"generic" => Self::Generic,
other => Self::Unknown(other.to_string()),
}
}
}
impl fmt::Display for Ecosystem {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Npm => write!(f, "npm"),
Self::PyPi => write!(f, "pypi"),
Self::Cargo => write!(f, "cargo"),
Self::Maven => write!(f, "maven"),
Self::Golang => write!(f, "golang"),
Self::Nuget => write!(f, "nuget"),
Self::RubyGems => write!(f, "gem"),
Self::Composer => write!(f, "composer"),
Self::CocoaPods => write!(f, "cocoapods"),
Self::Swift => write!(f, "swift"),
Self::Hex => write!(f, "hex"),
Self::Pub => write!(f, "pub"),
Self::Hackage => write!(f, "hackage"),
Self::Cpan => write!(f, "cpan"),
Self::Cran => write!(f, "cran"),
Self::Conda => write!(f, "conda"),
Self::Conan => write!(f, "conan"),
Self::Deb => write!(f, "deb"),
Self::Rpm => write!(f, "rpm"),
Self::Apk => write!(f, "apk"),
Self::Generic => write!(f, "generic"),
Self::Unknown(s) => write!(f, "{s}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ComponentRef {
id: CanonicalId,
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<String>,
}
impl ComponentRef {
pub fn new(id: CanonicalId, name: impl Into<String>) -> Self {
Self {
id,
name: name.into(),
version: None,
}
}
pub fn with_version(id: CanonicalId, name: impl Into<String>, version: Option<String>) -> Self {
Self {
id,
name: name.into(),
version,
}
}
#[must_use]
pub fn from_component(component: &super::Component) -> Self {
Self {
id: component.canonical_id.clone(),
name: component.name.clone(),
version: component.version.clone(),
}
}
#[must_use]
pub const fn id(&self) -> &CanonicalId {
&self.id
}
#[must_use]
pub fn id_str(&self) -> &str {
self.id.value()
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn version(&self) -> Option<&str> {
self.version.as_deref()
}
#[must_use]
pub fn display_with_version(&self) -> String {
self.version
.as_ref()
.map_or_else(|| self.name.clone(), |v| format!("{}@{}", self.name, v))
}
#[must_use]
pub fn matches_id(&self, id: &CanonicalId) -> bool {
&self.id == id
}
#[must_use]
pub fn matches_id_str(&self, id_str: &str) -> bool {
self.id.value() == id_str
}
}
impl fmt::Display for ComponentRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.name)
}
}
impl From<&super::Component> for ComponentRef {
fn from(component: &super::Component) -> Self {
Self::from_component(component)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct VulnerabilityRef2 {
pub vuln_id: String,
pub component: ComponentRef,
}
impl VulnerabilityRef2 {
pub fn new(vuln_id: impl Into<String>, component: ComponentRef) -> Self {
Self {
vuln_id: vuln_id.into(),
component,
}
}
#[must_use]
pub const fn component_id(&self) -> &CanonicalId {
self.component.id()
}
#[must_use]
pub fn component_name(&self) -> &str {
self.component.name()
}
}