use std::borrow::Cow;
use std::str::FromStr;
use packageurl::PackageUrl;
pub fn normalize_purl(purl: &str) -> String {
let Ok(parsed) = PackageUrl::from_str(purl) else {
return purl.to_string();
};
let (new_namespace, new_name): (Option<String>, String) = match parsed.ty() {
"pypi" => (None, normalize_pypi_name(parsed.name())),
"composer" | "hex" | "github" | "gitlab" | "bitbucket" => (
parsed.namespace().map(str::to_ascii_lowercase),
parsed.name().to_ascii_lowercase(),
),
"golang" => return lowercase_first_path_segment(purl, "pkg:golang/"),
_ => return purl.to_string(),
};
rebuild(purl, &parsed, new_namespace, new_name)
}
fn rebuild(
original: &str,
parsed: &PackageUrl<'_>,
namespace: Option<String>,
name: String,
) -> String {
let Ok(mut rebuilt) = PackageUrl::new(parsed.ty().to_string(), name) else {
return original.to_string();
};
if let Some(namespace) = namespace.filter(|value| !value.is_empty())
&& rebuilt.with_namespace(namespace).is_err()
{
return original.to_string();
}
if let Some(version) = parsed.version()
&& rebuilt.with_version(version.to_string()).is_err()
{
return original.to_string();
}
for (key, value) in parsed.qualifiers() {
if rebuilt
.add_qualifier(key.to_string(), value.to_string())
.is_err()
{
return original.to_string();
}
}
if let Some(subpath) = parsed.subpath()
&& rebuilt.with_subpath(subpath.to_string()).is_err()
{
return original.to_string();
}
rebuilt.to_string()
}
fn normalize_pypi_name(name: &str) -> String {
let lower = name.to_ascii_lowercase();
let mut normalized = String::with_capacity(lower.len());
let mut last_was_separator = false;
for ch in lower.chars() {
if matches!(ch, '-' | '_' | '.') {
if !last_was_separator {
normalized.push('-');
last_was_separator = true;
}
} else {
normalized.push(ch);
last_was_separator = false;
}
}
normalized
}
fn lowercase_first_path_segment(purl: &str, prefix: &str) -> String {
let Some(rest) = purl.strip_prefix(prefix) else {
return purl.to_string();
};
let end = rest.find(['/', '@', '?', '#']).unwrap_or(rest.len());
format!(
"{prefix}{}{}",
rest[..end].to_ascii_lowercase(),
&rest[end..]
)
}
const UID_MARKER: &str = "uuid";
const UID_MARKER_ALT: &str = "uid";
pub(crate) fn append_uuid_qualifier(base: &str, uuid: &str) -> String {
let (head, subpath) = split_subpath(base);
let separator = if head.contains('?') { '&' } else { '?' };
let marker = if has_qualifier(head, UID_MARKER) {
UID_MARKER_ALT
} else {
UID_MARKER
};
match subpath {
Some(subpath) => format!("{head}{separator}{marker}={uuid}#{subpath}"),
None => format!("{head}{separator}{marker}={uuid}"),
}
}
pub(crate) fn strip_uuid_qualifier(uid: &str) -> Cow<'_, str> {
let (head, subpath) = split_subpath(uid);
let Some((prefix, _)) = split_uid_marker(head) else {
return Cow::Borrowed(uid);
};
match subpath {
Some(subpath) => Cow::Owned(format!("{prefix}#{subpath}")),
None => Cow::Borrowed(prefix),
}
}
pub(crate) fn uuid_qualifier_value(uid: &str) -> Option<&str> {
let (head, _) = split_subpath(uid);
split_uid_marker(head).map(|(_, uuid)| uuid)
}
fn has_qualifier(head: &str, key: &str) -> bool {
head.contains(&format!("?{key}=")) || head.contains(&format!("&{key}="))
}
fn split_uid_marker(head: &str) -> Option<(&str, &str)> {
for marker in [UID_MARKER_ALT, UID_MARKER] {
let separator_index = [format!("?{marker}="), format!("&{marker}=")]
.iter()
.filter_map(|pattern| head.rfind(pattern.as_str()))
.max();
if let Some(index) = separator_index {
let value_start = index + marker.len() + 2;
let value = &head[value_start..];
let value = value.split_once('&').map_or(value, |(value, _)| value);
return Some((&head[..index], value));
}
}
None
}
fn split_subpath(purl: &str) -> (&str, Option<&str>) {
match purl.split_once('#') {
Some((head, subpath)) => (head, Some(subpath)),
None => (purl, None),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uuid_qualifier_stays_a_qualifier_when_the_purl_has_a_subpath() {
let uid = append_uuid_qualifier("pkg:cocoapods/SwiftFormat@0.44.17#CLI", "abc");
assert_eq!(uid, "pkg:cocoapods/SwiftFormat@0.44.17?uuid=abc#CLI");
assert_eq!(
strip_uuid_qualifier(&uid),
"pkg:cocoapods/SwiftFormat@0.44.17#CLI"
);
let parsed = PackageUrl::from_str(&uid).expect("uid should parse as a purl");
assert_eq!(parsed.subpath(), Some("CLI"));
assert_eq!(
parsed.qualifiers().get("uuid").map(Cow::as_ref),
Some("abc")
);
}
#[test]
fn uid_marker_does_not_collide_with_a_purls_own_uuid_qualifier() {
let base = "pkg:julia/HTTP@1.0.0?uuid=cd3eb016-35fb-5094-929b-558a96fad6f3";
let uid = append_uuid_qualifier(base, "98920f38-6039-4eaf-925e-f1216f083eba");
assert_eq!(
uid,
"pkg:julia/HTTP@1.0.0?uuid=cd3eb016-35fb-5094-929b-558a96fad6f3&uid=98920f38-6039-4eaf-925e-f1216f083eba"
);
let parsed = PackageUrl::from_str(&uid).expect("uid should parse");
assert_eq!(
parsed.qualifiers().get("uuid").map(Cow::as_ref),
Some("cd3eb016-35fb-5094-929b-558a96fad6f3")
);
assert_eq!(
parsed.qualifiers().get("uid").map(Cow::as_ref),
Some("98920f38-6039-4eaf-925e-f1216f083eba")
);
assert_eq!(strip_uuid_qualifier(&uid), base);
assert_eq!(
uuid_qualifier_value(&uid),
Some("98920f38-6039-4eaf-925e-f1216f083eba")
);
}
#[test]
fn a_uid_written_before_the_alternate_marker_resolves_to_the_appended_one() {
let legacy = "pkg:julia/HTTP@1.0.0?uuid=cd3eb016-35fb-5094-929b-558a96fad6f3&uuid=98920f38-6039-4eaf-925e-f1216f083eba";
assert_eq!(
strip_uuid_qualifier(legacy),
"pkg:julia/HTTP@1.0.0?uuid=cd3eb016-35fb-5094-929b-558a96fad6f3"
);
assert_eq!(
uuid_qualifier_value(legacy),
Some("98920f38-6039-4eaf-925e-f1216f083eba")
);
}
#[test]
fn uuid_qualifier_joins_existing_qualifiers_with_an_ampersand() {
let uid = append_uuid_qualifier("pkg:generic/x?arch=amd64", "abc");
assert_eq!(uid, "pkg:generic/x?arch=amd64&uuid=abc");
assert_eq!(strip_uuid_qualifier(&uid), "pkg:generic/x?arch=amd64");
}
#[test]
fn uuid_qualifier_round_trips_plain_purls_and_opaque_bases() {
for base in [
"pkg:pypi/requests@2.0",
"pkg:npm/%40scope/name@1.0.0",
"generated-package:cargo/unknown@unknown",
] {
let uid = append_uuid_qualifier(base, "abc");
assert_eq!(uid, format!("{base}?uuid=abc"));
assert_eq!(strip_uuid_qualifier(&uid), base);
}
}
#[test]
fn strip_uuid_qualifier_leaves_a_uid_without_one_untouched() {
assert_eq!(
strip_uuid_qualifier("pkg:pypi/requests@2.0"),
"pkg:pypi/requests@2.0"
);
assert_eq!(strip_uuid_qualifier(""), "");
}
#[test]
fn normalize_purl_matrix() {
let cases = [
(
"pkg:pypi/typing_extensions@4.0.0",
"pkg:pypi/typing-extensions@4.0.0",
),
("pkg:pypi/Django@4.2", "pkg:pypi/django@4.2"),
("pkg:pypi/zope.interface@5.0", "pkg:pypi/zope-interface@5.0"),
("pkg:pypi/foo__bar@1.0", "pkg:pypi/foo-bar@1.0"),
(
"pkg:composer/Monolog/Monolog@2.0",
"pkg:composer/monolog/monolog@2.0",
),
("pkg:hex/Phoenix@1.7.0", "pkg:hex/phoenix@1.7.0"),
(
"pkg:github/Package-Url/purl-Spec@1.0",
"pkg:github/package-url/purl-spec@1.0",
),
("pkg:gitlab/FooBar/Baz@2.0", "pkg:gitlab/foobar/baz@2.0"),
(
"pkg:bitbucket/Birkenfeld/Pygments@2.0",
"pkg:bitbucket/birkenfeld/pygments@2.0",
),
(
"pkg:golang/github.com/Azure/azure-sdk-for-go@1.0",
"pkg:golang/github.com/Azure/azure-sdk-for-go@1.0",
),
(
"pkg:golang/GitHub.com/Azure/azure-sdk-for-go@1.0",
"pkg:golang/github.com/Azure/azure-sdk-for-go@1.0",
),
];
for (input, expected) in cases {
assert_eq!(normalize_purl(input), expected, "input: {input}");
}
}
#[test]
fn normalize_purl_preserves_case_sensitive_types() {
let untouched = [
"pkg:npm/%40angular/Core@13.0.0",
"pkg:maven/com.Example/MyLib@1.0",
"pkg:cargo/Serde@1.0",
"pkg:gem/RSpec@3.0",
"pkg:bower/SomeLib@1.0",
];
for purl in untouched {
assert_eq!(normalize_purl(purl), purl, "input: {purl}");
}
}
#[test]
fn normalize_purl_is_idempotent() {
let inputs = [
"pkg:pypi/typing_extensions@4.0.0",
"pkg:composer/Monolog/Monolog@2.0",
"pkg:golang/GitHub.com/Azure/azure-sdk-for-go@1.0",
"pkg:github/Foo/Bar",
];
for input in inputs {
let once = normalize_purl(input);
let twice = normalize_purl(&once);
assert_eq!(once, twice, "not idempotent for {input}");
}
}
#[test]
fn normalize_purl_preserves_qualifiers_and_subpath() {
assert_eq!(
normalize_purl("pkg:pypi/typing_extensions@4.0?arch=any#sub/path"),
"pkg:pypi/typing-extensions@4.0?arch=any#sub/path",
);
}
#[test]
fn normalize_purl_returns_unparsable_input_unchanged() {
assert_eq!(normalize_purl("not-a-purl"), "not-a-purl");
assert_eq!(normalize_purl(""), "");
}
#[test]
fn normalize_purl_handles_pypi_without_version() {
assert_eq!(
normalize_purl("pkg:pypi/typing_extensions"),
"pkg:pypi/typing-extensions",
);
}
#[test]
fn normalize_purl_golang_no_namespace() {
assert_eq!(
normalize_purl("pkg:golang/Std@go1.21"),
"pkg:golang/std@go1.21",
);
assert_eq!(
normalize_purl("pkg:golang/std@go1.21"),
"pkg:golang/std@go1.21",
);
}
#[test]
fn normalize_purl_golang_preserves_qualifiers_and_subpath() {
assert_eq!(
normalize_purl(
"pkg:golang/GITHUB.COM/Azure/pkg@1.0?vcs_url=https://github.com/Azure/pkg#sub/path"
),
"pkg:golang/github.com/Azure/pkg@1.0?vcs_url=https://github.com/Azure/pkg#sub/path",
);
}
#[test]
fn normalize_purl_golang_mixed_case_type_unchanged() {
assert_eq!(
normalize_purl("pkg:Golang/GITHUB.COM/Azure/pkg@1.0"),
"pkg:Golang/GITHUB.COM/Azure/pkg@1.0",
);
}
}