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
148pub(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
169pub(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
188pub(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 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 #[test]
258 fn normalize_purl_matrix() {
259 let cases = [
260 (
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 (
270 "pkg:composer/Monolog/Monolog@2.0",
271 "pkg:composer/monolog/monolog@2.0",
272 ),
273 ("pkg:hex/Phoenix@1.7.0", "pkg:hex/phoenix@1.7.0"),
275 (
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 (
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 #[test]
303 fn normalize_purl_preserves_case_sensitive_types() {
304 let untouched = [
305 "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 "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 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 #[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 assert_eq!(
369 normalize_purl("pkg:golang/std@go1.21"),
370 "pkg:golang/std@go1.21",
371 );
372 }
373
374 #[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 #[test]
390 fn normalize_purl_golang_mixed_case_type_unchanged() {
391 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}