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}