vulnera_advisor/
purl.rs

1//! Package URL (PURL) builder and parser.
2//!
3//! Provides a convenient way to construct and parse Package URLs
4//! following the [PURL specification](https://github.com/package-url/purl-spec).
5//!
6//! # Example
7//!
8//! ```rust
9//! use vulnera_advisor::Purl;
10//!
11//! // Simple PURL
12//! let purl = Purl::new("npm", "lodash")
13//!     .with_version("4.17.20")
14//!     .to_string();
15//! assert_eq!(purl, "pkg:npm/lodash@4.17.20");
16//!
17//! // Maven with namespace (groupId)
18//! let purl = Purl::new("maven", "spring-core")
19//!     .with_namespace("org.springframework")
20//!     .with_version("5.3.9")
21//!     .to_string();
22//! assert_eq!(purl, "pkg:maven/org.springframework/spring-core@5.3.9");
23//! ```
24
25use std::collections::hash_map::DefaultHasher;
26use std::fmt;
27use std::hash::{Hash, Hasher};
28
29/// Known valid PURL ecosystem types.
30///
31/// This list includes all ecosystems supported by OSS Index and other
32/// vulnerability databases.
33pub const KNOWN_ECOSYSTEMS: &[&str] = &[
34    "cargo",     // Rust crates
35    "cocoapods", // iOS/macOS CocoaPods
36    "composer",  // PHP Composer
37    "conan",     // C/C++ Conan
38    "conda",     // Conda packages
39    "cran",      // R packages
40    "deb",       // Debian packages
41    "gem",       // Ruby gems
42    "generic",   // Generic packages
43    "github",    // GitHub repositories
44    "golang",    // Go modules
45    "hex",       // Erlang/Elixir Hex
46    "maven",     // Java Maven
47    "npm",       // Node.js npm
48    "nuget",     // .NET NuGet
49    "pub",       // Dart/Flutter pub
50    "pypi",      // Python PyPI
51    "rpm",       // RPM packages
52    "swift",     // Swift packages
53];
54
55/// Ecosystem name mappings from common names to PURL types.
56/// Some ecosystems use different names in PURL vs common usage.
57const 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/// Error returned when PURL validation fails.
69#[derive(Debug, Clone, thiserror::Error)]
70pub enum PurlError {
71    /// The ecosystem/type is not recognized.
72    #[error("Unknown ecosystem '{0}'. Known ecosystems: cargo, npm, pypi, maven, etc.")]
73    UnknownEcosystem(String),
74
75    /// The PURL string format is invalid.
76    #[error("Invalid PURL format: {0}")]
77    InvalidFormat(String),
78
79    /// The package name is empty or invalid.
80    #[error("Invalid package name: {0}")]
81    InvalidName(String),
82}
83
84/// A Package URL builder for creating valid PURL strings.
85///
86/// PURLs are a standardized way to identify software packages across
87/// different ecosystems. This struct provides a builder pattern for
88/// constructing valid PURL strings.
89///
90/// # Format
91///
92/// ```text
93/// pkg:type/namespace/name@version?qualifiers#subpath
94/// ```
95///
96/// - **type** (required): Package ecosystem (npm, maven, pypi, etc.)
97/// - **namespace** (optional): Package scope/group (e.g., Maven groupId, npm scope)
98/// - **name** (required): Package name
99/// - **version** (optional): Specific version
100///
101/// # Example
102///
103/// ```rust
104/// use vulnera_advisor::Purl;
105///
106/// // Scoped npm package
107/// let purl = Purl::new("npm", "core")
108///     .with_namespace("@angular")
109///     .with_version("12.0.0")
110///     .to_string();
111/// assert_eq!(purl, "pkg:npm/%40angular/core@12.0.0");
112/// ```
113#[derive(Debug, Clone, PartialEq, Eq, Hash)]
114pub struct Purl {
115    /// Package type (ecosystem).
116    pub purl_type: String,
117    /// Optional namespace (e.g., Maven groupId, npm scope).
118    pub namespace: Option<String>,
119    /// Package name.
120    pub name: String,
121    /// Optional version.
122    pub version: Option<String>,
123}
124
125impl Purl {
126    /// Create a new PURL with the given ecosystem and package name.
127    ///
128    /// The ecosystem is automatically mapped to the correct PURL type
129    /// (e.g., "crates.io" → "cargo", "PyPI" → "pypi").
130    ///
131    /// # Arguments
132    ///
133    /// * `ecosystem` - The package ecosystem (e.g., "npm", "crates.io", "PyPI")
134    /// * `name` - The package name
135    ///
136    /// # Example
137    ///
138    /// ```rust
139    /// use vulnera_advisor::Purl;
140    ///
141    /// let purl = Purl::new("crates.io", "serde");
142    /// assert_eq!(purl.purl_type, "cargo");
143    /// ```
144    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    /// Create a new PURL with validation.
157    ///
158    /// Returns an error if the ecosystem is not in the known list.
159    ///
160    /// # Example
161    ///
162    /// ```rust
163    /// use vulnera_advisor::Purl;
164    ///
165    /// // Valid ecosystem
166    /// let purl = Purl::new_validated("npm", "lodash").unwrap();
167    ///
168    /// // Invalid ecosystem
169    /// let result = Purl::new_validated("invalid", "package");
170    /// assert!(result.is_err());
171    /// ```
172    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    /// Check if an ecosystem type is in the known list.
200    pub fn is_known_ecosystem(purl_type: &str) -> bool {
201        KNOWN_ECOSYSTEMS.contains(&purl_type.to_lowercase().as_str())
202    }
203
204    /// Add a namespace (e.g., Maven groupId, npm scope like "@angular").
205    pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
206        self.namespace = Some(namespace.into());
207        self
208    }
209
210    /// Add a version.
211    pub fn with_version(mut self, version: impl Into<String>) -> Self {
212        self.version = Some(version.into());
213        self
214    }
215
216    /// Map common ecosystem names to PURL types.
217    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    /// URL-encode special characters in PURL components.
227    fn encode_component(s: &str) -> String {
228        s.replace('@', "%40")
229            .replace('/', "%2F")
230            .replace('?', "%3F")
231            .replace('#', "%23")
232    }
233
234    /// URL-decode PURL components.
235    fn decode_component(s: &str) -> String {
236        s.replace("%40", "@")
237            .replace("%2F", "/")
238            .replace("%3F", "?")
239            .replace("%23", "#")
240    }
241
242    /// Parse a PURL string into a Purl struct.
243    ///
244    /// # Example
245    ///
246    /// ```rust
247    /// use vulnera_advisor::Purl;
248    ///
249    /// let purl = Purl::parse("pkg:npm/lodash@4.17.20").unwrap();
250    /// assert_eq!(purl.purl_type, "npm");
251    /// assert_eq!(purl.name, "lodash");
252    /// assert_eq!(purl.version, Some("4.17.20".to_string()));
253    /// ```
254    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        // Split type from rest
260        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        // Remove qualifiers and subpath for now (everything after ? or #)
269        let rest = rest.split('?').next().unwrap_or(rest);
270        let rest = rest.split('#').next().unwrap_or(rest);
271
272        // Handle version
273        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        // Handle namespace
280        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    /// Get the ecosystem name (reverse mapping from PURL type).
301    ///
302    /// Returns the common ecosystem name for known mappings,
303    /// or the PURL type itself if no mapping exists.
304    pub fn ecosystem(&self) -> String {
305        // Reverse lookup for common mappings
306        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    /// Generate a hash suitable for use as a cache key.
315    ///
316    /// This creates a deterministic hash of the PURL for use in
317    /// Redis cache keys.
318    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    /// Generate a cache key from a PURL string.
325    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
350/// Create a PURL from ecosystem, name, and version.
351///
352/// This is a convenience function for creating PURLs without importing
353/// the `Purl` struct directly.
354///
355/// # Example
356///
357/// ```rust
358/// use vulnera_advisor::purl::purl;
359///
360/// let p = purl("npm", "lodash", "4.17.20");
361/// assert_eq!(p.to_string(), "pkg:npm/lodash@4.17.20");
362/// ```
363pub fn purl(ecosystem: &str, name: &str, version: &str) -> Purl {
364    Purl::new(ecosystem, name).with_version(version)
365}
366
367/// Create multiple PURLs from a list of (ecosystem, name, version) tuples.
368///
369/// # Example
370///
371/// ```rust
372/// use vulnera_advisor::purl::purls_from_packages;
373///
374/// let purls = purls_from_packages(&[
375///     ("npm", "lodash", "4.17.20"),
376///     ("cargo", "serde", "1.0.130"),
377/// ]);
378/// assert_eq!(purls.len(), 2);
379/// ```
380pub 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
387/// Convert a list of PURLs to a vector of string references.
388///
389/// Useful for passing to OSS Index queries.
390pub 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        // Valid ecosystem
474        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        // Invalid ecosystem
479        assert!(Purl::new_validated("invalid_eco", "package").is_err());
480
481        // Empty name
482        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")); // Case insensitive
520        assert!(!Purl::is_known_ecosystem("unknown"));
521    }
522}