1use std::collections::hash_map::DefaultHasher;
26use std::fmt;
27use std::hash::{Hash, Hasher};
28
29pub const KNOWN_ECOSYSTEMS: &[&str] = &[
34 "cargo", "cocoapods", "composer", "conan", "conda", "cran", "deb", "gem", "generic", "github", "golang", "hex", "maven", "npm", "nuget", "pub", "pypi", "rpm", "swift", ];
54
55const ECOSYSTEM_MAPPINGS: &[(&str, &str)] = &[
58 ("crates.io", "cargo"),
59 ("PyPI", "pypi"),
60 ("RubyGems", "gem"),
61 ("Go", "golang"),
62 ("Packagist", "composer"),
63 ("NuGet", "nuget"),
64 ("Hex", "hex"),
65 ("Pub", "pub"),
66];
67
68#[derive(Debug, Clone, thiserror::Error)]
70pub enum PurlError {
71 #[error("Unknown ecosystem '{0}'. Known ecosystems: cargo, npm, pypi, maven, etc.")]
73 UnknownEcosystem(String),
74
75 #[error("Invalid PURL format: {0}")]
77 InvalidFormat(String),
78
79 #[error("Invalid package name: {0}")]
81 InvalidName(String),
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Hash)]
114pub struct Purl {
115 pub purl_type: String,
117 pub namespace: Option<String>,
119 pub name: String,
121 pub version: Option<String>,
123}
124
125impl Purl {
126 pub fn new(ecosystem: impl Into<String>, name: impl Into<String>) -> Self {
145 let eco = ecosystem.into();
146 let purl_type = Self::map_ecosystem(&eco);
147
148 Self {
149 purl_type,
150 namespace: None,
151 name: name.into(),
152 version: None,
153 }
154 }
155
156 pub fn new_validated(
173 ecosystem: impl Into<String>,
174 name: impl Into<String>,
175 ) -> Result<Self, PurlError> {
176 let eco = ecosystem.into();
177 let name = name.into();
178
179 if name.is_empty() {
180 return Err(PurlError::InvalidName(
181 "Package name cannot be empty".into(),
182 ));
183 }
184
185 let purl_type = Self::map_ecosystem(&eco);
186
187 if !Self::is_known_ecosystem(&purl_type) {
188 return Err(PurlError::UnknownEcosystem(eco));
189 }
190
191 Ok(Self {
192 purl_type,
193 namespace: None,
194 name,
195 version: None,
196 })
197 }
198
199 pub fn is_known_ecosystem(purl_type: &str) -> bool {
201 KNOWN_ECOSYSTEMS.contains(&purl_type.to_lowercase().as_str())
202 }
203
204 pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
206 self.namespace = Some(namespace.into());
207 self
208 }
209
210 pub fn with_version(mut self, version: impl Into<String>) -> Self {
212 self.version = Some(version.into());
213 self
214 }
215
216 fn map_ecosystem(ecosystem: &str) -> String {
218 for (from, to) in ECOSYSTEM_MAPPINGS {
219 if ecosystem.eq_ignore_ascii_case(from) {
220 return to.to_string();
221 }
222 }
223 ecosystem.to_lowercase()
224 }
225
226 fn encode_component(s: &str) -> String {
228 s.replace('@', "%40")
229 .replace('/', "%2F")
230 .replace('?', "%3F")
231 .replace('#', "%23")
232 }
233
234 fn decode_component(s: &str) -> String {
236 s.replace("%40", "@")
237 .replace("%2F", "/")
238 .replace("%3F", "?")
239 .replace("%23", "#")
240 }
241
242 pub fn parse(s: &str) -> Result<Self, PurlError> {
255 let s = s
256 .strip_prefix("pkg:")
257 .ok_or_else(|| PurlError::InvalidFormat("PURL must start with 'pkg:'".into()))?;
258
259 let (purl_type, rest) = s
261 .split_once('/')
262 .ok_or_else(|| PurlError::InvalidFormat("Missing '/' after type".into()))?;
263
264 if purl_type.is_empty() {
265 return Err(PurlError::InvalidFormat("Empty PURL type".into()));
266 }
267
268 let rest = rest.split('?').next().unwrap_or(rest);
270 let rest = rest.split('#').next().unwrap_or(rest);
271
272 let (path, version) = if let Some((p, v)) = rest.split_once('@') {
274 (p, Some(v.to_string()))
275 } else {
276 (rest, None)
277 };
278
279 let (namespace, name) = if let Some((ns, n)) = path.rsplit_once('/') {
281 (Some(Self::decode_component(ns)), Self::decode_component(n))
282 } else {
283 (None, Self::decode_component(path))
284 };
285
286 if name.is_empty() {
287 return Err(PurlError::InvalidName(
288 "Package name cannot be empty".into(),
289 ));
290 }
291
292 Ok(Self {
293 purl_type: purl_type.to_string(),
294 namespace,
295 name,
296 version,
297 })
298 }
299
300 pub fn ecosystem(&self) -> String {
305 for (eco, purl) in ECOSYSTEM_MAPPINGS {
307 if self.purl_type.eq_ignore_ascii_case(purl) {
308 return eco.to_string();
309 }
310 }
311 self.purl_type.clone()
312 }
313
314 pub fn cache_key(&self) -> String {
319 let mut hasher = DefaultHasher::new();
320 self.hash(&mut hasher);
321 format!("{:x}", hasher.finish())
322 }
323
324 pub fn cache_key_from_str(purl: &str) -> String {
326 let mut hasher = DefaultHasher::new();
327 purl.hash(&mut hasher);
328 format!("{:x}", hasher.finish())
329 }
330}
331
332impl fmt::Display for Purl {
333 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
334 write!(f, "pkg:{}/", self.purl_type)?;
335
336 if let Some(ns) = &self.namespace {
337 write!(f, "{}/", Self::encode_component(ns))?;
338 }
339
340 write!(f, "{}", self.name)?;
341
342 if let Some(v) = &self.version {
343 write!(f, "@{}", v)?;
344 }
345
346 Ok(())
347 }
348}
349
350pub fn purl(ecosystem: &str, name: &str, version: &str) -> Purl {
364 Purl::new(ecosystem, name).with_version(version)
365}
366
367pub fn purls_from_packages(packages: &[(&str, &str, &str)]) -> Vec<Purl> {
381 packages
382 .iter()
383 .map(|(eco, name, ver)| Purl::new(*eco, *name).with_version(*ver))
384 .collect()
385}
386
387pub fn purls_to_strings(purls: &[Purl]) -> Vec<String> {
391 purls.iter().map(|p| p.to_string()).collect()
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397
398 #[test]
399 fn test_simple_purl() {
400 let purl = Purl::new("npm", "lodash").with_version("4.17.20");
401 assert_eq!(purl.to_string(), "pkg:npm/lodash@4.17.20");
402 }
403
404 #[test]
405 fn test_ecosystem_mapping() {
406 let purl = Purl::new("crates.io", "serde").with_version("1.0.130");
407 assert_eq!(purl.to_string(), "pkg:cargo/serde@1.0.130");
408
409 let purl = Purl::new("PyPI", "requests");
410 assert_eq!(purl.to_string(), "pkg:pypi/requests");
411
412 let purl = Purl::new("RubyGems", "rails");
413 assert_eq!(purl.to_string(), "pkg:gem/rails");
414 }
415
416 #[test]
417 fn test_maven_with_namespace() {
418 let purl = Purl::new("maven", "spring-core")
419 .with_namespace("org.springframework")
420 .with_version("5.3.9");
421 assert_eq!(
422 purl.to_string(),
423 "pkg:maven/org.springframework/spring-core@5.3.9"
424 );
425 }
426
427 #[test]
428 fn test_npm_scoped() {
429 let purl = Purl::new("npm", "core")
430 .with_namespace("@angular")
431 .with_version("12.0.0");
432 assert_eq!(purl.to_string(), "pkg:npm/%40angular/core@12.0.0");
433 }
434
435 #[test]
436 fn test_parse_simple() {
437 let purl = Purl::parse("pkg:npm/lodash@4.17.20").unwrap();
438 assert_eq!(purl.purl_type, "npm");
439 assert_eq!(purl.name, "lodash");
440 assert_eq!(purl.version, Some("4.17.20".to_string()));
441 assert_eq!(purl.namespace, None);
442 }
443
444 #[test]
445 fn test_parse_with_namespace() {
446 let purl = Purl::parse("pkg:maven/org.springframework/spring-core@5.3.9").unwrap();
447 assert_eq!(purl.purl_type, "maven");
448 assert_eq!(purl.namespace, Some("org.springframework".to_string()));
449 assert_eq!(purl.name, "spring-core");
450 assert_eq!(purl.version, Some("5.3.9".to_string()));
451 }
452
453 #[test]
454 fn test_parse_scoped_npm() {
455 let purl = Purl::parse("pkg:npm/%40angular/core@12.0.0").unwrap();
456 assert_eq!(purl.namespace, Some("@angular".to_string()));
457 assert_eq!(purl.name, "core");
458 }
459
460 #[test]
461 fn test_roundtrip() {
462 let original = "pkg:npm/lodash@4.17.20";
463 let purl = Purl::parse(original).unwrap();
464 assert_eq!(purl.to_string(), original);
465
466 let original = "pkg:maven/org.springframework/spring-core@5.3.9";
467 let purl = Purl::parse(original).unwrap();
468 assert_eq!(purl.to_string(), original);
469 }
470
471 #[test]
472 fn test_validation() {
473 assert!(Purl::new_validated("npm", "lodash").is_ok());
475 assert!(Purl::new_validated("crates.io", "serde").is_ok());
476 assert!(Purl::new_validated("cargo", "serde").is_ok());
477
478 assert!(Purl::new_validated("invalid_eco", "package").is_err());
480
481 assert!(Purl::new_validated("npm", "").is_err());
483 }
484
485 #[test]
486 fn test_ecosystem_reverse_mapping() {
487 let purl = Purl::new("cargo", "serde");
488 assert_eq!(purl.ecosystem(), "crates.io");
489
490 let purl = Purl::new("pypi", "requests");
491 assert_eq!(purl.ecosystem(), "PyPI");
492 }
493
494 #[test]
495 fn test_cache_key() {
496 let purl1 = Purl::new("npm", "lodash").with_version("4.17.20");
497 let purl2 = Purl::new("npm", "lodash").with_version("4.17.20");
498 let purl3 = Purl::new("npm", "lodash").with_version("4.17.21");
499
500 assert_eq!(purl1.cache_key(), purl2.cache_key());
501 assert_ne!(purl1.cache_key(), purl3.cache_key());
502 }
503
504 #[test]
505 fn test_purls_from_packages() {
506 let purls =
507 purls_from_packages(&[("npm", "lodash", "4.17.20"), ("cargo", "serde", "1.0.130")]);
508
509 assert_eq!(purls.len(), 2);
510 assert_eq!(purls[0].to_string(), "pkg:npm/lodash@4.17.20");
511 assert_eq!(purls[1].to_string(), "pkg:cargo/serde@1.0.130");
512 }
513
514 #[test]
515 fn test_known_ecosystems() {
516 assert!(Purl::is_known_ecosystem("npm"));
517 assert!(Purl::is_known_ecosystem("cargo"));
518 assert!(Purl::is_known_ecosystem("pypi"));
519 assert!(Purl::is_known_ecosystem("NPM")); assert!(!Purl::is_known_ecosystem("unknown"));
521 }
522}