packageurl/
purl.rs

1use std::borrow::Cow;
2use std::collections::HashMap;
3use std::fmt::Display;
4use std::fmt::Formatter;
5use std::fmt::Result as FmtResult;
6use std::str::FromStr;
7
8use percent_encoding::AsciiSet;
9#[cfg(feature = "serde")]
10use serde::{Deserialize, Serialize};
11
12use super::errors::Error;
13use super::errors::Result;
14use super::parser;
15use super::utils::{to_lowercase, PercentCodec};
16use super::validation;
17
18const ENCODE_SET: &AsciiSet = &percent_encoding::CONTROLS
19    .add(b' ')
20    .add(b'"')
21    .add(b'#')
22    .add(b'%')
23    .add(b'<')
24    .add(b'>')
25    .add(b'`')
26    .add(b'?')
27    .add(b'{')
28    .add(b'}')
29    // .add(b'/')
30    // .add(b':')
31    .add(b';')
32    .add(b'=')
33    .add(b'+')
34    .add(b'@')
35    .add(b'\\')
36    .add(b'[')
37    .add(b']')
38    .add(b'^')
39    .add(b'|');
40
41/// A Package URL.
42#[derive(Debug, Clone, PartialEq, Eq)]
43#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
44pub struct PackageUrl<'a> {
45    /// The package URL type.
46    pub(crate) ty: Cow<'a, str>,
47    /// The optional namespace
48    pub(crate) namespace: Option<Cow<'a, str>>,
49    /// The package name.
50    pub(crate) name: Cow<'a, str>,
51    /// The optional package version.
52    pub(crate) version: Option<Cow<'a, str>>,
53    /// The package qualifiers.
54    pub(crate) qualifiers: HashMap<Cow<'a, str>, Cow<'a, str>>,
55    /// The package subpath.
56    pub(crate) subpath: Option<Cow<'a, str>>,
57}
58
59impl<'a> PackageUrl<'a> {
60    /// Create a new Package URL with the provided type and name.
61    ///
62    /// # Type
63    /// The Package URL type must be valid, otherwise an error will be returned.
64    /// The type can only be composed of ASCII letters and numbers, '.', '+'
65    /// and '-' (period, plus and dash). It cannot start with a number and
66    /// cannot contain spaces.
67    ///
68    /// # Name
69    /// The package name will be canonicalized depending on the type: for instance,
70    /// 'bitbucket' packages have a case-insensitive name, so the name will be
71    /// lowercased if needed.
72    ///
73    /// # Example
74    /// ```rust
75    /// # extern crate packageurl;
76    /// assert!( packageurl::PackageUrl::new("cargo", "packageurl").is_ok() );
77    /// assert!( packageurl::PackageUrl::new("bad type", "packageurl").is_err() );
78    /// ```
79    pub fn new<T, N>(ty: T, name: N) -> Result<Self>
80    where
81        T: Into<Cow<'a, str>>,
82        N: Into<Cow<'a, str>>,
83    {
84        let mut t = ty.into();
85        let mut n = name.into();
86        if validation::is_type_valid(&t) {
87            t = to_lowercase(t);
88            // lowercase name if required by type and needed
89            match t.as_ref() {
90                "bitbucket" | "deb" | "github" | "hex" | "npm" => {
91                    n = to_lowercase(n);
92                }
93                "pypi" => {
94                    n = to_lowercase(n);
95                    if n.chars().any(|c| c == '_') {
96                        n = Cow::Owned(n.replace('_', "-"));
97                    }
98                }
99                _ => {}
100            }
101
102            Ok(Self::new_unchecked(t, n))
103        } else {
104            Err(Error::InvalidType(t.to_string()))
105        }
106    }
107
108    /// Create a new Package URL without checking the type.
109    fn new_unchecked<T, N>(ty: T, name: N) -> Self
110    where
111        T: Into<Cow<'a, str>>,
112        N: Into<Cow<'a, str>>,
113    {
114        Self {
115            ty: ty.into(),
116            namespace: None,
117            name: name.into(),
118            version: None,
119            qualifiers: HashMap::new(),
120            subpath: None,
121        }
122    }
123
124    /// Get the Package URL type.
125    pub fn ty(&self) -> &str {
126        self.ty.as_ref()
127    }
128
129    /// Get the optional namespace.
130    pub fn namespace(&self) -> Option<&str> {
131        self.namespace.as_ref().map(Cow::as_ref)
132    }
133
134    /// Get the package name.
135    pub fn name(&self) -> &str {
136        self.name.as_ref()
137    }
138
139    /// Get the optional package version.
140    pub fn version(&self) -> Option<&str> {
141        self.version.as_ref().map(Cow::as_ref)
142    }
143
144    /// Get the package qualifiers
145    pub fn qualifiers(&self) -> &HashMap<Cow<'a, str>, Cow<'a, str>> {
146        &self.qualifiers
147    }
148
149    /// Get the optional package subpath.
150    pub fn subpath(&self) -> Option<&str> {
151        self.subpath.as_ref().map(Cow::as_ref)
152    }
153
154    /// Assign a namespace to the package.
155    pub fn with_namespace<N>(&mut self, namespace: N) -> Result<&mut Self>
156    where
157        N: Into<Cow<'a, str>>,
158    {
159        // Fail if namespace is prohibited for this type
160        match self.ty.as_ref() {
161            "bitnami" | "cargo" | "cocoapods" | "conda" | "cran" | "gem" | "hackage" | "mlflow"
162            | "nuget" | "oci" | "pub" | "pypi" => {
163                return Err(Error::TypeProhibitsNamespace(self.ty.to_string()));
164            }
165            _ => {}
166        }
167
168        // Lowercase namespace if needed for this type
169        let mut n = namespace.into();
170        match self.ty.as_ref() {
171            "apk" | "bitbucket" | "composer" | "deb" | "github" | "golang" | "hex" | "qpkg"
172            | "rpm" => {
173                n = to_lowercase(n);
174            }
175            _ => {}
176        }
177
178        self.namespace = Some(n);
179        Ok(self)
180    }
181
182    /// Clear the namespace
183    pub fn without_namespace(&mut self) -> &mut Self {
184        self.namespace = None;
185        self
186    }
187
188    /// Assign a version to the package.
189    pub fn with_version<V>(&mut self, version: V) -> Result<&mut Self>
190    where
191        V: Into<Cow<'a, str>>,
192    {
193        self.version = Some(version.into());
194        Ok(self)
195    }
196
197    /// Clear the version
198    pub fn without_version(&mut self) -> &mut Self {
199        self.version = None;
200        self
201    }
202
203    /// Assign a subpath to the package.
204    ///
205    /// Subpaths must not contain empty, local ('.') or parent ('..') segments,
206    /// otherwise an error will be returned.
207    pub fn with_subpath<S>(&mut self, subpath: S) -> Result<&mut Self>
208    where
209        S: Into<Cow<'a, str>>,
210    {
211        let s = subpath.into();
212        for component in s.split('/') {
213            if !validation::is_subpath_segment_valid(component) {
214                return Err(Error::InvalidSubpathSegment(component.into()));
215            }
216        }
217        self.subpath = Some(s);
218        Ok(self)
219    }
220
221    /// Clear the subpath
222    pub fn without_subpath(&mut self) -> &mut Self {
223        self.subpath = None;
224        self
225    }
226
227    /// Clear qualifiers
228    pub fn clear_qualifiers(&mut self) -> &mut Self {
229        self.qualifiers.clear();
230        self
231    }
232
233    /// Add a qualifier to the package.
234    pub fn add_qualifier<K, V>(&mut self, key: K, value: V) -> Result<&mut Self>
235    where
236        K: Into<Cow<'a, str>>,
237        V: Into<Cow<'a, str>>,
238    {
239        let mut k = key.into();
240        if !validation::is_qualifier_key_valid(&k) {
241            Err(Error::InvalidKey(k.into()))
242        } else {
243            k = to_lowercase(k);
244            self.qualifiers.insert(k, value.into());
245            Ok(self)
246        }
247    }
248}
249
250impl FromStr for PackageUrl<'static> {
251    type Err = Error;
252
253    fn from_str(s: &str) -> Result<Self> {
254        // Parse all components into strings (since we don't know infer from `s` lifetime)
255        let (s, _) = parser::parse_scheme(s)?;
256        let (s, subpath) = parser::parse_subpath(s)?;
257        let (s, ql) = parser::parse_qualifiers(s)?;
258        let (s, version) = parser::parse_version(s)?;
259        let (s, ty) = parser::parse_type(s)?;
260        let (s, mut name) = parser::parse_name(s)?;
261        let (_, mut namespace) = parser::parse_namespace(s)?;
262
263        // Special rules for some types
264        match ty.as_ref() {
265            "bitbucket" | "github" => {
266                name = name.to_lowercase();
267                namespace = namespace.map(|ns| ns.to_lowercase());
268            }
269            "pypi" => {
270                name = name.replace('_', "-").to_lowercase();
271            }
272            _ => {}
273        };
274
275        let mut purl = Self::new(ty, name)?;
276        if let Some(ns) = namespace {
277            purl.with_namespace(ns)?;
278        }
279        if let Some(v) = version {
280            purl.with_version(v)?;
281        }
282        if let Some(sp) = subpath {
283            purl.with_subpath(sp)?;
284        }
285        for (k, v) in ql.into_iter() {
286            purl.add_qualifier(k, v)?;
287        }
288
289        // The obtained package url
290        Ok(purl)
291    }
292}
293
294impl Display for PackageUrl<'_> {
295    fn fmt(&self, f: &mut Formatter) -> FmtResult {
296        // Scheme: constant
297        f.write_str("pkg:")?;
298
299        // Type: no encoding needed
300        self.ty.fmt(f).and(f.write_str("/"))?;
301
302        // Namespace: percent-encode each component
303        if let Some(ref ns) = self.namespace {
304            for component in ns.split('/').map(|s| s.encode(ENCODE_SET)) {
305                component.fmt(f).and(f.write_str("/"))?;
306            }
307        }
308
309        // Name: percent-encode the name
310        self.name.encode(ENCODE_SET).fmt(f)?;
311
312        // Version: percent-encode the version
313        if let Some(ref v) = self.version {
314            f.write_str("@").and(v.encode(ENCODE_SET).fmt(f))?;
315        }
316
317        // Qualifiers: percent-encode the values
318        if !self.qualifiers.is_empty() {
319            f.write_str("?")?;
320
321            let mut items = self.qualifiers.iter().collect::<Vec<_>>();
322            items.sort();
323
324            let mut iter = items.into_iter();
325            if let Some((k, v)) = iter.next() {
326                k.fmt(f)
327                    .and(f.write_str("="))
328                    .and(v.encode(ENCODE_SET).fmt(f))?;
329            }
330            for (k, v) in iter {
331                f.write_str("&")
332                    .and(k.fmt(f))
333                    .and(f.write_str("="))
334                    .and(v.encode(ENCODE_SET).fmt(f))?;
335            }
336        }
337
338        // Subpath: percent-encode the components
339        if let Some(ref sp) = self.subpath {
340            f.write_str("#")?;
341            let mut components = sp
342                .split('/')
343                .filter(|&s| !(s.is_empty() || s == "." || s == ".."));
344            if let Some(component) = components.next() {
345                component.encode(ENCODE_SET).fmt(f)?;
346            }
347            for component in components {
348                f.write_str("/")?;
349                component.encode(ENCODE_SET).fmt(f)?;
350            }
351        }
352
353        Ok(())
354    }
355}
356
357#[cfg(test)]
358mod tests {
359
360    use super::*;
361
362    #[test]
363    fn test_from_str() {
364        let raw_purl = "pkg:type/name/space/name@version?k1=v1&k2=v2#sub/path";
365        let purl = PackageUrl::from_str(raw_purl).unwrap();
366        assert_eq!(purl.ty(), "type");
367        assert_eq!(purl.namespace(), Some("name/space"));
368        assert_eq!(purl.name(), "name");
369        assert_eq!(purl.version(), Some("version"));
370        assert_eq!(purl.qualifiers().get("k1"), Some(&Cow::Borrowed("v1")));
371        assert_eq!(purl.qualifiers().get("k2"), Some(&Cow::Borrowed("v2")));
372        assert_eq!(purl.subpath(), Some("sub/path"));
373    }
374
375    #[test]
376    fn test_to_str() {
377        let canonical = "pkg:type/name/space/name@version?k1=v1&k2=v2#sub/path";
378        let purl_string = PackageUrl::new("type", "name")
379            .unwrap()
380            .with_namespace("name/space")
381            .unwrap()
382            .with_version("version")
383            .unwrap()
384            .with_subpath("sub/path")
385            .unwrap()
386            .add_qualifier("k1", "v1")
387            .unwrap()
388            .add_qualifier("k2", "v2")
389            .unwrap()
390            .to_string();
391        assert_eq!(&purl_string, canonical);
392    }
393
394    #[test]
395    fn test_percent_encoding_idempotent() {
396        let orig = "pkg:brew/openssl%25401.1@1.1.1w";
397        let round_trip = orig.parse::<PackageUrl>().unwrap().to_string();
398        assert_eq!(orig, round_trip);
399    }
400
401    #[test]
402    fn test_percent_encoding_qualifier() {
403        let mut purl = "pkg:deb/ubuntu/gnome-calculator@1:41.1-2ubuntu2"
404            .parse::<PackageUrl>()
405            .unwrap();
406        purl.add_qualifier(
407            "vcs_url",
408            "git+https://salsa.debian.org/gnome-team/gnome-calculator.git@debian/1%41.1-2",
409        )
410        .unwrap();
411        let encoded = purl.to_string();
412        assert_eq!(encoded, "pkg:deb/ubuntu/gnome-calculator@1:41.1-2ubuntu2?vcs_url=git%2Bhttps://salsa.debian.org/gnome-team/gnome-calculator.git%40debian/1%2541.1-2");
413    }
414
415    #[cfg(feature = "serde")]
416    #[test]
417    fn test_serde() {
418        let mut purl = PackageUrl::new("type", "name").unwrap();
419        purl.with_namespace("name/space")
420            .with_version("version")
421            .with_subpath("sub/path")
422            .unwrap()
423            .add_qualifier("k1", "v1")
424            .unwrap()
425            .add_qualifier("k2", "v2")
426            .unwrap();
427
428        let j = serde_json::to_string(&purl).unwrap();
429        let purl2: PackageUrl = serde_json::from_str(&j).unwrap();
430
431        assert_eq!(purl, purl2);
432    }
433
434    #[test]
435    fn test_plus_sign_in_version() {
436        let expected = "pkg:type/name@1%2Bx";
437        for purl in [
438            "pkg:type/name@1+x",
439            "pkg:type/name@1%2bx",
440            "pkg:type/name@1%2Bx",
441        ] {
442            let actual = PackageUrl::from_str(purl).unwrap().to_string();
443            assert_eq!(actual, expected);
444        }
445    }
446}