1use std::borrow::Cow;
26use std::str::FromStr;
27
28use packageurl::PackageUrl;
29
30pub 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 "pypi" => (None, normalize_pypi_name(parsed.name())),
44
45 "composer" | "hex" | "github" | "gitlab" | "bitbucket" => (
48 parsed.namespace().map(str::to_ascii_lowercase),
49 parsed.name().to_ascii_lowercase(),
50 ),
51
52 "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
65fn 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
108fn 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
130fn 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
148const UID_MARKER: &str = "uuid";
150
151const UID_MARKER_ALT: &str = "uid";
159
160pub(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
186pub(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
202pub(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
208fn has_qualifier(head: &str, key: &str) -> bool {
212 head.contains(&format!("?{key}=")) || head.contains(&format!("&{key}="))
213}
214
215fn 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 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 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 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 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 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 #[test]
350 fn normalize_purl_matrix() {
351 let cases = [
352 (
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 (
362 "pkg:composer/Monolog/Monolog@2.0",
363 "pkg:composer/monolog/monolog@2.0",
364 ),
365 ("pkg:hex/Phoenix@1.7.0", "pkg:hex/phoenix@1.7.0"),
367 (
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 (
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 #[test]
395 fn normalize_purl_preserves_case_sensitive_types() {
396 let untouched = [
397 "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 "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 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 #[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 assert_eq!(
461 normalize_purl("pkg:golang/std@go1.21"),
462 "pkg:golang/std@go1.21",
463 );
464 }
465
466 #[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 #[test]
482 fn normalize_purl_golang_mixed_case_type_unchanged() {
483 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}