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/// Check if a string looks like a PURL.
223pub fn is_purl(s: &str) -> bool {
224    s.starts_with("pkg:")
225}
226
227/// Does a manifest PURL key match a user-supplied PURL identifier?
228///
229/// PyPI patches are keyed in the manifest by their fully-qualified PURL
230/// (`pkg:pypi/foo@1.0?artifact_id=...`), one entry per release variant.
231/// A user removing or rolling back a package usually types the *base*
232/// PURL without a qualifier and expects it to cover every variant. So:
233///
234/// * a **base** identifier (no `?`) matches any key whose base equals it
235///   — i.e. all release variants of that `package@version`, and
236/// * a **qualified** identifier (`?artifact_id=...`) matches only the
237///   exact key, so a single variant can still be targeted precisely.
238///
239/// Non-PyPI keys never carry a `?`, so for them this reduces to plain
240/// equality.
241pub 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        // Base identifier matches every qualified variant + the bare base.
283        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        // Base identifier does NOT match a different version.
292        assert!(!purl_matches_identifier(
293            "pkg:pypi/requests@2.29.0?artifact_id=abc",
294            "pkg:pypi/requests@2.28.0"
295        ));
296        // Qualified identifier matches only the exact key.
297        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        // A qualified identifier must not match the bare base key.
306        assert!(!purl_matches_identifier(
307            "pkg:pypi/requests@2.28.0",
308            "pkg:pypi/requests@2.28.0?artifact_id=abc"
309        ));
310        // Non-PyPI keys: plain equality.
311        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        // Scope must start with `@`.
505        assert_eq!(parse_jsr_purl("pkg:jsr/std/path@0.220.0"), None);
506        // Empty pieces.
507        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        // Wrong scheme.
511        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    // --- Regression: qualifier handling -------------------------------------
578    //
579    // Qualifiers are stripped *before* the version is split off with
580    // `rfind('@')`. This matters because a qualifier *value* can itself
581    // contain an `@` (e.g. a `git@github.com` source URL). If stripping
582    // ran after the `@` search, that trailing `@` would be mistaken for
583    // the version separator and corrupt both name and version.
584
585    #[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        // The `@github.com` inside the qualifier value must not be read
596        // as the version separator.
597        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        // groupId/artifactId split must survive an `@` buried in a
615        // qualifier value.
616        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        // The module path retains its internal slashes — only the
637        // version is split off. A trailing qualifier is ignored.
638        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        // Scope `@` + version `@` + qualifier `@` all coexist; only the
648        // version `@` should be honored.
649        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    // --- Regression: purl_matches_identifier for non-PyPI keys --------------
656
657    #[test]
658    fn test_purl_matches_identifier_qualified_id_needs_exact_key() {
659        // A qualified identifier must not match an unqualified manifest
660        // key, even when their bases are equal.
661        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        // A base identifier matches a qualified manifest key in any
670        // ecosystem (gems can carry a `?platform=` qualifier).
671        assert!(purl_matches_identifier(
672            "pkg:gem/nokogiri@1.16.5?platform=java",
673            "pkg:gem/nokogiri@1.16.5"
674        ));
675    }
676}