@@ -11,6 +11,7 @@ use socket_patch_core::api::types::{
1111} ;
1212use socket_patch_core:: crawlers:: fuzzy_match:: fuzzy_match_packages;
1313use socket_patch_core:: crawlers:: { CrawlerOptions , Ecosystem } ;
14+ use socket_patch_core:: formats:: pnpm:: PnpmLock ;
1415use socket_patch_core:: manifest:: operations:: { read_manifest, write_manifest} ;
1516pub ( crate ) use socket_patch_core:: manifest:: records:: record_from_patch_response;
1617use socket_patch_core:: manifest:: records:: { build_patch_record, files_for_manifest} ;
@@ -1425,47 +1426,6 @@ fn purl_has_version(purl: &str) -> bool {
14251426 } )
14261427}
14271428
1428- /// Does the raw pnpm-lock text RESOLVE `name@version`? Boundary-anchored
1429- /// probes over the three lock grammars — a plain `contains` collides on
1430- /// version prefixes (`left-pad@1.3.0` matches inside
1431- /// `left-pad@1.3.0-beta.1`), name suffixes (`pad@1.3.0` inside
1432- /// `left-pad@1.3.0`), and unscoped-inside-scoped names (`name@1.0.0` inside
1433- /// `@scope/name@1.0.0`). The needles cover v6/v9's `name@version` and v5's
1434- /// `/name/version` key spellings; a match counts only when the preceding
1435- /// char cannot extend the name (start/whitespace/quote, or a `/` delimiter
1436- /// itself preceded by such a boundary) and the following char cannot extend
1437- /// the version (so `:`, `'`, `(`, and v5's `_peer` suffix all accept).
1438- /// Heuristic by design: a false negative degrades to a calm skip, a false
1439- /// positive costs one grant request the rewriter's per-dep confirmation
1440- /// then ignores.
1441- fn pnpm_lock_resolves ( text : & str , name : & str , version : & str ) -> bool {
1442- let version_boundary = |c : char | !( c. is_ascii_alphanumeric ( ) || matches ! ( c, '.' | '-' | '+' ) ) ;
1443- let name_boundary = |c : char | matches ! ( c, ' ' | '\t' | '\n' | '\r' | '\'' | '"' ) ;
1444- for needle in [ format ! ( "{name}@{version}" ) , format ! ( "/{name}/{version}" ) ] {
1445- for ( pos, _) in text. match_indices ( needle. as_str ( ) ) {
1446- let before_ok = match text[ ..pos] . chars ( ) . next_back ( ) {
1447- None => true ,
1448- // v5/v6's leading key delimiter — legitimate only when the
1449- // char before it is itself a boundary (otherwise this is a
1450- // scoped `@scope/<name>` tail: a DIFFERENT package).
1451- Some ( '/' ) => text[ ..pos - 1 ]
1452- . chars ( )
1453- . next_back ( )
1454- . is_none_or ( name_boundary) ,
1455- Some ( c) => name_boundary ( c) ,
1456- } ;
1457- let after_ok = text[ pos + needle. len ( ) ..]
1458- . chars ( )
1459- . next ( )
1460- . is_none_or ( version_boundary) ;
1461- if before_ok && after_ok {
1462- return true ;
1463- }
1464- }
1465- }
1466- false
1467- }
1468-
14691429/// Outcome of the coarse installed-VERSION narrowing over a CVE/GHSA/PURL
14701430/// search fan-out (see [`filter_to_installed_purls`]).
14711431struct InstalledNarrowing {
@@ -1504,7 +1464,7 @@ struct InstalledNarrowing {
15041464/// `yarn_pnp_unsupported`, not a false "not installed"). pnpm PnP skips
15051465/// carry `pnpm_pnp_unsupported` in agent/vendored modes; hosted mode — the
15061466/// refusal's own remedy — keeps the versions the raw pnpm-lock.yaml text
1507- /// resolves ([`pnpm_lock_resolves `]), labels a judged miss
1467+ /// resolves ([`PnpmLock::resolves `]), labels a judged miss
15081468/// `package_not_installed` like any other mode, and reserves the layout
15091469/// code for an unreadable lock (no judgment possible).
15101470///
@@ -1576,6 +1536,7 @@ async fn filter_to_installed_purls(
15761536 let pnpm_pnp_lock_text: Option < String > = ( pnp_pnpm && mode == super :: scan:: ScanMode :: Hosted )
15771537 . then ( || std:: fs:: read_to_string ( common. cwd . join ( "pnpm-lock.yaml" ) ) . ok ( ) )
15781538 . flatten ( ) ;
1539+ let pnpm_pnp_lock = pnpm_pnp_lock_text. as_deref ( ) . map ( PnpmLock :: parse) ;
15791540
15801541 let mut out = InstalledNarrowing {
15811542 kept : Vec :: new ( ) ,
@@ -1605,8 +1566,8 @@ async fn filter_to_installed_purls(
16051566 // The pnpm PnP refusal's own remedy is the hosted lockfile
16061567 // rewrite — but only for versions the lock ACTUALLY resolves:
16071568 // keeping the whole fan-out would request grants for every
1608- // version ever patched. Anchored probe over the raw lock text
1609- // (see `pnpm_lock_resolves `); a hit is kept (the rewriter's
1569+ // version ever patched. The lock model's key probe
1570+ // (`PnpmLock::resolves `); a hit is kept (the rewriter's
16101571 // per-dep confirmation still decides). A judged MISS is a
16111572 // genuine "version not resolved" verdict — the layout blocked
16121573 // nothing — so it carries the same `package_not_installed` code
@@ -1615,9 +1576,9 @@ async fn filter_to_installed_purls(
16151576 let decoded = canon ( & result. purl ) ;
16161577 let coord = decoded. strip_prefix ( "pkg:npm/" ) . unwrap_or ( & decoded) ;
16171578 if mode == super :: scan:: ScanMode :: Hosted {
1618- match ( pnpm_pnp_lock_text . as_deref ( ) , coord. rsplit_once ( '@' ) ) {
1619- ( Some ( text ) , Some ( ( name, version) ) ) => {
1620- if pnpm_lock_resolves ( text , name, version) {
1579+ match ( & pnpm_pnp_lock , coord. rsplit_once ( '@' ) ) {
1580+ ( Some ( lock ) , Some ( ( name, version) ) ) => {
1581+ if lock . resolves ( name, version) {
16211582 out. kept . push ( result. clone ( ) ) ;
16221583 continue ;
16231584 }
@@ -3914,77 +3875,6 @@ pub(crate) fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
39143875mod tests {
39153876 use super :: * ;
39163877
3917- /// The pnpm-PnP hosted lock probe must be boundary-anchored: plain
3918- /// substring matching collides on version prefixes, name suffixes, and
3919- /// unscoped-inside-scoped names.
3920- #[ test]
3921- fn pnpm_lock_resolves_is_boundary_anchored ( ) {
3922- // v9/v6/v5 key spellings all resolve.
3923- assert ! ( pnpm_lock_resolves(
3924- "lockfileVersion: '9.0'\n \n snapshots:\n \n left-pad@1.3.0:\n " ,
3925- "left-pad" ,
3926- "1.3.0"
3927- ) ) ;
3928- assert ! ( pnpm_lock_resolves(
3929- " /left-pad@1.3.0:\n resolution: {}\n " ,
3930- "left-pad" ,
3931- "1.3.0"
3932- ) ) ;
3933- assert ! ( pnpm_lock_resolves(
3934- " /left-pad/1.3.0:\n resolution: {}\n " ,
3935- "left-pad" ,
3936- "1.3.0"
3937- ) ) ;
3938- // Peer-qualified keys still resolve: v9 `(peer)` and v5 `_peer`.
3939- assert ! ( pnpm_lock_resolves(
3940- " 'left-pad@1.3.0(react@18.0.0)':\n " ,
3941- "left-pad" ,
3942- "1.3.0"
3943- ) ) ;
3944- assert ! ( pnpm_lock_resolves(
3945- " /left-pad/1.3.0_react@18.0.0:\n " ,
3946- "left-pad" ,
3947- "1.3.0"
3948- ) ) ;
3949- // Scoped names resolve in both quoted-v9 and v6 spellings.
3950- assert ! ( pnpm_lock_resolves(
3951- " '@scope/name@1.0.0':\n " ,
3952- "@scope/name" ,
3953- "1.0.0"
3954- ) ) ;
3955- assert ! ( pnpm_lock_resolves(
3956- " /@scope/name@1.0.0:\n " ,
3957- "@scope/name" ,
3958- "1.0.0"
3959- ) ) ;
3960-
3961- // Version-prefix collision: 1.3.0 must NOT match 1.3.0-beta.1.
3962- assert ! ( !pnpm_lock_resolves(
3963- " left-pad@1.3.0-beta.1:\n " ,
3964- "left-pad" ,
3965- "1.3.0"
3966- ) ) ;
3967- // Name-suffix collision: `pad` must NOT match inside `left-pad`.
3968- assert ! ( !pnpm_lock_resolves( " left-pad@1.3.0:\n " , "pad" , "1.3.0" ) ) ;
3969- assert ! ( !pnpm_lock_resolves( " /left-pad/1.3.0:\n " , "pad" , "1.3.0" ) ) ;
3970- // Unscoped-inside-scoped: `name` must NOT match `@scope/name`.
3971- assert ! ( !pnpm_lock_resolves(
3972- " '@scope/name@1.0.0':\n " ,
3973- "name" ,
3974- "1.0.0"
3975- ) ) ;
3976- assert ! ( !pnpm_lock_resolves(
3977- " /@scope/name@1.0.0:\n " ,
3978- "name" ,
3979- "1.0.0"
3980- ) ) ;
3981- // Absent version: never resolves.
3982- assert ! ( !pnpm_lock_resolves(
3983- " left-pad@1.3.0:\n " ,
3984- "left-pad" ,
3985- "2.0.0"
3986- ) ) ;
3987- }
39883878 use socket_patch_core:: api:: types:: { PatchFileResponse , VulnerabilityResponse } ;
39893879 use std:: collections:: HashMap ;
39903880
@@ -4963,36 +4853,6 @@ mod tests {
49634853 ) ;
49644854 }
49654855
4966- // --- pnpm_lock_resolves: needle at byte 0 ------------------------------
4967- // The boundary probe reads the char BEFORE the match; a match at the very
4968- // start of the text has none (`None => true`). A regression that indexes
4969- // `text[..pos - 1]` unconditionally would underflow/panic here.
4970-
4971- #[ test]
4972- fn pnpm_lock_resolves_needle_at_start_of_text ( ) {
4973- // pos == 0, plain v9 spelling: no preceding char is a valid boundary.
4974- assert ! ( pnpm_lock_resolves( "left-pad@1.3.0:\n " , "left-pad" , "1.3.0" ) ) ;
4975- // pos == 0, v5/v6 `/name/version` and `/name@version` spellings: the
4976- // leading `/` delimiter itself has nothing before it.
4977- assert ! ( pnpm_lock_resolves(
4978- "/left-pad/1.3.0:\n " ,
4979- "left-pad" ,
4980- "1.3.0"
4981- ) ) ;
4982- assert ! ( pnpm_lock_resolves(
4983- "/left-pad@1.3.0:\n " ,
4984- "left-pad" ,
4985- "1.3.0"
4986- ) ) ;
4987- // Still boundary-checked at the start of text: a scoped tail whose
4988- // name begins mid-token must NOT match.
4989- assert ! ( !pnpm_lock_resolves(
4990- "@scope/left-pad@1.3.0:\n " ,
4991- "left-pad" ,
4992- "1.3.0"
4993- ) ) ;
4994- }
4995-
49964856 // --- write_all_patch_blobs ---------------------------------------------
49974857 // The per-patch fan-out over write_blob_entry: the FIRST bad entry must
49984858 // fail the whole patch (Err(())) and leave nothing outside the blobs
0 commit comments