use std::borrow::Cow;
use std::collections::HashMap;
use std::fmt::Display;
use std::fmt::Formatter;
use std::fmt::Result as FmtResult;
use std::str::FromStr;
use percent_encoding::AsciiSet;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use super::errors::Error;
use super::errors::Result;
use super::parser;
use super::utils::{PercentCodec, to_lowercase};
use super::validation;
const ENCODE_SET: &AsciiSet = &percent_encoding::CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'%')
.add(b'<')
.add(b'>')
.add(b'`')
.add(b'?')
.add(b'{')
.add(b'}')
.add(b';')
.add(b'=')
.add(b'+')
.add(b'@')
.add(b'\\')
.add(b'[')
.add(b']')
.add(b'^')
.add(b'|');
const NAME_ENCODE_SET: &AsciiSet = &ENCODE_SET.add(b'/');
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct PackageUrl<'a> {
pub(crate) ty: Cow<'a, str>,
pub(crate) namespace: Option<Cow<'a, str>>,
pub(crate) name: Cow<'a, str>,
pub(crate) version: Option<Cow<'a, str>>,
pub(crate) qualifiers: HashMap<Cow<'a, str>, Cow<'a, str>>,
pub(crate) subpath: Option<Cow<'a, str>>,
}
impl<'a> PackageUrl<'a> {
pub fn new<T, N>(ty: T, name: N) -> Result<Self>
where
T: Into<Cow<'a, str>>,
N: Into<Cow<'a, str>>,
{
let mut t = ty.into();
let mut n = name.into();
if validation::is_type_valid(&t) {
t = to_lowercase(t);
match t.as_ref() {
"bitbucket" | "deb" | "github" | "hex" | "npm" => {
n = to_lowercase(n);
}
"pypi" => {
n = to_lowercase(n);
if n.chars().any(|c| c == '_') {
n = Cow::Owned(n.replace('_', "-"));
}
}
_ => {}
}
Ok(Self::new_unchecked(t, n))
} else {
Err(Error::InvalidType(t.to_string()))
}
}
fn new_unchecked<T, N>(ty: T, name: N) -> Self
where
T: Into<Cow<'a, str>>,
N: Into<Cow<'a, str>>,
{
Self {
ty: ty.into(),
namespace: None,
name: name.into(),
version: None,
qualifiers: HashMap::new(),
subpath: None,
}
}
pub fn ty(&self) -> &str {
self.ty.as_ref()
}
pub fn namespace(&self) -> Option<&str> {
self.namespace.as_ref().map(Cow::as_ref)
}
pub fn name(&self) -> &str {
self.name.as_ref()
}
pub fn version(&self) -> Option<&str> {
self.version.as_ref().map(Cow::as_ref)
}
pub fn qualifiers(&self) -> &HashMap<Cow<'a, str>, Cow<'a, str>> {
&self.qualifiers
}
pub fn subpath(&self) -> Option<&str> {
self.subpath.as_ref().map(Cow::as_ref)
}
pub fn with_namespace<N>(&mut self, namespace: N) -> Result<&mut Self>
where
N: Into<Cow<'a, str>>,
{
match self.ty.as_ref() {
"bitnami" | "cargo" | "cocoapods" | "conda" | "cran" | "gem" | "hackage" | "mlflow"
| "nuget" | "oci" | "pub" | "pypi" => {
return Err(Error::TypeProhibitsNamespace(self.ty.to_string()));
}
_ => {}
}
let mut n = namespace.into();
match self.ty.as_ref() {
"apk" | "bitbucket" | "composer" | "deb" | "github" | "golang" | "hex" | "qpkg"
| "rpm" => {
n = to_lowercase(n);
}
_ => {}
}
self.namespace = Some(n);
Ok(self)
}
pub fn without_namespace(&mut self) -> &mut Self {
self.namespace = None;
self
}
pub fn with_version<V>(&mut self, version: V) -> Result<&mut Self>
where
V: Into<Cow<'a, str>>,
{
self.version = Some(version.into());
Ok(self)
}
pub fn without_version(&mut self) -> &mut Self {
self.version = None;
self
}
pub fn with_subpath<S>(&mut self, subpath: S) -> Result<&mut Self>
where
S: Into<Cow<'a, str>>,
{
let s = subpath.into();
for component in s.split('/') {
if !validation::is_subpath_segment_valid(component) {
return Err(Error::InvalidSubpathSegment(component.into()));
}
}
self.subpath = Some(s);
Ok(self)
}
pub fn without_subpath(&mut self) -> &mut Self {
self.subpath = None;
self
}
pub fn clear_qualifiers(&mut self) -> &mut Self {
self.qualifiers.clear();
self
}
pub fn add_qualifier<K, V>(&mut self, key: K, value: V) -> Result<&mut Self>
where
K: Into<Cow<'a, str>>,
V: Into<Cow<'a, str>>,
{
let mut k = key.into();
if !validation::is_qualifier_key_valid(&k) {
Err(Error::InvalidKey(k.into()))
} else {
k = to_lowercase(k);
self.qualifiers.insert(k, value.into());
Ok(self)
}
}
}
impl FromStr for PackageUrl<'static> {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
let (s, _) = parser::parse_scheme(s)?;
let (s, subpath) = parser::parse_subpath(s)?;
let (s, ql) = parser::parse_qualifiers(s)?;
let (s, version) = parser::parse_version(s)?;
let (s, ty) = parser::parse_type(s)?;
let (s, mut name) = parser::parse_name(s)?;
let (_, mut namespace) = parser::parse_namespace(s)?;
match ty.as_ref() {
"bitbucket" | "github" => {
name = name.to_lowercase();
namespace = namespace.map(|ns| ns.to_lowercase());
}
"pypi" => {
name = name.replace('_', "-").to_lowercase();
}
_ => {}
};
let mut purl = Self::new(ty, name)?;
if let Some(ns) = namespace {
purl.with_namespace(ns)?;
}
if let Some(v) = version {
purl.with_version(v)?;
}
if let Some(sp) = subpath {
purl.with_subpath(sp)?;
}
for (k, v) in ql.into_iter() {
purl.add_qualifier(k, v)?;
}
Ok(purl)
}
}
impl Display for PackageUrl<'_> {
fn fmt(&self, f: &mut Formatter) -> FmtResult {
f.write_str("pkg:")?;
self.ty.fmt(f).and(f.write_str("/"))?;
if let Some(ref ns) = self.namespace {
for component in ns.split('/').map(|s| s.encode(NAME_ENCODE_SET)) {
component.fmt(f).and(f.write_str("/"))?;
}
}
self.name.encode(NAME_ENCODE_SET).fmt(f)?;
if let Some(ref v) = self.version {
f.write_str("@").and(v.encode(ENCODE_SET).fmt(f))?;
}
if !self.qualifiers.is_empty() {
f.write_str("?")?;
let mut items = self.qualifiers.iter().collect::<Vec<_>>();
items.sort();
let mut iter = items.into_iter();
if let Some((k, v)) = iter.next() {
k.fmt(f)
.and(f.write_str("="))
.and(v.encode(ENCODE_SET).fmt(f))?;
}
for (k, v) in iter {
f.write_str("&")
.and(k.fmt(f))
.and(f.write_str("="))
.and(v.encode(ENCODE_SET).fmt(f))?;
}
}
if let Some(ref sp) = self.subpath {
f.write_str("#")?;
let mut components = sp
.split('/')
.filter(|&s| !(s.is_empty() || s == "." || s == ".."));
if let Some(component) = components.next() {
component.encode(ENCODE_SET).fmt(f)?;
}
for component in components {
f.write_str("/")?;
component.encode(ENCODE_SET).fmt(f)?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_from_str() {
let raw_purl = "pkg:type/name/space/name@version?k1=v1&k2=v2#sub/path";
let purl = PackageUrl::from_str(raw_purl).unwrap();
assert_eq!(purl.ty(), "type");
assert_eq!(purl.namespace(), Some("name/space"));
assert_eq!(purl.name(), "name");
assert_eq!(purl.version(), Some("version"));
assert_eq!(purl.qualifiers().get("k1"), Some(&Cow::Borrowed("v1")));
assert_eq!(purl.qualifiers().get("k2"), Some(&Cow::Borrowed("v2")));
assert_eq!(purl.subpath(), Some("sub/path"));
}
#[test]
fn test_to_str() {
let canonical = "pkg:type/name/space/name@version?k1=v1&k2=v2#sub/path";
let purl_string = PackageUrl::new("type", "name")
.unwrap()
.with_namespace("name/space")
.unwrap()
.with_version("version")
.unwrap()
.with_subpath("sub/path")
.unwrap()
.add_qualifier("k1", "v1")
.unwrap()
.add_qualifier("k2", "v2")
.unwrap()
.to_string();
assert_eq!(&purl_string, canonical);
}
#[test]
fn test_percent_encoding_idempotent() {
let orig = "pkg:brew/open%2Fssl%25401.1@1.1.1w";
let round_trip = orig.parse::<PackageUrl>().unwrap().to_string();
assert_eq!(orig, round_trip);
}
#[test]
fn test_percent_encoded_name() {
let raw_purl = "pkg:type/name/space/first%2Fname";
let purl = PackageUrl::from_str(raw_purl).unwrap();
assert_eq!(purl.ty(), "type");
assert_eq!(purl.namespace(), Some("name/space"));
assert_eq!(purl.name(), "first/name");
}
#[test]
fn test_percent_encoding_qualifier() {
let mut purl = "pkg:deb/ubuntu/gnome-calculator@1:41.1-2ubuntu2"
.parse::<PackageUrl>()
.unwrap();
purl.add_qualifier(
"vcs_url",
"git+https://salsa.debian.org/gnome-team/gnome-calculator.git@debian/1%41.1-2",
)
.unwrap();
let encoded = purl.to_string();
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"
);
}
#[cfg(feature = "serde")]
#[test]
fn test_serde() {
let mut purl = PackageUrl::new("type", "name").unwrap();
purl.with_namespace("name/space")
.with_version("version")
.with_subpath("sub/path")
.unwrap()
.add_qualifier("k1", "v1")
.unwrap()
.add_qualifier("k2", "v2")
.unwrap();
let j = serde_json::to_string(&purl).unwrap();
let purl2: PackageUrl = serde_json::from_str(&j).unwrap();
assert_eq!(purl, purl2);
}
#[test]
fn test_plus_sign_in_version() {
let expected = "pkg:type/name@1%2Bx";
for purl in [
"pkg:type/name@1+x",
"pkg:type/name@1%2bx",
"pkg:type/name@1%2Bx",
] {
let actual = PackageUrl::from_str(purl).unwrap().to_string();
assert_eq!(actual, expected);
}
}
}