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/// Add the `uuid` qualifier that turns a PURL into a UID, keeping it a
149/// qualifier.
150///
151/// A PURL orders its parts `…?qualifiers#subpath`, so appending to the end of the
152/// string lands the uuid *inside the subpath* whenever one is present: a
153/// cocoapods subspec UID came out as `pkg:cocoapods/SwiftFormat@0.44.17#CLI?uuid=…`,
154/// where `?uuid=…` is no longer a qualifier and re-parsing yields the subpath
155/// `CLI?uuid=…`. Insert ahead of the subpath instead, and use `&` only when the
156/// PURL already carries a qualifier.
157///
158/// Non-PURL bases (the `generated-package:` fallback identity) carry neither
159/// qualifiers nor a subpath, so they simply take the `?uuid=` form.
160pub(crate) fn append_uuid_qualifier(base: &str, uuid: &str) -> String {
161    let (head, subpath) = split_subpath(base);
162    let separator = if head.contains('?') { '&' } else { '?' };
163    match subpath {
164        Some(subpath) => format!("{head}{separator}uuid={uuid}#{subpath}"),
165        None => format!("{head}{separator}uuid={uuid}"),
166    }
167}
168
169/// The UID with its `uuid` qualifier removed, restoring the underlying PURL.
170///
171/// Borrows whenever the UID has no subpath, which is the common case; only a
172/// subpath-carrying UID needs the two remaining parts rejoined.
173pub(crate) fn strip_uuid_qualifier(uid: &str) -> Cow<'_, str> {
174    let (head, subpath) = split_subpath(uid);
175    let Some((prefix, _)) = head
176        .split_once("?uuid=")
177        .or_else(|| head.split_once("&uuid="))
178    else {
179        return Cow::Borrowed(uid);
180    };
181
182    match subpath {
183        Some(subpath) => Cow::Owned(format!("{prefix}#{subpath}")),
184        None => Cow::Borrowed(prefix),
185    }
186}
187
188/// The value of a UID's `uuid` qualifier, or `None` when it carries none.
189pub(crate) fn uuid_qualifier_value(uid: &str) -> Option<&str> {
190    let (head, _) = split_subpath(uid);
191    head.split_once("?uuid=")
192        .or_else(|| head.split_once("&uuid="))
193        .map(|(_, uuid)| uuid)
194}
195
196fn split_subpath(purl: &str) -> (&str, Option<&str>) {
197    match purl.split_once('#') {
198        Some((head, subpath)) => (head, Some(subpath)),
199        None => (purl, None),
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn uuid_qualifier_stays_a_qualifier_when_the_purl_has_a_subpath() {
209        let uid = append_uuid_qualifier("pkg:cocoapods/SwiftFormat@0.44.17#CLI", "abc");
210        assert_eq!(uid, "pkg:cocoapods/SwiftFormat@0.44.17?uuid=abc#CLI");
211        assert_eq!(
212            strip_uuid_qualifier(&uid),
213            "pkg:cocoapods/SwiftFormat@0.44.17#CLI"
214        );
215
216        // The parsed UID must still expose the original subpath rather than one
217        // with the uuid swallowed into it.
218        let parsed = PackageUrl::from_str(&uid).expect("uid should parse as a purl");
219        assert_eq!(parsed.subpath(), Some("CLI"));
220        assert_eq!(
221            parsed.qualifiers().get("uuid").map(Cow::as_ref),
222            Some("abc")
223        );
224    }
225
226    #[test]
227    fn uuid_qualifier_joins_existing_qualifiers_with_an_ampersand() {
228        let uid = append_uuid_qualifier("pkg:generic/x?arch=amd64", "abc");
229        assert_eq!(uid, "pkg:generic/x?arch=amd64&uuid=abc");
230        assert_eq!(strip_uuid_qualifier(&uid), "pkg:generic/x?arch=amd64");
231    }
232
233    #[test]
234    fn uuid_qualifier_round_trips_plain_purls_and_opaque_bases() {
235        for base in [
236            "pkg:pypi/requests@2.0",
237            "pkg:npm/%40scope/name@1.0.0",
238            "generated-package:cargo/unknown@unknown",
239        ] {
240            let uid = append_uuid_qualifier(base, "abc");
241            assert_eq!(uid, format!("{base}?uuid=abc"));
242            assert_eq!(strip_uuid_qualifier(&uid), base);
243        }
244    }
245
246    #[test]
247    fn strip_uuid_qualifier_leaves_a_uid_without_one_untouched() {
248        assert_eq!(
249            strip_uuid_qualifier("pkg:pypi/requests@2.0"),
250            "pkg:pypi/requests@2.0"
251        );
252        assert_eq!(strip_uuid_qualifier(""), "");
253    }
254
255    /// Spec-rule matrix: representative PURL per type asserted against the
256    /// canonical form. Guards every parser against drift.
257    #[test]
258    fn normalize_purl_matrix() {
259        let cases = [
260            // pypi: full PEP 503 (lowercase + collapse `-_.` runs).
261            (
262                "pkg:pypi/typing_extensions@4.0.0",
263                "pkg:pypi/typing-extensions@4.0.0",
264            ),
265            ("pkg:pypi/Django@4.2", "pkg:pypi/django@4.2"),
266            ("pkg:pypi/zope.interface@5.0", "pkg:pypi/zope-interface@5.0"),
267            ("pkg:pypi/foo__bar@1.0", "pkg:pypi/foo-bar@1.0"),
268            // composer: lowercase vendor namespace + name.
269            (
270                "pkg:composer/Monolog/Monolog@2.0",
271                "pkg:composer/monolog/monolog@2.0",
272            ),
273            // hex: lowercase namespace + name.
274            ("pkg:hex/Phoenix@1.7.0", "pkg:hex/phoenix@1.7.0"),
275            // github / gitlab / bitbucket: lowercase namespace + name.
276            (
277                "pkg:github/Package-Url/purl-Spec@1.0",
278                "pkg:github/package-url/purl-spec@1.0",
279            ),
280            ("pkg:gitlab/FooBar/Baz@2.0", "pkg:gitlab/foobar/baz@2.0"),
281            (
282                "pkg:bitbucket/Birkenfeld/Pygments@2.0",
283                "pkg:bitbucket/birkenfeld/pygments@2.0",
284            ),
285            // golang: lowercase host only, preserve path-part case.
286            (
287                "pkg:golang/github.com/Azure/azure-sdk-for-go@1.0",
288                "pkg:golang/github.com/Azure/azure-sdk-for-go@1.0",
289            ),
290            (
291                "pkg:golang/GitHub.com/Azure/azure-sdk-for-go@1.0",
292                "pkg:golang/github.com/Azure/azure-sdk-for-go@1.0",
293            ),
294        ];
295
296        for (input, expected) in cases {
297            assert_eq!(normalize_purl(input), expected, "input: {input}");
298        }
299    }
300
301    /// Case-sensitive and custom types must be returned byte-for-byte unchanged.
302    #[test]
303    fn normalize_purl_preserves_case_sensitive_types() {
304        let untouched = [
305            // npm grandfathers mixed-case names.
306            "pkg:npm/%40angular/Core@13.0.0",
307            "pkg:maven/com.Example/MyLib@1.0",
308            "pkg:cargo/Serde@1.0",
309            "pkg:gem/RSpec@3.0",
310            // Unknown / custom type with no registered spec.
311            "pkg:bower/SomeLib@1.0",
312        ];
313
314        for purl in untouched {
315            assert_eq!(normalize_purl(purl), purl, "input: {purl}");
316        }
317    }
318
319    #[test]
320    fn normalize_purl_is_idempotent() {
321        let inputs = [
322            "pkg:pypi/typing_extensions@4.0.0",
323            "pkg:composer/Monolog/Monolog@2.0",
324            "pkg:golang/GitHub.com/Azure/azure-sdk-for-go@1.0",
325            "pkg:github/Foo/Bar",
326        ];
327
328        for input in inputs {
329            let once = normalize_purl(input);
330            let twice = normalize_purl(&once);
331            assert_eq!(once, twice, "not idempotent for {input}");
332        }
333    }
334
335    #[test]
336    fn normalize_purl_preserves_qualifiers_and_subpath() {
337        // pypi name changes, but qualifiers and subpath survive the round-trip.
338        assert_eq!(
339            normalize_purl("pkg:pypi/typing_extensions@4.0?arch=any#sub/path"),
340            "pkg:pypi/typing-extensions@4.0?arch=any#sub/path",
341        );
342    }
343
344    #[test]
345    fn normalize_purl_returns_unparsable_input_unchanged() {
346        assert_eq!(normalize_purl("not-a-purl"), "not-a-purl");
347        assert_eq!(normalize_purl(""), "");
348    }
349
350    #[test]
351    fn normalize_purl_handles_pypi_without_version() {
352        assert_eq!(
353            normalize_purl("pkg:pypi/typing_extensions"),
354            "pkg:pypi/typing-extensions",
355        );
356    }
357
358    /// A golang PURL with no namespace (single-segment module path): the whole
359    /// `rest` slice is lowercased, which is the correct host-only rule when the
360    /// module name itself is the host (e.g. the standard library placeholder).
361    #[test]
362    fn normalize_purl_golang_no_namespace() {
363        assert_eq!(
364            normalize_purl("pkg:golang/Std@go1.21"),
365            "pkg:golang/std@go1.21",
366        );
367        // Already lowercase — unchanged.
368        assert_eq!(
369            normalize_purl("pkg:golang/std@go1.21"),
370            "pkg:golang/std@go1.21",
371        );
372    }
373
374    /// Qualifiers and subpath on a golang PURL must survive the raw-string edit.
375    #[test]
376    fn normalize_purl_golang_preserves_qualifiers_and_subpath() {
377        assert_eq!(
378            normalize_purl(
379                "pkg:golang/GITHUB.COM/Azure/pkg@1.0?vcs_url=https://github.com/Azure/pkg#sub/path"
380            ),
381            "pkg:golang/github.com/Azure/pkg@1.0?vcs_url=https://github.com/Azure/pkg#sub/path",
382        );
383    }
384
385    /// A golang PURL whose type prefix is not all-lowercase is returned unchanged
386    /// because `lowercase_first_path_segment` relies on the crate serializer
387    /// always emitting a lowercase type; all PURL-generating paths in this code
388    /// base go through that serializer so this edge is never triggered in practice.
389    #[test]
390    fn normalize_purl_golang_mixed_case_type_unchanged() {
391        // The crate's serializer always lowercases the type, so "pkg:Golang/"
392        // never appears in practice. The function documents this no-op contract.
393        assert_eq!(
394            normalize_purl("pkg:Golang/GITHUB.COM/Azure/pkg@1.0"),
395            "pkg:Golang/GITHUB.COM/Azure/pkg@1.0",
396        );
397    }
398}