1pub fn strip_purl_qualifiers(purl: &str) -> &str {
5 match purl.find('?') {
6 Some(idx) => &purl[..idx],
7 None => purl,
8 }
9}
10
11pub fn parse_pypi_purl(purl: &str) -> Option<(&str, &str)> {
15 let base = strip_purl_qualifiers(purl);
16 let rest = base.strip_prefix("pkg:pypi/")?;
17 let at_idx = rest.rfind('@')?;
18 let name = &rest[..at_idx];
19 let version = &rest[at_idx + 1..];
20 if name.is_empty() || version.is_empty() {
21 return None;
22 }
23 Some((name, version))
24}
25
26pub fn parse_gem_purl(purl: &str) -> Option<(&str, &str)> {
30 let base = strip_purl_qualifiers(purl);
31 let rest = base.strip_prefix("pkg:gem/")?;
32 let at_idx = rest.rfind('@')?;
33 let name = &rest[..at_idx];
34 let version = &rest[at_idx + 1..];
35 if name.is_empty() || version.is_empty() {
36 return None;
37 }
38 Some((name, version))
39}
40
41pub fn build_gem_purl(name: &str, version: &str) -> String {
43 format!("pkg:gem/{name}@{version}")
44}
45
46#[cfg(feature = "maven")]
50pub fn parse_maven_purl(purl: &str) -> Option<(&str, &str, &str)> {
51 let base = strip_purl_qualifiers(purl);
52 let rest = base.strip_prefix("pkg:maven/")?;
53 let at_idx = rest.rfind('@')?;
54 let name_part = &rest[..at_idx];
55 let version = &rest[at_idx + 1..];
56
57 if name_part.is_empty() || version.is_empty() {
58 return None;
59 }
60
61 let slash_idx = name_part.find('/')?;
63 let group_id = &name_part[..slash_idx];
64 let artifact_id = &name_part[slash_idx + 1..];
65
66 if group_id.is_empty() || artifact_id.is_empty() {
67 return None;
68 }
69
70 Some((group_id, artifact_id, version))
71}
72
73#[cfg(feature = "maven")]
75pub fn build_maven_purl(group_id: &str, artifact_id: &str, version: &str) -> String {
76 format!("pkg:maven/{group_id}/{artifact_id}@{version}")
77}
78
79#[cfg(feature = "golang")]
83pub fn parse_golang_purl(purl: &str) -> Option<(&str, &str)> {
84 let base = strip_purl_qualifiers(purl);
85 let rest = base.strip_prefix("pkg:golang/")?;
86 let at_idx = rest.rfind('@')?;
87 let module_path = &rest[..at_idx];
88 let version = &rest[at_idx + 1..];
89 if module_path.is_empty() || version.is_empty() {
90 return None;
91 }
92 Some((module_path, version))
93}
94
95#[cfg(feature = "golang")]
97pub fn build_golang_purl(module_path: &str, version: &str) -> String {
98 format!("pkg:golang/{module_path}@{version}")
99}
100
101#[cfg(feature = "composer")]
106pub fn parse_composer_purl(purl: &str) -> Option<((&str, &str), &str)> {
107 let base = strip_purl_qualifiers(purl);
108 let rest = base.strip_prefix("pkg:composer/")?;
109 let at_idx = rest.rfind('@')?;
110 let name_part = &rest[..at_idx];
111 let version = &rest[at_idx + 1..];
112
113 if name_part.is_empty() || version.is_empty() {
114 return None;
115 }
116
117 let slash_idx = name_part.find('/')?;
119 let namespace = &name_part[..slash_idx];
120 let name = &name_part[slash_idx + 1..];
121
122 if namespace.is_empty() || name.is_empty() {
123 return None;
124 }
125
126 Some(((namespace, name), version))
127}
128
129#[cfg(feature = "composer")]
131pub fn build_composer_purl(namespace: &str, name: &str, version: &str) -> String {
132 format!("pkg:composer/{namespace}/{name}@{version}")
133}
134
135#[cfg(feature = "deno")]
148pub fn parse_jsr_purl(purl: &str) -> Option<((&str, &str), &str)> {
149 let base = strip_purl_qualifiers(purl);
150 let rest = base.strip_prefix("pkg:jsr/")?;
151 let at_idx = rest.rfind('@')?;
152 let name_part = &rest[..at_idx];
153 let version = &rest[at_idx + 1..];
154
155 if name_part.is_empty() || version.is_empty() {
156 return None;
157 }
158
159 let slash_idx = name_part.find('/')?;
160 let scope = &name_part[..slash_idx];
161 let name = &name_part[slash_idx + 1..];
162
163 if name.is_empty() || !scope.starts_with('@') || scope.len() < 2 {
166 return None;
167 }
168
169 Some(((scope, name), version))
170}
171
172#[cfg(feature = "deno")]
174pub fn build_jsr_purl(scope: &str, name: &str, version: &str) -> String {
175 format!("pkg:jsr/{scope}/{name}@{version}")
176}
177
178#[cfg(feature = "nuget")]
182pub fn parse_nuget_purl(purl: &str) -> Option<(&str, &str)> {
183 let base = strip_purl_qualifiers(purl);
184 let rest = base.strip_prefix("pkg:nuget/")?;
185 let at_idx = rest.rfind('@')?;
186 let name = &rest[..at_idx];
187 let version = &rest[at_idx + 1..];
188 if name.is_empty() || version.is_empty() {
189 return None;
190 }
191 Some((name, version))
192}
193
194#[cfg(feature = "nuget")]
196pub fn build_nuget_purl(name: &str, version: &str) -> String {
197 format!("pkg:nuget/{name}@{version}")
198}
199
200#[cfg(feature = "cargo")]
204pub fn parse_cargo_purl(purl: &str) -> Option<(&str, &str)> {
205 let base = strip_purl_qualifiers(purl);
206 let rest = base.strip_prefix("pkg:cargo/")?;
207 let at_idx = rest.rfind('@')?;
208 let name = &rest[..at_idx];
209 let version = &rest[at_idx + 1..];
210 if name.is_empty() || version.is_empty() {
211 return None;
212 }
213 Some((name, version))
214}
215
216#[cfg(feature = "cargo")]
218pub fn build_cargo_purl(name: &str, version: &str) -> String {
219 format!("pkg:cargo/{name}@{version}")
220}
221
222pub fn is_purl(s: &str) -> bool {
224 s.starts_with("pkg:")
225}
226
227pub fn purl_matches_identifier(manifest_key: &str, identifier: &str) -> bool {
242 if identifier.contains('?') {
243 manifest_key == identifier
244 } else {
245 strip_purl_qualifiers(manifest_key) == identifier
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 #[test]
254 fn test_strip_qualifiers() {
255 assert_eq!(
256 strip_purl_qualifiers("pkg:pypi/requests@2.28.0?artifact_id=abc"),
257 "pkg:pypi/requests@2.28.0"
258 );
259 assert_eq!(
260 strip_purl_qualifiers("pkg:npm/lodash@4.17.21"),
261 "pkg:npm/lodash@4.17.21"
262 );
263 }
264
265 #[test]
266 fn test_parse_pypi_purl() {
267 assert_eq!(
268 parse_pypi_purl("pkg:pypi/requests@2.28.0"),
269 Some(("requests", "2.28.0"))
270 );
271 assert_eq!(
272 parse_pypi_purl("pkg:pypi/requests@2.28.0?artifact_id=abc"),
273 Some(("requests", "2.28.0"))
274 );
275 assert_eq!(parse_pypi_purl("pkg:npm/lodash@4.17.21"), None);
276 assert_eq!(parse_pypi_purl("pkg:pypi/@2.28.0"), None);
277 assert_eq!(parse_pypi_purl("pkg:pypi/requests@"), None);
278 }
279
280 #[test]
281 fn test_purl_matches_identifier() {
282 assert!(purl_matches_identifier(
284 "pkg:pypi/requests@2.28.0?artifact_id=abc",
285 "pkg:pypi/requests@2.28.0"
286 ));
287 assert!(purl_matches_identifier(
288 "pkg:pypi/requests@2.28.0",
289 "pkg:pypi/requests@2.28.0"
290 ));
291 assert!(!purl_matches_identifier(
293 "pkg:pypi/requests@2.29.0?artifact_id=abc",
294 "pkg:pypi/requests@2.28.0"
295 ));
296 assert!(purl_matches_identifier(
298 "pkg:pypi/requests@2.28.0?artifact_id=abc",
299 "pkg:pypi/requests@2.28.0?artifact_id=abc"
300 ));
301 assert!(!purl_matches_identifier(
302 "pkg:pypi/requests@2.28.0?artifact_id=xyz",
303 "pkg:pypi/requests@2.28.0?artifact_id=abc"
304 ));
305 assert!(!purl_matches_identifier(
307 "pkg:pypi/requests@2.28.0",
308 "pkg:pypi/requests@2.28.0?artifact_id=abc"
309 ));
310 assert!(purl_matches_identifier(
312 "pkg:npm/lodash@4.17.21",
313 "pkg:npm/lodash@4.17.21"
314 ));
315 assert!(!purl_matches_identifier(
316 "pkg:npm/lodash@4.17.21",
317 "pkg:npm/lodash@4.17.20"
318 ));
319 }
320
321 #[test]
322 fn test_is_purl() {
323 assert!(is_purl("pkg:npm/lodash@4.17.21"));
324 assert!(is_purl("pkg:pypi/requests@2.28.0"));
325 assert!(!is_purl("lodash"));
326 assert!(!is_purl("CVE-2024-1234"));
327 }
328
329 #[cfg(feature = "cargo")]
330 #[test]
331 fn test_parse_cargo_purl() {
332 assert_eq!(
333 parse_cargo_purl("pkg:cargo/serde@1.0.200"),
334 Some(("serde", "1.0.200"))
335 );
336 assert_eq!(
337 parse_cargo_purl("pkg:cargo/serde_json@1.0.120"),
338 Some(("serde_json", "1.0.120"))
339 );
340 assert_eq!(parse_cargo_purl("pkg:npm/lodash@4.17.21"), None);
341 assert_eq!(parse_cargo_purl("pkg:cargo/@1.0.0"), None);
342 assert_eq!(parse_cargo_purl("pkg:cargo/serde@"), None);
343 }
344
345 #[cfg(feature = "cargo")]
346 #[test]
347 fn test_build_cargo_purl() {
348 assert_eq!(
349 build_cargo_purl("serde", "1.0.200"),
350 "pkg:cargo/serde@1.0.200"
351 );
352 }
353
354 #[cfg(feature = "cargo")]
355 #[test]
356 fn test_cargo_purl_round_trip() {
357 let purl = build_cargo_purl("tokio", "1.38.0");
358 let (name, version) = parse_cargo_purl(&purl).unwrap();
359 assert_eq!(name, "tokio");
360 assert_eq!(version, "1.38.0");
361 }
362
363 #[test]
364 fn test_parse_gem_purl() {
365 assert_eq!(
366 parse_gem_purl("pkg:gem/rails@7.1.0"),
367 Some(("rails", "7.1.0"))
368 );
369 assert_eq!(
370 parse_gem_purl("pkg:gem/nokogiri@1.16.5"),
371 Some(("nokogiri", "1.16.5"))
372 );
373 assert_eq!(parse_gem_purl("pkg:npm/lodash@4.17.21"), None);
374 assert_eq!(parse_gem_purl("pkg:gem/@1.0.0"), None);
375 assert_eq!(parse_gem_purl("pkg:gem/rails@"), None);
376 }
377
378 #[test]
379 fn test_build_gem_purl() {
380 assert_eq!(build_gem_purl("rails", "7.1.0"), "pkg:gem/rails@7.1.0");
381 }
382
383 #[test]
384 fn test_gem_purl_round_trip() {
385 let purl = build_gem_purl("nokogiri", "1.16.5");
386 let (name, version) = parse_gem_purl(&purl).unwrap();
387 assert_eq!(name, "nokogiri");
388 assert_eq!(version, "1.16.5");
389 }
390
391 #[cfg(feature = "maven")]
392 #[test]
393 fn test_parse_maven_purl() {
394 assert_eq!(
395 parse_maven_purl("pkg:maven/org.apache.commons/commons-lang3@3.12.0"),
396 Some(("org.apache.commons", "commons-lang3", "3.12.0"))
397 );
398 assert_eq!(
399 parse_maven_purl("pkg:maven/com.google.guava/guava@32.1.3-jre"),
400 Some(("com.google.guava", "guava", "32.1.3-jre"))
401 );
402 assert_eq!(parse_maven_purl("pkg:npm/lodash@4.17.21"), None);
403 assert_eq!(parse_maven_purl("pkg:maven/@3.12.0"), None);
404 assert_eq!(
405 parse_maven_purl("pkg:maven/org.apache.commons/@3.12.0"),
406 None
407 );
408 assert_eq!(
409 parse_maven_purl("pkg:maven/org.apache.commons/commons-lang3@"),
410 None
411 );
412 }
413
414 #[cfg(feature = "maven")]
415 #[test]
416 fn test_build_maven_purl() {
417 assert_eq!(
418 build_maven_purl("org.apache.commons", "commons-lang3", "3.12.0"),
419 "pkg:maven/org.apache.commons/commons-lang3@3.12.0"
420 );
421 }
422
423 #[cfg(feature = "maven")]
424 #[test]
425 fn test_maven_purl_round_trip() {
426 let purl = build_maven_purl("com.google.guava", "guava", "32.1.3-jre");
427 let (group_id, artifact_id, version) = parse_maven_purl(&purl).unwrap();
428 assert_eq!(group_id, "com.google.guava");
429 assert_eq!(artifact_id, "guava");
430 assert_eq!(version, "32.1.3-jre");
431 }
432
433 #[cfg(feature = "golang")]
434 #[test]
435 fn test_parse_golang_purl() {
436 assert_eq!(
437 parse_golang_purl("pkg:golang/github.com/gin-gonic/gin@v1.9.1"),
438 Some(("github.com/gin-gonic/gin", "v1.9.1"))
439 );
440 assert_eq!(
441 parse_golang_purl("pkg:golang/golang.org/x/text@v0.14.0"),
442 Some(("golang.org/x/text", "v0.14.0"))
443 );
444 assert_eq!(parse_golang_purl("pkg:npm/lodash@4.17.21"), None);
445 assert_eq!(parse_golang_purl("pkg:golang/@v1.0.0"), None);
446 assert_eq!(parse_golang_purl("pkg:golang/github.com/foo/bar@"), None);
447 }
448
449 #[cfg(feature = "golang")]
450 #[test]
451 fn test_build_golang_purl() {
452 assert_eq!(
453 build_golang_purl("github.com/gin-gonic/gin", "v1.9.1"),
454 "pkg:golang/github.com/gin-gonic/gin@v1.9.1"
455 );
456 }
457
458 #[cfg(feature = "golang")]
459 #[test]
460 fn test_golang_purl_round_trip() {
461 let purl = build_golang_purl("golang.org/x/text", "v0.14.0");
462 let (module_path, version) = parse_golang_purl(&purl).unwrap();
463 assert_eq!(module_path, "golang.org/x/text");
464 assert_eq!(version, "v0.14.0");
465 }
466
467 #[cfg(feature = "composer")]
468 #[test]
469 fn test_parse_composer_purl() {
470 assert_eq!(
471 parse_composer_purl("pkg:composer/monolog/monolog@3.5.0"),
472 Some((("monolog", "monolog"), "3.5.0"))
473 );
474 assert_eq!(
475 parse_composer_purl("pkg:composer/symfony/console@6.4.1"),
476 Some((("symfony", "console"), "6.4.1"))
477 );
478 assert_eq!(parse_composer_purl("pkg:npm/lodash@4.17.21"), None);
479 assert_eq!(parse_composer_purl("pkg:composer/@3.5.0"), None);
480 assert_eq!(parse_composer_purl("pkg:composer/monolog/@3.5.0"), None);
481 assert_eq!(parse_composer_purl("pkg:composer/monolog/monolog@"), None);
482 }
483
484 #[cfg(feature = "composer")]
485 #[test]
486 fn test_build_composer_purl() {
487 assert_eq!(
488 build_composer_purl("monolog", "monolog", "3.5.0"),
489 "pkg:composer/monolog/monolog@3.5.0"
490 );
491 }
492
493 #[cfg(feature = "deno")]
494 #[test]
495 fn test_parse_jsr_purl() {
496 assert_eq!(
497 parse_jsr_purl("pkg:jsr/@std/path@0.220.0"),
498 Some((("@std", "path"), "0.220.0"))
499 );
500 assert_eq!(
501 parse_jsr_purl("pkg:jsr/@luca/flag@1.0.0"),
502 Some((("@luca", "flag"), "1.0.0"))
503 );
504 assert_eq!(parse_jsr_purl("pkg:jsr/std/path@0.220.0"), None);
506 assert_eq!(parse_jsr_purl("pkg:jsr/@/path@0.220.0"), None);
508 assert_eq!(parse_jsr_purl("pkg:jsr/@std/@0.220.0"), None);
509 assert_eq!(parse_jsr_purl("pkg:jsr/@std/path@"), None);
510 assert_eq!(parse_jsr_purl("pkg:npm/@std/path@0.220.0"), None);
512 }
513
514 #[cfg(feature = "deno")]
515 #[test]
516 fn test_build_jsr_purl() {
517 assert_eq!(
518 build_jsr_purl("@std", "path", "0.220.0"),
519 "pkg:jsr/@std/path@0.220.0"
520 );
521 }
522
523 #[cfg(feature = "deno")]
524 #[test]
525 fn test_jsr_purl_round_trip() {
526 let purl = build_jsr_purl("@std", "path", "0.220.0");
527 let ((scope, name), version) = parse_jsr_purl(&purl).unwrap();
528 assert_eq!(scope, "@std");
529 assert_eq!(name, "path");
530 assert_eq!(version, "0.220.0");
531 }
532
533 #[cfg(feature = "composer")]
534 #[test]
535 fn test_composer_purl_round_trip() {
536 let purl = build_composer_purl("symfony", "console", "6.4.1");
537 let ((namespace, name), version) = parse_composer_purl(&purl).unwrap();
538 assert_eq!(namespace, "symfony");
539 assert_eq!(name, "console");
540 assert_eq!(version, "6.4.1");
541 }
542
543 #[cfg(feature = "nuget")]
544 #[test]
545 fn test_parse_nuget_purl() {
546 assert_eq!(
547 parse_nuget_purl("pkg:nuget/Newtonsoft.Json@13.0.3"),
548 Some(("Newtonsoft.Json", "13.0.3"))
549 );
550 assert_eq!(
551 parse_nuget_purl("pkg:nuget/System.Text.Json@8.0.0"),
552 Some(("System.Text.Json", "8.0.0"))
553 );
554 assert_eq!(parse_nuget_purl("pkg:npm/lodash@4.17.21"), None);
555 assert_eq!(parse_nuget_purl("pkg:nuget/@1.0.0"), None);
556 assert_eq!(parse_nuget_purl("pkg:nuget/Newtonsoft.Json@"), None);
557 }
558
559 #[cfg(feature = "nuget")]
560 #[test]
561 fn test_build_nuget_purl() {
562 assert_eq!(
563 build_nuget_purl("Newtonsoft.Json", "13.0.3"),
564 "pkg:nuget/Newtonsoft.Json@13.0.3"
565 );
566 }
567
568 #[cfg(feature = "nuget")]
569 #[test]
570 fn test_nuget_purl_round_trip() {
571 let purl = build_nuget_purl("System.Text.Json", "8.0.0");
572 let (name, version) = parse_nuget_purl(&purl).unwrap();
573 assert_eq!(name, "System.Text.Json");
574 assert_eq!(version, "8.0.0");
575 }
576
577 #[test]
586 fn test_strip_qualifiers_with_embedded_at() {
587 assert_eq!(
588 strip_purl_qualifiers("pkg:pypi/requests@2.28.0?vcs_url=git@github.com:psf/requests"),
589 "pkg:pypi/requests@2.28.0"
590 );
591 }
592
593 #[test]
594 fn test_parse_pypi_qualifier_with_embedded_at() {
595 assert_eq!(
598 parse_pypi_purl("pkg:pypi/requests@2.28.0?vcs_url=git@github.com"),
599 Some(("requests", "2.28.0"))
600 );
601 }
602
603 #[test]
604 fn test_parse_gem_with_trailing_qualifier() {
605 assert_eq!(
606 parse_gem_purl("pkg:gem/nokogiri@1.16.5?platform=java"),
607 Some(("nokogiri", "1.16.5"))
608 );
609 }
610
611 #[cfg(feature = "maven")]
612 #[test]
613 fn test_parse_maven_qualifier_with_embedded_at() {
614 assert_eq!(
617 parse_maven_purl(
618 "pkg:maven/org.apache.commons/commons-lang3@3.12.0?repository_url=user@host"
619 ),
620 Some(("org.apache.commons", "commons-lang3", "3.12.0"))
621 );
622 }
623
624 #[cfg(feature = "composer")]
625 #[test]
626 fn test_parse_composer_qualifier_with_embedded_at() {
627 assert_eq!(
628 parse_composer_purl("pkg:composer/monolog/monolog@3.5.0?source=git@github.com"),
629 Some((("monolog", "monolog"), "3.5.0"))
630 );
631 }
632
633 #[cfg(feature = "golang")]
634 #[test]
635 fn test_parse_golang_keeps_full_module_path() {
636 assert_eq!(
639 parse_golang_purl("pkg:golang/github.com/gin-gonic/gin@v1.9.1?type=module"),
640 Some(("github.com/gin-gonic/gin", "v1.9.1"))
641 );
642 }
643
644 #[cfg(feature = "deno")]
645 #[test]
646 fn test_parse_jsr_with_trailing_qualifier() {
647 assert_eq!(
650 parse_jsr_purl("pkg:jsr/@std/path@0.220.0?download_url=x@y"),
651 Some((("@std", "path"), "0.220.0"))
652 );
653 }
654
655 #[test]
658 fn test_purl_matches_identifier_qualified_id_needs_exact_key() {
659 assert!(!purl_matches_identifier(
662 "pkg:npm/lodash@4.17.21",
663 "pkg:npm/lodash@4.17.21?foo=bar"
664 ));
665 }
666
667 #[test]
668 fn test_purl_matches_identifier_base_id_matches_qualified_nonpypi_key() {
669 assert!(purl_matches_identifier(
672 "pkg:gem/nokogiri@1.16.5?platform=java",
673 "pkg:gem/nokogiri@1.16.5"
674 ));
675 }
676}