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
222
223pub fn is_purl(s: &str) -> bool {
225 s.starts_with("pkg:")
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 #[test]
233 fn test_strip_qualifiers() {
234 assert_eq!(
235 strip_purl_qualifiers("pkg:pypi/requests@2.28.0?artifact_id=abc"),
236 "pkg:pypi/requests@2.28.0"
237 );
238 assert_eq!(
239 strip_purl_qualifiers("pkg:npm/lodash@4.17.21"),
240 "pkg:npm/lodash@4.17.21"
241 );
242 }
243
244 #[test]
245 fn test_parse_pypi_purl() {
246 assert_eq!(
247 parse_pypi_purl("pkg:pypi/requests@2.28.0"),
248 Some(("requests", "2.28.0"))
249 );
250 assert_eq!(
251 parse_pypi_purl("pkg:pypi/requests@2.28.0?artifact_id=abc"),
252 Some(("requests", "2.28.0"))
253 );
254 assert_eq!(parse_pypi_purl("pkg:npm/lodash@4.17.21"), None);
255 assert_eq!(parse_pypi_purl("pkg:pypi/@2.28.0"), None);
256 assert_eq!(parse_pypi_purl("pkg:pypi/requests@"), None);
257 }
258
259 #[test]
260 fn test_is_purl() {
261 assert!(is_purl("pkg:npm/lodash@4.17.21"));
262 assert!(is_purl("pkg:pypi/requests@2.28.0"));
263 assert!(!is_purl("lodash"));
264 assert!(!is_purl("CVE-2024-1234"));
265 }
266
267 #[cfg(feature = "cargo")]
268 #[test]
269 fn test_parse_cargo_purl() {
270 assert_eq!(
271 parse_cargo_purl("pkg:cargo/serde@1.0.200"),
272 Some(("serde", "1.0.200"))
273 );
274 assert_eq!(
275 parse_cargo_purl("pkg:cargo/serde_json@1.0.120"),
276 Some(("serde_json", "1.0.120"))
277 );
278 assert_eq!(parse_cargo_purl("pkg:npm/lodash@4.17.21"), None);
279 assert_eq!(parse_cargo_purl("pkg:cargo/@1.0.0"), None);
280 assert_eq!(parse_cargo_purl("pkg:cargo/serde@"), None);
281 }
282
283 #[cfg(feature = "cargo")]
284 #[test]
285 fn test_build_cargo_purl() {
286 assert_eq!(
287 build_cargo_purl("serde", "1.0.200"),
288 "pkg:cargo/serde@1.0.200"
289 );
290 }
291
292 #[cfg(feature = "cargo")]
293 #[test]
294 fn test_cargo_purl_round_trip() {
295 let purl = build_cargo_purl("tokio", "1.38.0");
296 let (name, version) = parse_cargo_purl(&purl).unwrap();
297 assert_eq!(name, "tokio");
298 assert_eq!(version, "1.38.0");
299 }
300
301 #[test]
302 fn test_parse_gem_purl() {
303 assert_eq!(
304 parse_gem_purl("pkg:gem/rails@7.1.0"),
305 Some(("rails", "7.1.0"))
306 );
307 assert_eq!(
308 parse_gem_purl("pkg:gem/nokogiri@1.16.5"),
309 Some(("nokogiri", "1.16.5"))
310 );
311 assert_eq!(parse_gem_purl("pkg:npm/lodash@4.17.21"), None);
312 assert_eq!(parse_gem_purl("pkg:gem/@1.0.0"), None);
313 assert_eq!(parse_gem_purl("pkg:gem/rails@"), None);
314 }
315
316 #[test]
317 fn test_build_gem_purl() {
318 assert_eq!(
319 build_gem_purl("rails", "7.1.0"),
320 "pkg:gem/rails@7.1.0"
321 );
322 }
323
324 #[test]
325 fn test_gem_purl_round_trip() {
326 let purl = build_gem_purl("nokogiri", "1.16.5");
327 let (name, version) = parse_gem_purl(&purl).unwrap();
328 assert_eq!(name, "nokogiri");
329 assert_eq!(version, "1.16.5");
330 }
331
332 #[cfg(feature = "maven")]
333 #[test]
334 fn test_parse_maven_purl() {
335 assert_eq!(
336 parse_maven_purl("pkg:maven/org.apache.commons/commons-lang3@3.12.0"),
337 Some(("org.apache.commons", "commons-lang3", "3.12.0"))
338 );
339 assert_eq!(
340 parse_maven_purl("pkg:maven/com.google.guava/guava@32.1.3-jre"),
341 Some(("com.google.guava", "guava", "32.1.3-jre"))
342 );
343 assert_eq!(parse_maven_purl("pkg:npm/lodash@4.17.21"), None);
344 assert_eq!(parse_maven_purl("pkg:maven/@3.12.0"), None);
345 assert_eq!(parse_maven_purl("pkg:maven/org.apache.commons/@3.12.0"), None);
346 assert_eq!(parse_maven_purl("pkg:maven/org.apache.commons/commons-lang3@"), None);
347 }
348
349 #[cfg(feature = "maven")]
350 #[test]
351 fn test_build_maven_purl() {
352 assert_eq!(
353 build_maven_purl("org.apache.commons", "commons-lang3", "3.12.0"),
354 "pkg:maven/org.apache.commons/commons-lang3@3.12.0"
355 );
356 }
357
358 #[cfg(feature = "maven")]
359 #[test]
360 fn test_maven_purl_round_trip() {
361 let purl = build_maven_purl("com.google.guava", "guava", "32.1.3-jre");
362 let (group_id, artifact_id, version) = parse_maven_purl(&purl).unwrap();
363 assert_eq!(group_id, "com.google.guava");
364 assert_eq!(artifact_id, "guava");
365 assert_eq!(version, "32.1.3-jre");
366 }
367
368 #[cfg(feature = "golang")]
369 #[test]
370 fn test_parse_golang_purl() {
371 assert_eq!(
372 parse_golang_purl("pkg:golang/github.com/gin-gonic/gin@v1.9.1"),
373 Some(("github.com/gin-gonic/gin", "v1.9.1"))
374 );
375 assert_eq!(
376 parse_golang_purl("pkg:golang/golang.org/x/text@v0.14.0"),
377 Some(("golang.org/x/text", "v0.14.0"))
378 );
379 assert_eq!(parse_golang_purl("pkg:npm/lodash@4.17.21"), None);
380 assert_eq!(parse_golang_purl("pkg:golang/@v1.0.0"), None);
381 assert_eq!(parse_golang_purl("pkg:golang/github.com/foo/bar@"), None);
382 }
383
384 #[cfg(feature = "golang")]
385 #[test]
386 fn test_build_golang_purl() {
387 assert_eq!(
388 build_golang_purl("github.com/gin-gonic/gin", "v1.9.1"),
389 "pkg:golang/github.com/gin-gonic/gin@v1.9.1"
390 );
391 }
392
393 #[cfg(feature = "golang")]
394 #[test]
395 fn test_golang_purl_round_trip() {
396 let purl = build_golang_purl("golang.org/x/text", "v0.14.0");
397 let (module_path, version) = parse_golang_purl(&purl).unwrap();
398 assert_eq!(module_path, "golang.org/x/text");
399 assert_eq!(version, "v0.14.0");
400 }
401
402 #[cfg(feature = "composer")]
403 #[test]
404 fn test_parse_composer_purl() {
405 assert_eq!(
406 parse_composer_purl("pkg:composer/monolog/monolog@3.5.0"),
407 Some((("monolog", "monolog"), "3.5.0"))
408 );
409 assert_eq!(
410 parse_composer_purl("pkg:composer/symfony/console@6.4.1"),
411 Some((("symfony", "console"), "6.4.1"))
412 );
413 assert_eq!(parse_composer_purl("pkg:npm/lodash@4.17.21"), None);
414 assert_eq!(parse_composer_purl("pkg:composer/@3.5.0"), None);
415 assert_eq!(parse_composer_purl("pkg:composer/monolog/@3.5.0"), None);
416 assert_eq!(parse_composer_purl("pkg:composer/monolog/monolog@"), None);
417 }
418
419 #[cfg(feature = "composer")]
420 #[test]
421 fn test_build_composer_purl() {
422 assert_eq!(
423 build_composer_purl("monolog", "monolog", "3.5.0"),
424 "pkg:composer/monolog/monolog@3.5.0"
425 );
426 }
427
428 #[cfg(feature = "deno")]
429 #[test]
430 fn test_parse_jsr_purl() {
431 assert_eq!(
432 parse_jsr_purl("pkg:jsr/@std/path@0.220.0"),
433 Some((("@std", "path"), "0.220.0"))
434 );
435 assert_eq!(
436 parse_jsr_purl("pkg:jsr/@luca/flag@1.0.0"),
437 Some((("@luca", "flag"), "1.0.0"))
438 );
439 assert_eq!(parse_jsr_purl("pkg:jsr/std/path@0.220.0"), None);
441 assert_eq!(parse_jsr_purl("pkg:jsr/@/path@0.220.0"), None);
443 assert_eq!(parse_jsr_purl("pkg:jsr/@std/@0.220.0"), None);
444 assert_eq!(parse_jsr_purl("pkg:jsr/@std/path@"), None);
445 assert_eq!(parse_jsr_purl("pkg:npm/@std/path@0.220.0"), None);
447 }
448
449 #[cfg(feature = "deno")]
450 #[test]
451 fn test_build_jsr_purl() {
452 assert_eq!(
453 build_jsr_purl("@std", "path", "0.220.0"),
454 "pkg:jsr/@std/path@0.220.0"
455 );
456 }
457
458 #[cfg(feature = "deno")]
459 #[test]
460 fn test_jsr_purl_round_trip() {
461 let purl = build_jsr_purl("@std", "path", "0.220.0");
462 let ((scope, name), version) = parse_jsr_purl(&purl).unwrap();
463 assert_eq!(scope, "@std");
464 assert_eq!(name, "path");
465 assert_eq!(version, "0.220.0");
466 }
467
468 #[cfg(feature = "composer")]
469 #[test]
470 fn test_composer_purl_round_trip() {
471 let purl = build_composer_purl("symfony", "console", "6.4.1");
472 let ((namespace, name), version) = parse_composer_purl(&purl).unwrap();
473 assert_eq!(namespace, "symfony");
474 assert_eq!(name, "console");
475 assert_eq!(version, "6.4.1");
476 }
477
478 #[cfg(feature = "nuget")]
479 #[test]
480 fn test_parse_nuget_purl() {
481 assert_eq!(
482 parse_nuget_purl("pkg:nuget/Newtonsoft.Json@13.0.3"),
483 Some(("Newtonsoft.Json", "13.0.3"))
484 );
485 assert_eq!(
486 parse_nuget_purl("pkg:nuget/System.Text.Json@8.0.0"),
487 Some(("System.Text.Json", "8.0.0"))
488 );
489 assert_eq!(parse_nuget_purl("pkg:npm/lodash@4.17.21"), None);
490 assert_eq!(parse_nuget_purl("pkg:nuget/@1.0.0"), None);
491 assert_eq!(parse_nuget_purl("pkg:nuget/Newtonsoft.Json@"), None);
492 }
493
494 #[cfg(feature = "nuget")]
495 #[test]
496 fn test_build_nuget_purl() {
497 assert_eq!(
498 build_nuget_purl("Newtonsoft.Json", "13.0.3"),
499 "pkg:nuget/Newtonsoft.Json@13.0.3"
500 );
501 }
502
503 #[cfg(feature = "nuget")]
504 #[test]
505 fn test_nuget_purl_round_trip() {
506 let purl = build_nuget_purl("System.Text.Json", "8.0.0");
507 let (name, version) = parse_nuget_purl(&purl).unwrap();
508 assert_eq!(name, "System.Text.Json");
509 assert_eq!(version, "8.0.0");
510 }
511
512}