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..]
)
}
#[cfg(test)]
mod tests {
use super::*;
#[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",
);
}
}