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::borrow::Cow;
26use std::str::FromStr;
27
28use packageurl::PackageUrl;
29
30/// Normalize a PURL string according to its type's spec rules.
31///
32/// Per-type case/canonicalization rules are applied to the namespace and name;
33/// version, qualifiers, and subpath are preserved. Unparsable input and types
34/// with no rule are returned unchanged, so already-canonical PURLs never churn.
35pub fn normalize_purl(purl: &str) -> String {
36    let Ok(parsed) = PackageUrl::from_str(purl) else {
37        return purl.to_string();
38    };
39
40    let (new_namespace, new_name): (Option<String>, String) = match parsed.ty() {
41        // PEP 503: lowercase, then collapse runs of `-_.` to a single `-` (the
42        // crate only handles `_`). Namespace is prohibited for pypi.
43        "pypi" => (None, normalize_pypi_name(parsed.name())),
44
45        // Lowercase namespace + name. The crate lowercases some of these names
46        // but never the namespace.
47        "composer" | "hex" | "github" | "gitlab" | "bitbucket" => (
48            parsed.namespace().map(str::to_ascii_lowercase),
49            parsed.name().to_ascii_lowercase(),
50        ),
51
52        // golang's spec is self-contradictory and acknowledged-broken; the
53        // decided direction (purl-spec#308) is to lowercase only the host
54        // segment and preserve path-part case (e.g. keep `github.com/Azure/…`).
55        // The crate force-lowercases the whole golang namespace at parse time,
56        // so `parsed` has already lost the case — edit the raw string instead.
57        "golang" => return lowercase_first_path_segment(purl, "pkg:golang/"),
58
59        _ => return purl.to_string(),
60    };
61
62    rebuild(purl, &parsed, new_namespace, new_name)
63}
64
65/// Re-emit `parsed` with a replaced namespace/name, preserving the rest.
66///
67/// Falls back to the original string if the rebuilt PURL cannot be constructed.
68fn rebuild(
69    original: &str,
70    parsed: &PackageUrl<'_>,
71    namespace: Option<String>,
72    name: String,
73) -> String {
74    let Ok(mut rebuilt) = PackageUrl::new(parsed.ty().to_string(), name) else {
75        return original.to_string();
76    };
77
78    if let Some(namespace) = namespace.filter(|value| !value.is_empty())
79        && rebuilt.with_namespace(namespace).is_err()
80    {
81        return original.to_string();
82    }
83
84    if let Some(version) = parsed.version()
85        && rebuilt.with_version(version.to_string()).is_err()
86    {
87        return original.to_string();
88    }
89
90    for (key, value) in parsed.qualifiers() {
91        if rebuilt
92            .add_qualifier(key.to_string(), value.to_string())
93            .is_err()
94        {
95            return original.to_string();
96        }
97    }
98
99    if let Some(subpath) = parsed.subpath()
100        && rebuilt.with_subpath(subpath.to_string()).is_err()
101    {
102        return original.to_string();
103    }
104
105    rebuilt.to_string()
106}
107
108/// Apply the PEP 503 normalized distribution name rule: lowercase, then collapse
109/// every run of `-`, `_`, or `.` into a single `-`.
110fn normalize_pypi_name(name: &str) -> String {
111    let lower = name.to_ascii_lowercase();
112    let mut normalized = String::with_capacity(lower.len());
113    let mut last_was_separator = false;
114
115    for ch in lower.chars() {
116        if matches!(ch, '-' | '_' | '.') {
117            if !last_was_separator {
118                normalized.push('-');
119                last_was_separator = true;
120            }
121        } else {
122            normalized.push(ch);
123            last_was_separator = false;
124        }
125    }
126
127    normalized
128}
129
130/// Lowercase the first path segment that follows `prefix` in a PURL string,
131/// stopping at the next path separator or component delimiter (`/ @ ? #`).
132///
133/// Used for golang's host-only lowercasing: it edits the raw string so the
134/// remaining path parts, version, qualifiers, and subpath survive untouched.
135/// Returns the input unchanged if it does not start with `prefix`.
136fn lowercase_first_path_segment(purl: &str, prefix: &str) -> String {
137    let Some(rest) = purl.strip_prefix(prefix) else {
138        return purl.to_string();
139    };
140    let end = rest.find(['/', '@', '?', '#']).unwrap_or(rest.len());
141    format!(
142        "{prefix}{}{}",
143        rest[..end].to_ascii_lowercase(),
144        &rest[end..]
145    )
146}
147
148/// The qualifier a UID's instance marker normally uses.
149const UID_MARKER: &str = "uuid";
150
151/// The marker used when the PURL already carries a `uuid` qualifier of its own.
152///
153/// Julia's registry identity is exactly that — the spec requires it, and a Julia
154/// name alone is ambiguous without it. Appending a second `uuid` produced a PURL
155/// with a duplicate qualifier key, which a parser resolves last-wins: the
156/// package's own identity was silently discarded, and re-emitting dropped it
157/// from the string entirely.
158const UID_MARKER_ALT: &str = "uid";
159
160/// Add the qualifier that turns a PURL into a UID, keeping it a qualifier and
161/// leaving the PURL's own qualifiers intact.
162///
163/// A PURL orders its parts `…?qualifiers#subpath`, so appending to the end of the
164/// string lands the marker *inside the subpath* whenever one is present: a
165/// cocoapods subspec UID came out as `pkg:cocoapods/SwiftFormat@0.44.17#CLI?uuid=…`,
166/// where `?uuid=…` is no longer a qualifier at all. Insert ahead of the subpath
167/// instead, and use `&` only when the PURL already carries a qualifier.
168///
169/// Non-PURL bases (the `generated-package:` fallback identity) carry neither
170/// qualifiers nor a subpath, so they simply take the `?uuid=` form.
171pub(crate) fn append_uuid_qualifier(base: &str, uuid: &str) -> String {
172    let (head, subpath) = split_subpath(base);
173    let separator = if head.contains('?') { '&' } else { '?' };
174    let marker = if has_qualifier(head, UID_MARKER) {
175        UID_MARKER_ALT
176    } else {
177        UID_MARKER
178    };
179
180    match subpath {
181        Some(subpath) => format!("{head}{separator}{marker}={uuid}#{subpath}"),
182        None => format!("{head}{separator}{marker}={uuid}"),
183    }
184}
185
186/// The UID with its instance marker removed, restoring the underlying PURL.
187///
188/// Borrows whenever the UID has no subpath, which is the common case; only a
189/// subpath-carrying UID needs the two remaining parts rejoined.
190pub(crate) fn strip_uuid_qualifier(uid: &str) -> Cow<'_, str> {
191    let (head, subpath) = split_subpath(uid);
192    let Some((prefix, _)) = split_uid_marker(head) else {
193        return Cow::Borrowed(uid);
194    };
195
196    match subpath {
197        Some(subpath) => Cow::Owned(format!("{prefix}#{subpath}")),
198        None => Cow::Borrowed(prefix),
199    }
200}
201
202/// The value of a UID's instance marker, or `None` when it carries none.
203pub(crate) fn uuid_qualifier_value(uid: &str) -> Option<&str> {
204    let (head, _) = split_subpath(uid);
205    split_uid_marker(head).map(|(_, uuid)| uuid)
206}
207
208/// Whether `head` already carries `key` as a qualifier.
209///
210/// Anchored on the qualifier separator so `uid` does not match inside `uuid`.
211fn has_qualifier(head: &str, key: &str) -> bool {
212    head.contains(&format!("?{key}=")) || head.contains(&format!("&{key}="))
213}
214
215/// Splits a UID's qualifier section into the PURL before its instance marker and
216/// the marker's value.
217///
218/// Prefers the alternate marker, which is only ever emitted when the PURL has a
219/// `uuid` of its own. Otherwise takes the *last* `uuid`, so a UID produced before
220/// the alternate marker existed still resolves to the appended one rather than to
221/// the package's registry identity.
222fn split_uid_marker(head: &str) -> Option<(&str, &str)> {
223    for marker in [UID_MARKER_ALT, UID_MARKER] {
224        let separator_index = [format!("?{marker}="), format!("&{marker}=")]
225            .iter()
226            .filter_map(|pattern| head.rfind(pattern.as_str()))
227            .max();
228
229        if let Some(index) = separator_index {
230            let value_start = index + marker.len() + 2;
231            let value = &head[value_start..];
232            let value = value.split_once('&').map_or(value, |(value, _)| value);
233            return Some((&head[..index], value));
234        }
235    }
236    None
237}
238
239fn split_subpath(purl: &str) -> (&str, Option<&str>) {
240    match purl.split_once('#') {
241        Some((head, subpath)) => (head, Some(subpath)),
242        None => (purl, None),
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn uuid_qualifier_stays_a_qualifier_when_the_purl_has_a_subpath() {
252        let uid = append_uuid_qualifier("pkg:cocoapods/SwiftFormat@0.44.17#CLI", "abc");
253        assert_eq!(uid, "pkg:cocoapods/SwiftFormat@0.44.17?uuid=abc#CLI");
254        assert_eq!(
255            strip_uuid_qualifier(&uid),
256            "pkg:cocoapods/SwiftFormat@0.44.17#CLI"
257        );
258
259        // The parsed UID must still expose the original subpath rather than one
260        // with the uuid swallowed into it.
261        let parsed = PackageUrl::from_str(&uid).expect("uid should parse as a purl");
262        assert_eq!(parsed.subpath(), Some("CLI"));
263        assert_eq!(
264            parsed.qualifiers().get("uuid").map(Cow::as_ref),
265            Some("abc")
266        );
267    }
268
269    #[test]
270    fn uid_marker_does_not_collide_with_a_purls_own_uuid_qualifier() {
271        // julia's registry identity is a `uuid` qualifier and the spec requires
272        // it. A second one made the two indistinguishable: a parser resolves
273        // duplicate keys last-wins, so the package's own identity was discarded
274        // and re-emitting dropped it from the string.
275        let base = "pkg:julia/HTTP@1.0.0?uuid=cd3eb016-35fb-5094-929b-558a96fad6f3";
276        let uid = append_uuid_qualifier(base, "98920f38-6039-4eaf-925e-f1216f083eba");
277        assert_eq!(
278            uid,
279            "pkg:julia/HTTP@1.0.0?uuid=cd3eb016-35fb-5094-929b-558a96fad6f3&uid=98920f38-6039-4eaf-925e-f1216f083eba"
280        );
281
282        // The registry identity survives a parse, and the marker is separate.
283        let parsed = PackageUrl::from_str(&uid).expect("uid should parse");
284        assert_eq!(
285            parsed.qualifiers().get("uuid").map(Cow::as_ref),
286            Some("cd3eb016-35fb-5094-929b-558a96fad6f3")
287        );
288        assert_eq!(
289            parsed.qualifiers().get("uid").map(Cow::as_ref),
290            Some("98920f38-6039-4eaf-925e-f1216f083eba")
291        );
292
293        // And the PURL is recoverable, so two julia packages sharing a name and
294        // version no longer collapse to the same key.
295        assert_eq!(strip_uuid_qualifier(&uid), base);
296        assert_eq!(
297            uuid_qualifier_value(&uid),
298            Some("98920f38-6039-4eaf-925e-f1216f083eba")
299        );
300    }
301
302    #[test]
303    fn a_uid_written_before_the_alternate_marker_resolves_to_the_appended_one() {
304        // Reading back output produced when both markers were spelled `uuid`:
305        // the appended one is the last, so the package's own identity is what
306        // survives stripping.
307        let legacy = "pkg:julia/HTTP@1.0.0?uuid=cd3eb016-35fb-5094-929b-558a96fad6f3&uuid=98920f38-6039-4eaf-925e-f1216f083eba";
308        assert_eq!(
309            strip_uuid_qualifier(legacy),
310            "pkg:julia/HTTP@1.0.0?uuid=cd3eb016-35fb-5094-929b-558a96fad6f3"
311        );
312        assert_eq!(
313            uuid_qualifier_value(legacy),
314            Some("98920f38-6039-4eaf-925e-f1216f083eba")
315        );
316    }
317
318    #[test]
319    fn uuid_qualifier_joins_existing_qualifiers_with_an_ampersand() {
320        let uid = append_uuid_qualifier("pkg:generic/x?arch=amd64", "abc");
321        assert_eq!(uid, "pkg:generic/x?arch=amd64&uuid=abc");
322        assert_eq!(strip_uuid_qualifier(&uid), "pkg:generic/x?arch=amd64");
323    }
324
325    #[test]
326    fn uuid_qualifier_round_trips_plain_purls_and_opaque_bases() {
327        for base in [
328            "pkg:pypi/requests@2.0",
329            "pkg:npm/%40scope/name@1.0.0",
330            "generated-package:cargo/unknown@unknown",
331        ] {
332            let uid = append_uuid_qualifier(base, "abc");
333            assert_eq!(uid, format!("{base}?uuid=abc"));
334            assert_eq!(strip_uuid_qualifier(&uid), base);
335        }
336    }
337
338    #[test]
339    fn strip_uuid_qualifier_leaves_a_uid_without_one_untouched() {
340        assert_eq!(
341            strip_uuid_qualifier("pkg:pypi/requests@2.0"),
342            "pkg:pypi/requests@2.0"
343        );
344        assert_eq!(strip_uuid_qualifier(""), "");
345    }
346
347    /// Spec-rule matrix: representative PURL per type asserted against the
348    /// canonical form. Guards every parser against drift.
349    #[test]
350    fn normalize_purl_matrix() {
351        let cases = [
352            // pypi: full PEP 503 (lowercase + collapse `-_.` runs).
353            (
354                "pkg:pypi/typing_extensions@4.0.0",
355                "pkg:pypi/typing-extensions@4.0.0",
356            ),
357            ("pkg:pypi/Django@4.2", "pkg:pypi/django@4.2"),
358            ("pkg:pypi/zope.interface@5.0", "pkg:pypi/zope-interface@5.0"),
359            ("pkg:pypi/foo__bar@1.0", "pkg:pypi/foo-bar@1.0"),
360            // composer: lowercase vendor namespace + name.
361            (
362                "pkg:composer/Monolog/Monolog@2.0",
363                "pkg:composer/monolog/monolog@2.0",
364            ),
365            // hex: lowercase namespace + name.
366            ("pkg:hex/Phoenix@1.7.0", "pkg:hex/phoenix@1.7.0"),
367            // github / gitlab / bitbucket: lowercase namespace + name.
368            (
369                "pkg:github/Package-Url/purl-Spec@1.0",
370                "pkg:github/package-url/purl-spec@1.0",
371            ),
372            ("pkg:gitlab/FooBar/Baz@2.0", "pkg:gitlab/foobar/baz@2.0"),
373            (
374                "pkg:bitbucket/Birkenfeld/Pygments@2.0",
375                "pkg:bitbucket/birkenfeld/pygments@2.0",
376            ),
377            // golang: lowercase host only, preserve path-part case.
378            (
379                "pkg:golang/github.com/Azure/azure-sdk-for-go@1.0",
380                "pkg:golang/github.com/Azure/azure-sdk-for-go@1.0",
381            ),
382            (
383                "pkg:golang/GitHub.com/Azure/azure-sdk-for-go@1.0",
384                "pkg:golang/github.com/Azure/azure-sdk-for-go@1.0",
385            ),
386        ];
387
388        for (input, expected) in cases {
389            assert_eq!(normalize_purl(input), expected, "input: {input}");
390        }
391    }
392
393    /// Case-sensitive and custom types must be returned byte-for-byte unchanged.
394    #[test]
395    fn normalize_purl_preserves_case_sensitive_types() {
396        let untouched = [
397            // npm grandfathers mixed-case names.
398            "pkg:npm/%40angular/Core@13.0.0",
399            "pkg:maven/com.Example/MyLib@1.0",
400            "pkg:cargo/Serde@1.0",
401            "pkg:gem/RSpec@3.0",
402            // Unknown / custom type with no registered spec.
403            "pkg:bower/SomeLib@1.0",
404        ];
405
406        for purl in untouched {
407            assert_eq!(normalize_purl(purl), purl, "input: {purl}");
408        }
409    }
410
411    #[test]
412    fn normalize_purl_is_idempotent() {
413        let inputs = [
414            "pkg:pypi/typing_extensions@4.0.0",
415            "pkg:composer/Monolog/Monolog@2.0",
416            "pkg:golang/GitHub.com/Azure/azure-sdk-for-go@1.0",
417            "pkg:github/Foo/Bar",
418        ];
419
420        for input in inputs {
421            let once = normalize_purl(input);
422            let twice = normalize_purl(&once);
423            assert_eq!(once, twice, "not idempotent for {input}");
424        }
425    }
426
427    #[test]
428    fn normalize_purl_preserves_qualifiers_and_subpath() {
429        // pypi name changes, but qualifiers and subpath survive the round-trip.
430        assert_eq!(
431            normalize_purl("pkg:pypi/typing_extensions@4.0?arch=any#sub/path"),
432            "pkg:pypi/typing-extensions@4.0?arch=any#sub/path",
433        );
434    }
435
436    #[test]
437    fn normalize_purl_returns_unparsable_input_unchanged() {
438        assert_eq!(normalize_purl("not-a-purl"), "not-a-purl");
439        assert_eq!(normalize_purl(""), "");
440    }
441
442    #[test]
443    fn normalize_purl_handles_pypi_without_version() {
444        assert_eq!(
445            normalize_purl("pkg:pypi/typing_extensions"),
446            "pkg:pypi/typing-extensions",
447        );
448    }
449
450    /// A golang PURL with no namespace (single-segment module path): the whole
451    /// `rest` slice is lowercased, which is the correct host-only rule when the
452    /// module name itself is the host (e.g. the standard library placeholder).
453    #[test]
454    fn normalize_purl_golang_no_namespace() {
455        assert_eq!(
456            normalize_purl("pkg:golang/Std@go1.21"),
457            "pkg:golang/std@go1.21",
458        );
459        // Already lowercase — unchanged.
460        assert_eq!(
461            normalize_purl("pkg:golang/std@go1.21"),
462            "pkg:golang/std@go1.21",
463        );
464    }
465
466    /// Qualifiers and subpath on a golang PURL must survive the raw-string edit.
467    #[test]
468    fn normalize_purl_golang_preserves_qualifiers_and_subpath() {
469        assert_eq!(
470            normalize_purl(
471                "pkg:golang/GITHUB.COM/Azure/pkg@1.0?vcs_url=https://github.com/Azure/pkg#sub/path"
472            ),
473            "pkg:golang/github.com/Azure/pkg@1.0?vcs_url=https://github.com/Azure/pkg#sub/path",
474        );
475    }
476
477    /// A golang PURL whose type prefix is not all-lowercase is returned unchanged
478    /// because `lowercase_first_path_segment` relies on the crate serializer
479    /// always emitting a lowercase type; all PURL-generating paths in this code
480    /// base go through that serializer so this edge is never triggered in practice.
481    #[test]
482    fn normalize_purl_golang_mixed_case_type_unchanged() {
483        // The crate's serializer always lowercases the type, so "pkg:Golang/"
484        // never appears in practice. The function documents this no-op contract.
485        assert_eq!(
486            normalize_purl("pkg:Golang/GITHUB.COM/Azure/pkg@1.0"),
487            "pkg:Golang/GITHUB.COM/Azure/pkg@1.0",
488        );
489    }
490}