Skip to main content

socket_patch_core/utils/
purl.rs

1/// Strip query string qualifiers from a PURL.
2///
3/// e.g., `"pkg:pypi/requests@2.28.0?artifact_id=abc"` -> `"pkg:pypi/requests@2.28.0"`
4pub fn strip_purl_qualifiers(purl: &str) -> &str {
5    match purl.find('?') {
6        Some(idx) => &purl[..idx],
7        None => purl,
8    }
9}
10
11/// Parse a PyPI PURL to extract name and version.
12///
13/// e.g., `"pkg:pypi/requests@2.28.0?artifact_id=abc"` -> `Some(("requests", "2.28.0"))`
14pub 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
26/// Parse a gem PURL to extract name and version.
27///
28/// e.g., `"pkg:gem/rails@7.1.0"` -> `Some(("rails", "7.1.0"))`
29pub 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
41/// Build a gem PURL from components.
42pub fn build_gem_purl(name: &str, version: &str) -> String {
43    format!("pkg:gem/{name}@{version}")
44}
45
46/// Parse a Maven PURL to extract groupId, artifactId, and version.
47///
48/// e.g., `"pkg:maven/org.apache.commons/commons-lang3@3.12.0"` -> `Some(("org.apache.commons", "commons-lang3", "3.12.0"))`
49#[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    // Split groupId/artifactId
62    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/// Build a Maven PURL from components.
74#[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/// Parse a Go module PURL to extract module path and version.
80///
81/// e.g., `"pkg:golang/github.com/gin-gonic/gin@v1.9.1"` -> `Some(("github.com/gin-gonic/gin", "v1.9.1"))`
82#[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/// Build a Go module PURL from components.
96#[cfg(feature = "golang")]
97pub fn build_golang_purl(module_path: &str, version: &str) -> String {
98    format!("pkg:golang/{module_path}@{version}")
99}
100
101/// Parse a Composer PURL to extract namespace, name, and version.
102///
103/// Composer packages always have a namespace (vendor).
104/// e.g., `"pkg:composer/monolog/monolog@3.5.0"` -> `Some((("monolog", "monolog"), "3.5.0"))`
105#[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    // Split namespace/name
118    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/// Build a Composer PURL from components.
130#[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/// Parse a JSR PURL to extract scope, name, and version.
136///
137/// JSR (https://jsr.io) is Deno's package registry. Packages are
138/// always scoped (`@scope/name`). PURL form:
139/// `pkg:jsr/<scope>/<name>@<version>` — e.g.
140/// `"pkg:jsr/@std/path@0.220.0"` -> `Some((("@std", "path"), "0.220.0"))`.
141///
142/// `pkg:jsr/` isn't a standardized purl-type upstream as of writing,
143/// but the convention is informally adopted by some Deno tooling.
144/// We follow the same shape as `parse_composer_purl` since both
145/// have a `<scope>/<name>` namespace structure. The leading `@` on
146/// the scope is preserved (matching npm's `@scope/name` convention).
147#[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    // Scope must be `@<non-empty>`. The bare `@` (length 1) is
164    // invalid — there's no actual scope after the marker.
165    if name.is_empty() || !scope.starts_with('@') || scope.len() < 2 {
166        return None;
167    }
168
169    Some(((scope, name), version))
170}
171
172/// Build a JSR PURL from components.
173#[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/// Parse a NuGet PURL to extract name and version.
179///
180/// e.g., `"pkg:nuget/Newtonsoft.Json@13.0.3"` -> `Some(("Newtonsoft.Json", "13.0.3"))`
181#[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/// Build a NuGet PURL from components.
195#[cfg(feature = "nuget")]
196pub fn build_nuget_purl(name: &str, version: &str) -> String {
197    format!("pkg:nuget/{name}@{version}")
198}
199
200/// Parse a Cargo PURL to extract name and version.
201///
202/// e.g., `"pkg:cargo/serde@1.0.200"` -> `Some(("serde", "1.0.200"))`
203#[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/// Build a Cargo PURL from components.
217#[cfg(feature = "cargo")]
218pub fn build_cargo_purl(name: &str, version: &str) -> String {
219    format!("pkg:cargo/{name}@{version}")
220}
221
222
223/// Check if a string looks like a PURL.
224pub 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        // Scope must start with `@`.
440        assert_eq!(parse_jsr_purl("pkg:jsr/std/path@0.220.0"), None);
441        // Empty pieces.
442        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        // Wrong scheme.
446        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}