Skip to main content

provenant/models/
purl.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Central, per-type Package-URL (PURL) normalization.
5//!
6//! The `packageurl` crate percent-encodes components and lowercases a few
7//! hard-coded names, but does not apply the full per-type case/canonicalization
8//! rules from the [purl-spec](https://github.com/package-url/purl-spec), and
9//! never touches the namespace. Without a central layer each parser would have
10//! to reimplement these rules, so the same package could get different PURLs
11//! from different datasources (e.g. `typing_extensions` vs `typing-extensions`),
12//! breaking dedup and registry/vuln-database lookups.
13//!
14//! Every emitted PURL passes through [`normalize_purl`]: in-memory before a
15//! [`crate::models::Package`] derives its `package_uid` (the dedup key), and at
16//! the `src/output_schema` boundary for package, package-data, dependency, and
17//! resolved-package PURLs.
18//!
19//! This layer covers the case/name-canonicalization rules. Structural per-type
20//! fixes that need parser knowledge (type remapping, moving a value between
21//! namespace/qualifier/subpath, synthesizing a required qualifier) stay with the
22//! owning parser. Unlisted types are returned unchanged, preserving the
23//! case-sensitive types (npm, maven, cargo, gem, …).
24
25use std::str::FromStr;
26
27use packageurl::PackageUrl;
28
29/// Normalize a PURL string according to its type's spec rules.
30///
31/// Per-type case/canonicalization rules are applied to the namespace and name;
32/// version, qualifiers, and subpath are preserved. Unparsable input and types
33/// with no rule are returned unchanged, so already-canonical PURLs never churn.
34pub fn normalize_purl(purl: &str) -> String {
35    let Ok(parsed) = PackageUrl::from_str(purl) else {
36        return purl.to_string();
37    };
38
39    let (new_namespace, new_name): (Option<String>, String) = match parsed.ty() {
40        // PEP 503: lowercase, then collapse runs of `-_.` to a single `-` (the
41        // crate only handles `_`). Namespace is prohibited for pypi.
42        "pypi" => (None, normalize_pypi_name(parsed.name())),
43
44        // Lowercase namespace + name. The crate lowercases some of these names
45        // but never the namespace.
46        "composer" | "hex" | "github" | "gitlab" | "bitbucket" => (
47            parsed.namespace().map(str::to_ascii_lowercase),
48            parsed.name().to_ascii_lowercase(),
49        ),
50
51        // golang's spec is self-contradictory and acknowledged-broken; the
52        // decided direction (purl-spec#308) is to lowercase only the host
53        // segment and preserve path-part case (e.g. keep `github.com/Azure/…`).
54        // The crate force-lowercases the whole golang namespace at parse time,
55        // so `parsed` has already lost the case — edit the raw string instead.
56        "golang" => return lowercase_first_path_segment(purl, "pkg:golang/"),
57
58        _ => return purl.to_string(),
59    };
60
61    rebuild(purl, &parsed, new_namespace, new_name)
62}
63
64/// Re-emit `parsed` with a replaced namespace/name, preserving the rest.
65///
66/// Falls back to the original string if the rebuilt PURL cannot be constructed.
67fn rebuild(
68    original: &str,
69    parsed: &PackageUrl<'_>,
70    namespace: Option<String>,
71    name: String,
72) -> String {
73    let Ok(mut rebuilt) = PackageUrl::new(parsed.ty().to_string(), name) else {
74        return original.to_string();
75    };
76
77    if let Some(namespace) = namespace.filter(|value| !value.is_empty())
78        && rebuilt.with_namespace(namespace).is_err()
79    {
80        return original.to_string();
81    }
82
83    if let Some(version) = parsed.version()
84        && rebuilt.with_version(version.to_string()).is_err()
85    {
86        return original.to_string();
87    }
88
89    for (key, value) in parsed.qualifiers() {
90        if rebuilt
91            .add_qualifier(key.to_string(), value.to_string())
92            .is_err()
93        {
94            return original.to_string();
95        }
96    }
97
98    if let Some(subpath) = parsed.subpath()
99        && rebuilt.with_subpath(subpath.to_string()).is_err()
100    {
101        return original.to_string();
102    }
103
104    rebuilt.to_string()
105}
106
107/// Apply the PEP 503 normalized distribution name rule: lowercase, then collapse
108/// every run of `-`, `_`, or `.` into a single `-`.
109fn normalize_pypi_name(name: &str) -> String {
110    let lower = name.to_ascii_lowercase();
111    let mut normalized = String::with_capacity(lower.len());
112    let mut last_was_separator = false;
113
114    for ch in lower.chars() {
115        if matches!(ch, '-' | '_' | '.') {
116            if !last_was_separator {
117                normalized.push('-');
118                last_was_separator = true;
119            }
120        } else {
121            normalized.push(ch);
122            last_was_separator = false;
123        }
124    }
125
126    normalized
127}
128
129/// Lowercase the first path segment that follows `prefix` in a PURL string,
130/// stopping at the next path separator or component delimiter (`/ @ ? #`).
131///
132/// Used for golang's host-only lowercasing: it edits the raw string so the
133/// remaining path parts, version, qualifiers, and subpath survive untouched.
134/// Returns the input unchanged if it does not start with `prefix`.
135fn lowercase_first_path_segment(purl: &str, prefix: &str) -> String {
136    let Some(rest) = purl.strip_prefix(prefix) else {
137        return purl.to_string();
138    };
139    let end = rest.find(['/', '@', '?', '#']).unwrap_or(rest.len());
140    format!(
141        "{prefix}{}{}",
142        rest[..end].to_ascii_lowercase(),
143        &rest[end..]
144    )
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    /// Spec-rule matrix: representative PURL per type asserted against the
152    /// canonical form. Guards every parser against drift.
153    #[test]
154    fn normalize_purl_matrix() {
155        let cases = [
156            // pypi: full PEP 503 (lowercase + collapse `-_.` runs).
157            (
158                "pkg:pypi/typing_extensions@4.0.0",
159                "pkg:pypi/typing-extensions@4.0.0",
160            ),
161            ("pkg:pypi/Django@4.2", "pkg:pypi/django@4.2"),
162            ("pkg:pypi/zope.interface@5.0", "pkg:pypi/zope-interface@5.0"),
163            ("pkg:pypi/foo__bar@1.0", "pkg:pypi/foo-bar@1.0"),
164            // composer: lowercase vendor namespace + name.
165            (
166                "pkg:composer/Monolog/Monolog@2.0",
167                "pkg:composer/monolog/monolog@2.0",
168            ),
169            // hex: lowercase namespace + name.
170            ("pkg:hex/Phoenix@1.7.0", "pkg:hex/phoenix@1.7.0"),
171            // github / gitlab / bitbucket: lowercase namespace + name.
172            (
173                "pkg:github/Package-Url/purl-Spec@1.0",
174                "pkg:github/package-url/purl-spec@1.0",
175            ),
176            ("pkg:gitlab/FooBar/Baz@2.0", "pkg:gitlab/foobar/baz@2.0"),
177            (
178                "pkg:bitbucket/Birkenfeld/Pygments@2.0",
179                "pkg:bitbucket/birkenfeld/pygments@2.0",
180            ),
181            // golang: lowercase host only, preserve path-part case.
182            (
183                "pkg:golang/github.com/Azure/azure-sdk-for-go@1.0",
184                "pkg:golang/github.com/Azure/azure-sdk-for-go@1.0",
185            ),
186            (
187                "pkg:golang/GitHub.com/Azure/azure-sdk-for-go@1.0",
188                "pkg:golang/github.com/Azure/azure-sdk-for-go@1.0",
189            ),
190        ];
191
192        for (input, expected) in cases {
193            assert_eq!(normalize_purl(input), expected, "input: {input}");
194        }
195    }
196
197    /// Case-sensitive and custom types must be returned byte-for-byte unchanged.
198    #[test]
199    fn normalize_purl_preserves_case_sensitive_types() {
200        let untouched = [
201            // npm grandfathers mixed-case names.
202            "pkg:npm/%40angular/Core@13.0.0",
203            "pkg:maven/com.Example/MyLib@1.0",
204            "pkg:cargo/Serde@1.0",
205            "pkg:gem/RSpec@3.0",
206            // Unknown / custom type with no registered spec.
207            "pkg:bower/SomeLib@1.0",
208        ];
209
210        for purl in untouched {
211            assert_eq!(normalize_purl(purl), purl, "input: {purl}");
212        }
213    }
214
215    #[test]
216    fn normalize_purl_is_idempotent() {
217        let inputs = [
218            "pkg:pypi/typing_extensions@4.0.0",
219            "pkg:composer/Monolog/Monolog@2.0",
220            "pkg:golang/GitHub.com/Azure/azure-sdk-for-go@1.0",
221            "pkg:github/Foo/Bar",
222        ];
223
224        for input in inputs {
225            let once = normalize_purl(input);
226            let twice = normalize_purl(&once);
227            assert_eq!(once, twice, "not idempotent for {input}");
228        }
229    }
230
231    #[test]
232    fn normalize_purl_preserves_qualifiers_and_subpath() {
233        // pypi name changes, but qualifiers and subpath survive the round-trip.
234        assert_eq!(
235            normalize_purl("pkg:pypi/typing_extensions@4.0?arch=any#sub/path"),
236            "pkg:pypi/typing-extensions@4.0?arch=any#sub/path",
237        );
238    }
239
240    #[test]
241    fn normalize_purl_returns_unparsable_input_unchanged() {
242        assert_eq!(normalize_purl("not-a-purl"), "not-a-purl");
243        assert_eq!(normalize_purl(""), "");
244    }
245
246    #[test]
247    fn normalize_purl_handles_pypi_without_version() {
248        assert_eq!(
249            normalize_purl("pkg:pypi/typing_extensions"),
250            "pkg:pypi/typing-extensions",
251        );
252    }
253
254    /// A golang PURL with no namespace (single-segment module path): the whole
255    /// `rest` slice is lowercased, which is the correct host-only rule when the
256    /// module name itself is the host (e.g. the standard library placeholder).
257    #[test]
258    fn normalize_purl_golang_no_namespace() {
259        assert_eq!(
260            normalize_purl("pkg:golang/Std@go1.21"),
261            "pkg:golang/std@go1.21",
262        );
263        // Already lowercase — unchanged.
264        assert_eq!(
265            normalize_purl("pkg:golang/std@go1.21"),
266            "pkg:golang/std@go1.21",
267        );
268    }
269
270    /// Qualifiers and subpath on a golang PURL must survive the raw-string edit.
271    #[test]
272    fn normalize_purl_golang_preserves_qualifiers_and_subpath() {
273        assert_eq!(
274            normalize_purl(
275                "pkg:golang/GITHUB.COM/Azure/pkg@1.0?vcs_url=https://github.com/Azure/pkg#sub/path"
276            ),
277            "pkg:golang/github.com/Azure/pkg@1.0?vcs_url=https://github.com/Azure/pkg#sub/path",
278        );
279    }
280
281    /// A golang PURL whose type prefix is not all-lowercase is returned unchanged
282    /// because `lowercase_first_path_segment` relies on the crate serializer
283    /// always emitting a lowercase type; all PURL-generating paths in this code
284    /// base go through that serializer so this edge is never triggered in practice.
285    #[test]
286    fn normalize_purl_golang_mixed_case_type_unchanged() {
287        // The crate's serializer always lowercases the type, so "pkg:Golang/"
288        // never appears in practice. The function documents this no-op contract.
289        assert_eq!(
290            normalize_purl("pkg:Golang/GITHUB.COM/Azure/pkg@1.0"),
291            "pkg:Golang/GITHUB.COM/Azure/pkg@1.0",
292        );
293    }
294}