@@ -23,6 +23,7 @@ use socket_patch_core::utils::concurrent::{api_concurrency_for, ordered_concurre
2323use socket_patch_core:: utils:: purl:: {
2424 canonical_purl, is_purl, normalize_purl, strip_purl_qualifiers,
2525} ;
26+ use socket_patch_core:: utils:: purl_key:: PurlKey ;
2627use socket_patch_core:: vendor:: { load_state, lookup_entry, VendorEntry , VendorState } ;
2728use std:: collections:: HashMap ;
2829use std:: fmt;
@@ -1430,9 +1431,8 @@ struct InstalledNarrowing {
14301431/// runs — the coarse layer above [`filter_to_installed_releases`]'s
14311432/// per-release variant narrowing (which still runs later, unchanged).
14321433///
1433- /// Presence evidence per result purl (compared on
1434- /// `normalize_purl(strip_purl_qualifiers(..))` — API purls are
1435- /// percent-encoded/qualified, crawler purls literal):
1434+ /// Presence evidence per result purl (compared by [`PurlKey`] — API purls
1435+ /// are percent-encoded/qualified/mixed-case, crawler purls literal):
14361436/// * installed on disk — `find_packages_for_rollback` over the deduped base
14371437/// purls (the qualified-aware resolver; memory invariant);
14381438/// * already tracked in the manifest — the user opted this purl in earlier,
@@ -1465,8 +1465,6 @@ async fn filter_to_installed_purls(
14651465 use socket_patch_core:: vendor:: lock_inventory;
14661466 use std:: collections:: HashSet ;
14671467
1468- let canon = canonical_purl;
1469-
14701468 // Deduped base purls, probed against the installed tree. The resolver
14711469 // keys its result by the purls we pass, so canonicalize the found keys
14721470 // the same way as the membership probes below.
@@ -1480,14 +1478,14 @@ async fn filter_to_installed_purls(
14801478 } ;
14811479 let partitioned = partition_purls ( & bases, None ) ;
14821480 let found = find_packages_for_rollback ( & partitioned, & common. crawler_options ( ) , true ) . await ;
1483- let mut present: HashSet < String > = found. keys ( ) . map ( |k| canon ( k) ) . collect ( ) ;
1481+ let mut present: HashSet < PurlKey > = found. keys ( ) . map ( |k| PurlKey :: new ( k) ) . collect ( ) ;
14841482
14851483 let ctx = super :: context:: ProjectContext :: rooted ( common, common. cwd . clone ( ) ) ;
14861484 // Manifest membership counts as presence (read-only probe: a corrupt
14871485 // manifest degrades to "no extension" here — the download path's
14881486 // fail-closed read still guards every write).
14891487 if let Some ( manifest) = ctx. ledgers ( ) . await . manifest {
1490- present. extend ( manifest. patches . keys ( ) . map ( |k| canon ( k) ) ) ;
1488+ present. extend ( manifest. patches . keys ( ) . map ( |k| PurlKey :: new ( k) ) ) ;
14911489 }
14921490
14931491 // scan's lockfile + vendored-ledger discovery supplements (and their
@@ -1498,11 +1496,11 @@ async fn filter_to_installed_purls(
14981496 let supplement = super :: scan:: project_lockfile_supplement ( & ctx, & [ ] , None ) . await ;
14991497 pnp_diags = supplement. unsupported ;
15001498 if mode != super :: scan:: ScanMode :: Agent {
1501- present. extend ( supplement. entries . iter ( ) . map ( |e| canon ( & e. purl ) ) ) ;
1499+ present. extend ( supplement. entries . iter ( ) . map ( |e| PurlKey :: new ( & e. purl ) ) ) ;
15021500 let vendored =
15031501 super :: scan:: project_vendored_supplement ( common, & [ ] , & ctx. loaded ( ) . await . vendor )
15041502 . await ;
1505- present. extend ( vendored. packages . iter ( ) . map ( |p| canon ( & p. purl ) ) ) ;
1503+ present. extend ( vendored. packages . iter ( ) . map ( |p| PurlKey :: new ( & p. purl ) ) ) ;
15061504 }
15071505 }
15081506
@@ -1536,7 +1534,7 @@ async fn filter_to_installed_purls(
15361534 warnings,
15371535 } ;
15381536 for result in accessible {
1539- if present. contains ( & canon ( & result. purl ) ) {
1537+ if present. contains ( & PurlKey :: new ( & result. purl ) ) {
15401538 out. kept . push ( result. clone ( ) ) ;
15411539 continue ;
15421540 }
@@ -1565,7 +1563,7 @@ async fn filter_to_installed_purls(
15651563 // nothing — so it carries the same `package_not_installed` code
15661564 // a non-PnP pnpm project would get; only an UNREADABLE lock
15671565 // (no judgment possible) keeps the layout-refusal code.
1568- let decoded = canon ( & result. purl ) ;
1566+ let decoded = canonical_purl ( & result. purl ) ;
15691567 let coord = decoded. strip_prefix ( "pkg:npm/" ) . unwrap_or ( & decoded) ;
15701568 if mode == super :: scan:: ScanMode :: Hosted {
15711569 match ( & pnpm_pnp_lock, coord. rsplit_once ( '@' ) ) {
@@ -1780,7 +1778,8 @@ async fn lock_text_refusals_for(
17801778 )
17811779 . await ,
17821780 ) ;
1783- let claimed: Vec < String > = pins. iter ( ) . map ( |pin| canonical_purl ( & pin. purl ) ) . collect ( ) ;
1781+ let claimed: std:: collections:: HashSet < PurlKey > =
1782+ pins. iter ( ) . map ( |pin| PurlKey :: new ( & pin. purl ) ) . collect ( ) ;
17841783 let fetchable: Vec < & PatchSearchResult > = selected
17851784 . iter ( )
17861785 . filter ( |sr| bun_refusal. filter ( |r| r. applies_to ( & sr. purl ) ) . is_none ( ) )
@@ -1791,7 +1790,7 @@ async fn lock_text_refusals_for(
17911790 . collect ( ) ;
17921791 let candidates: Vec < ( & str , & str ) > = fetchable
17931792 . iter ( )
1794- . filter ( |sr| !claimed. contains ( & canonical_purl ( & sr. purl ) ) )
1793+ . filter ( |sr| !claimed. contains ( & PurlKey :: new ( & sr. purl ) ) )
17951794 . map ( |sr| ( sr. purl . as_str ( ) , sr. uuid . as_str ( ) ) )
17961795 . collect ( ) ;
17971796 let refused = socket_patch_core:: vendor:: lock_text_refusals ( cwd, & candidates) . await ;
@@ -1815,7 +1814,7 @@ async fn lock_text_refusals_for(
18151814 cwd,
18161815 fetchable
18171816 . iter ( )
1818- . filter ( |sr| claimed. contains ( & canonical_purl ( & sr. purl ) ) )
1817+ . filter ( |sr| claimed. contains ( & PurlKey :: new ( & sr. purl ) ) )
18191818 . map ( |sr| sr. purl . as_str ( ) ) ,
18201819 & pins,
18211820 false ,
@@ -2389,8 +2388,8 @@ async fn run_nested_apply(
23892388/// qualified record (apply keys a release-variant base by its base purl).
23902389/// A qualified key never covers a sibling variant.
23912390fn apply_key_covers ( key : & str , record : & str ) -> bool {
2392- let ( key, record ) = ( normalize_purl ( key ) , normalize_purl ( record) ) ;
2393- key == record || ( !key. contains ( [ '?' , '#' ] ) && record . split ( [ '?' , '#' ] ) . next ( ) == Some ( & * key ) )
2391+ PurlKey :: qualified ( key) == PurlKey :: qualified ( record)
2392+ || ( !key. trim ( ) . contains ( [ '?' , '#' ] ) && PurlKey :: same ( key , record ) )
23942393}
23952394
23962395/// Fold a failed nested apply into a `get` / `scan --mode agent` JSON
@@ -2433,7 +2432,8 @@ fn fold_apply_failures(
24332432 }
24342433 }
24352434 let appended = patches[ selected..] . iter ( ) . any ( |r| {
2436- normalize_purl ( r[ "purl" ] . as_str ( ) . unwrap_or_default ( ) ) == normalize_purl ( & failure. purl )
2435+ PurlKey :: qualified ( r[ "purl" ] . as_str ( ) . unwrap_or_default ( ) )
2436+ == PurlKey :: qualified ( & failure. purl )
24372437 } ) ;
24382438 if !hit && !appended {
24392439 let mut rec = serde_json:: json!( {
0 commit comments