use std::collections::hash_map::DefaultHasher;
use std::fmt;
use std::hash::{Hash, Hasher};
use crate::ecosystem::canonicalize_ecosystem;
pub const KNOWN_ECOSYSTEMS: &[&str] = &[
"cargo", "cocoapods", "composer", "conan", "conda", "cran", "deb", "gem", "generic", "github", "golang", "hex", "maven", "npm", "nuget", "pub", "pypi", "rpm", "swift", ];
const ECOSYSTEM_MAPPINGS: &[(&str, &str)] = &[
("crates.io", "cargo"),
("PyPI", "pypi"),
("RubyGems", "gem"),
("Go", "golang"),
("Packagist", "composer"),
("NuGet", "nuget"),
("Hex", "hex"),
("Pub", "pub"),
];
#[derive(Debug, Clone, thiserror::Error)]
pub enum PurlError {
#[error("Unknown ecosystem '{0}'. Known ecosystems: cargo, npm, pypi, maven, etc.")]
UnknownEcosystem(String),
#[error("Invalid PURL format: {0}")]
InvalidFormat(String),
#[error("Invalid package name: {0}")]
InvalidName(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Purl {
pub purl_type: String,
pub namespace: Option<String>,
pub name: String,
pub version: Option<String>,
}
impl Purl {
pub fn new(ecosystem: impl Into<String>, name: impl Into<String>) -> Self {
let eco = ecosystem.into();
let purl_type = Self::map_ecosystem(&eco);
Self {
purl_type,
namespace: None,
name: name.into(),
version: None,
}
}
pub fn new_validated(
ecosystem: impl Into<String>,
name: impl Into<String>,
) -> Result<Self, PurlError> {
let eco = ecosystem.into();
let name = name.into();
if name.is_empty() {
return Err(PurlError::InvalidName(
"Package name cannot be empty".into(),
));
}
let purl_type = Self::map_ecosystem(&eco);
if !Self::is_known_ecosystem(&purl_type) {
return Err(PurlError::UnknownEcosystem(eco));
}
Ok(Self {
purl_type,
namespace: None,
name,
version: None,
})
}
pub fn is_known_ecosystem(purl_type: &str) -> bool {
KNOWN_ECOSYSTEMS.contains(&purl_type.to_lowercase().as_str())
}
pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
self.namespace = Some(namespace.into());
self
}
pub fn with_version(mut self, version: impl Into<String>) -> Self {
self.version = Some(version.into());
self
}
fn map_ecosystem(ecosystem: &str) -> String {
if let Some(canonical) = canonicalize_ecosystem(ecosystem) {
return match canonical {
"cargo" => "cargo".to_string(),
"go" => "golang".to_string(),
"packagist" => "composer".to_string(),
"rubygems" => "gem".to_string(),
other => other.to_string(),
};
}
for (from, to) in ECOSYSTEM_MAPPINGS {
if ecosystem.eq_ignore_ascii_case(from) {
return to.to_string();
}
}
ecosystem.to_lowercase()
}
fn encode_component(s: &str) -> String {
s.replace('@', "%40")
.replace('/', "%2F")
.replace('?', "%3F")
.replace('#', "%23")
}
fn decode_component(s: &str) -> String {
s.replace("%40", "@")
.replace("%2F", "/")
.replace("%3F", "?")
.replace("%23", "#")
}
pub fn parse(s: &str) -> Result<Self, PurlError> {
let s = s
.strip_prefix("pkg:")
.ok_or_else(|| PurlError::InvalidFormat("PURL must start with 'pkg:'".into()))?;
let (purl_type, rest) = s
.split_once('/')
.ok_or_else(|| PurlError::InvalidFormat("Missing '/' after type".into()))?;
if purl_type.is_empty() {
return Err(PurlError::InvalidFormat("Empty PURL type".into()));
}
let rest = rest.split('?').next().unwrap_or(rest);
let rest = rest.split('#').next().unwrap_or(rest);
let (path, version) = if let Some((p, v)) = rest.split_once('@') {
(p, Some(v.to_string()))
} else {
(rest, None)
};
let (namespace, name) = if let Some((ns, n)) = path.rsplit_once('/') {
(Some(Self::decode_component(ns)), Self::decode_component(n))
} else {
(None, Self::decode_component(path))
};
if name.is_empty() {
return Err(PurlError::InvalidName(
"Package name cannot be empty".into(),
));
}
Ok(Self {
purl_type: purl_type.to_string(),
namespace,
name,
version,
})
}
pub fn ecosystem(&self) -> String {
for (eco, purl) in ECOSYSTEM_MAPPINGS {
if self.purl_type.eq_ignore_ascii_case(purl) {
return eco.to_string();
}
}
self.purl_type.clone()
}
pub fn cache_key(&self) -> String {
let mut hasher = DefaultHasher::new();
self.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
pub fn cache_key_from_str(purl: &str) -> String {
let mut hasher = DefaultHasher::new();
purl.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
}
impl fmt::Display for Purl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "pkg:{}/", self.purl_type)?;
if let Some(ns) = &self.namespace {
write!(f, "{}/", Self::encode_component(ns))?;
}
write!(f, "{}", self.name)?;
if let Some(v) = &self.version {
write!(f, "@{}", v)?;
}
Ok(())
}
}
pub fn purl(ecosystem: &str, name: &str, version: &str) -> Purl {
Purl::new(ecosystem, name).with_version(version)
}
pub fn purls_from_packages(packages: &[(&str, &str, &str)]) -> Vec<Purl> {
packages
.iter()
.map(|(eco, name, ver)| Purl::new(*eco, *name).with_version(*ver))
.collect()
}
pub fn purls_to_strings(purls: &[Purl]) -> Vec<String> {
purls.iter().map(|p| p.to_string()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_simple_purl() {
let purl = Purl::new("npm", "lodash").with_version("4.17.20");
assert_eq!(purl.to_string(), "pkg:npm/lodash@4.17.20");
}
#[test]
fn test_ecosystem_mapping() {
let purl = Purl::new("crates.io", "serde").with_version("1.0.130");
assert_eq!(purl.to_string(), "pkg:cargo/serde@1.0.130");
let purl = Purl::new("PyPI", "requests");
assert_eq!(purl.to_string(), "pkg:pypi/requests");
let purl = Purl::new("RubyGems", "rails");
assert_eq!(purl.to_string(), "pkg:gem/rails");
}
#[test]
fn test_maven_with_namespace() {
let purl = Purl::new("maven", "spring-core")
.with_namespace("org.springframework")
.with_version("5.3.9");
assert_eq!(
purl.to_string(),
"pkg:maven/org.springframework/spring-core@5.3.9"
);
}
#[test]
fn test_npm_scoped() {
let purl = Purl::new("npm", "core")
.with_namespace("@angular")
.with_version("12.0.0");
assert_eq!(purl.to_string(), "pkg:npm/%40angular/core@12.0.0");
}
#[test]
fn test_parse_simple() {
let purl = Purl::parse("pkg:npm/lodash@4.17.20").unwrap();
assert_eq!(purl.purl_type, "npm");
assert_eq!(purl.name, "lodash");
assert_eq!(purl.version, Some("4.17.20".to_string()));
assert_eq!(purl.namespace, None);
}
#[test]
fn test_parse_with_namespace() {
let purl = Purl::parse("pkg:maven/org.springframework/spring-core@5.3.9").unwrap();
assert_eq!(purl.purl_type, "maven");
assert_eq!(purl.namespace, Some("org.springframework".to_string()));
assert_eq!(purl.name, "spring-core");
assert_eq!(purl.version, Some("5.3.9".to_string()));
}
#[test]
fn test_parse_scoped_npm() {
let purl = Purl::parse("pkg:npm/%40angular/core@12.0.0").unwrap();
assert_eq!(purl.namespace, Some("@angular".to_string()));
assert_eq!(purl.name, "core");
}
#[test]
fn test_roundtrip() {
let original = "pkg:npm/lodash@4.17.20";
let purl = Purl::parse(original).unwrap();
assert_eq!(purl.to_string(), original);
let original = "pkg:maven/org.springframework/spring-core@5.3.9";
let purl = Purl::parse(original).unwrap();
assert_eq!(purl.to_string(), original);
}
#[test]
fn test_validation() {
assert!(Purl::new_validated("npm", "lodash").is_ok());
assert!(Purl::new_validated("crates.io", "serde").is_ok());
assert!(Purl::new_validated("cargo", "serde").is_ok());
assert!(Purl::new_validated("invalid_eco", "package").is_err());
assert!(Purl::new_validated("npm", "").is_err());
}
#[test]
fn test_ecosystem_reverse_mapping() {
let purl = Purl::new("cargo", "serde");
assert_eq!(purl.ecosystem(), "crates.io");
let purl = Purl::new("pypi", "requests");
assert_eq!(purl.ecosystem(), "PyPI");
}
#[test]
fn test_cache_key() {
let purl1 = Purl::new("npm", "lodash").with_version("4.17.20");
let purl2 = Purl::new("npm", "lodash").with_version("4.17.20");
let purl3 = Purl::new("npm", "lodash").with_version("4.17.21");
assert_eq!(purl1.cache_key(), purl2.cache_key());
assert_ne!(purl1.cache_key(), purl3.cache_key());
}
#[test]
fn test_purls_from_packages() {
let purls =
purls_from_packages(&[("npm", "lodash", "4.17.20"), ("cargo", "serde", "1.0.130")]);
assert_eq!(purls.len(), 2);
assert_eq!(purls[0].to_string(), "pkg:npm/lodash@4.17.20");
assert_eq!(purls[1].to_string(), "pkg:cargo/serde@1.0.130");
}
#[test]
fn test_known_ecosystems() {
assert!(Purl::is_known_ecosystem("npm"));
assert!(Purl::is_known_ecosystem("cargo"));
assert!(Purl::is_known_ecosystem("pypi"));
assert!(Purl::is_known_ecosystem("NPM")); assert!(!Purl::is_known_ecosystem("unknown"));
}
}