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';')
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#[derive(Debug, Clone, PartialEq, Eq)]
43#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
44pub struct PackageUrl<'a> {
45 pub(crate) ty: Cow<'a, str>,
47 pub(crate) namespace: Option<Cow<'a, str>>,
49 pub(crate) name: Cow<'a, str>,
51 pub(crate) version: Option<Cow<'a, str>>,
53 pub(crate) qualifiers: HashMap<Cow<'a, str>, Cow<'a, str>>,
55 pub(crate) subpath: Option<Cow<'a, str>>,
57}
58
59impl<'a> PackageUrl<'a> {
60 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 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 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 pub fn ty(&self) -> &str {
126 self.ty.as_ref()
127 }
128
129 pub fn namespace(&self) -> Option<&str> {
131 self.namespace.as_ref().map(Cow::as_ref)
132 }
133
134 pub fn name(&self) -> &str {
136 self.name.as_ref()
137 }
138
139 pub fn version(&self) -> Option<&str> {
141 self.version.as_ref().map(Cow::as_ref)
142 }
143
144 pub fn qualifiers(&self) -> &HashMap<Cow<'a, str>, Cow<'a, str>> {
146 &self.qualifiers
147 }
148
149 pub fn subpath(&self) -> Option<&str> {
151 self.subpath.as_ref().map(Cow::as_ref)
152 }
153
154 pub fn with_namespace<N>(&mut self, namespace: N) -> Result<&mut Self>
156 where
157 N: Into<Cow<'a, str>>,
158 {
159 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 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 pub fn without_namespace(&mut self) -> &mut Self {
184 self.namespace = None;
185 self
186 }
187
188 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 pub fn without_version(&mut self) -> &mut Self {
199 self.version = None;
200 self
201 }
202
203 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 pub fn without_subpath(&mut self) -> &mut Self {
223 self.subpath = None;
224 self
225 }
226
227 pub fn clear_qualifiers(&mut self) -> &mut Self {
229 self.qualifiers.clear();
230 self
231 }
232
233 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 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 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 Ok(purl)
291 }
292}
293
294impl Display for PackageUrl<'_> {
295 fn fmt(&self, f: &mut Formatter) -> FmtResult {
296 f.write_str("pkg:")?;
298
299 self.ty.fmt(f).and(f.write_str("/"))?;
301
302 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 self.name.encode(ENCODE_SET).fmt(f)?;
311
312 if let Some(ref v) = self.version {
314 f.write_str("@").and(v.encode(ENCODE_SET).fmt(f))?;
315 }
316
317 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 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}