From 6596621b05b3a7f181462461f98c757c530cbc59 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 18 Aug 2026 19:59:27 -0400 Subject: [PATCH 1/5] fix(scan): parse pnpm v5.4/v6 lock grammars in the lockfile supplement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresh-clone scan of a pnpm 7 or pnpm 8 project discovered ZERO packages: inventory_pnpm_lock_at parsed only the v9 `name@version` key grammar, so v6 keys (`/name@1.2.8:`) produced a leading-slash name dropped fail-closed by is_safe_npm_name, and v5.4 keys (`/name/1.2.8:`) were skipped outright. Confirmed against real pnpm-7/8-emitted locks (2026-08-18 matrix). - split_pnpm_key handles all three grammars: peer-paren suffix trimmed, one leading slash stripped and remembered as legacy; legacy keys may use the v5 name/version form (segment after the last '/', truncated at the first '_' peer/hash suffix, digit-leading, no '@') — correctly parsing v5 peered keys like /styled-components/5.3.3_react@17.0.2; scoped v5 keys parse via the same rule. - The probe-failure fallback that reads a root pnpm-lock.yaml is narrowed to pnpm-specific refusals (vendor_lockfile_version_unsupported, vendor_pnpm_pnp_unsupported): a stale pnpm-lock.yaml left behind by a pnpm->yarn/bun migration is no longer resurrected as the live dependency set. Tests: grammar cases quoted verbatim from real captured 5.4/6.0 locks, plus migration regressions (stale lock behind .pnp.cjs / bun lock is not inventoried; legacy lock alone still is). Co-Authored-By: Claude Fable 5 --- .../src/vendor/lock_inventory.rs | 290 +++++++++++++++++- 1 file changed, 273 insertions(+), 17 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/lock_inventory.rs b/crates/socket-patch-core/src/vendor/lock_inventory.rs index 91dc9da0..c2030829 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory.rs @@ -97,18 +97,46 @@ impl LockfileEntry { } /// Inventory the project's npm-family lockfile. Routes by -/// [`detect_npm_lock_flavor`] (PnP markers, bun.lockb, unsupported lock -/// versions, and a missing lockfile all yield `None`). +/// [`detect_npm_lock_flavor`]; the two PNPM-SPECIFIC probe refusals +/// (legacy lockfileVersion, pnpm node-linker=pnp) fall back to reading a +/// root `pnpm-lock.yaml` directly, and any probe failure falls back to +/// Rush's common lock when `rush.json` is present. All other refusals +/// (yarn-berry PnP markers, bun locks, unrecognizable yarn locks, a +/// missing lockfile) yield `None`. pub(crate) async fn inventory_npm_lock( project_root: &Path, ) -> Option<(NpmLockFlavor, Vec)> { - // Rush monorepos have no root package.json/lock pair; their single - // pnpm source-of-truth lives under common/config/rush/. The flavor - // probe (root-relative) can't see it, so fall back explicitly when the - // root lock is absent but rush.json is present. let (flavor, _warnings) = match detect_npm_lock_flavor(project_root).await { Ok(found) => found, - Err(_) => { + Err((code, _detail)) => { + // The flavor probe passes only pnpm locks the WIRING backend + // supports (lockfileVersion 9.0) and refuses PnP layouts, but + // inventory is read-only discovery — a legacy v5.4/v6.0 (pnpm + // 7/8) or pnpm-PnP-linked lock still names the resolved set, so + // on the probe's two PNPM-SPECIFIC refusals a present root lock + // is read directly rather than leaving fresh clones of such + // projects blind. Only those two codes: on any other refusal + // (yarn-berry PnP marker, bun locks) a root pnpm-lock.yaml is + // stale debris from a pnpm→yarn/bun migration, and inventorying + // it would present dead resolutions as the live dependency set. + // (`vendor_lockfile_version_unsupported` also covers the + // unrecognizable-yarn.lock refusal, but the probe only sniffs + // yarn.lock when no root pnpm-lock.yaml exists, so the direct + // read is a no-op there.) + if matches!( + code, + "vendor_lockfile_version_unsupported" | "vendor_pnpm_pnp_unsupported" + ) { + let pnpm = inventory_pnpm_lock(project_root).await.unwrap_or_default(); + if !pnpm.is_empty() { + return Some((NpmLockFlavor::Pnpm, finalize_npm(pnpm))); + } + } + // Rush monorepos have no root package.json/lock pair; their + // single pnpm source-of-truth lives under common/config/rush/. + // The flavor probe (root-relative) can't see it, so fall back + // explicitly when the root lock is absent but rush.json is + // present. let rush = inventory_rush_pnpm_locks(project_root).await; return (!rush.is_empty()).then(|| (NpmLockFlavor::Pnpm, finalize_npm(rush))); } @@ -393,7 +421,7 @@ async fn inventory_package_lock(root: &Path) -> Option> { Some(out) } -// ─────────────────────────── pnpm-lock.yaml v9 ─────────────────────────── +// ────────────────────────────── pnpm-lock.yaml ────────────────────────────── async fn inventory_pnpm_lock(root: &Path) -> Option> { inventory_pnpm_lock_at(&root.join("pnpm-lock.yaml")).await @@ -410,16 +438,22 @@ async fn inventory_pnpm_lock_at(lock_path: &Path) -> Option> let mut i = start + 1; while let Some(block) = pnpm_lock::next_block(&lines, i, end) { i = block.end; - // Key grammar: `name@version` (name may be `@scope/name`), with - // optional peer-dep suffixes `(peer@1.2.3)…` after the version. - let base = match block.key.find('(') { + // Key grammar by lock generation: v9 `name@version`, v6 (pnpm 8) + // the same behind a leading `/`, v5.4 (pnpm 7) `/name/version` — + // names may be scoped (`@scope/name`) in all three. Peer suffixes: + // v6/v9 append `(peer@1.2.3)…` after the version; v5 appends + // `_peer@x`/`_` to the version itself. + let trimmed = match block.key.find('(') { Some(p) => block.key[..p].trim_end(), None => block.key.as_str(), }; - let Some(at) = base.rfind('@').filter(|&p| p > 0) else { + let (base, legacy) = match trimmed.strip_prefix('/') { + Some(stripped) => (stripped, true), + None => (trimmed, false), + }; + let Some((name, version)) = split_pnpm_key(base, legacy) else { continue; }; - let (name, version) = (&base[..at], &base[at + 1..]); // Only plain registry versions: `file:`/`link:`/`https:`/git specs // are not registry-resolvable. if !version.chars().next().is_some_and(|c| c.is_ascii_digit()) { @@ -454,6 +488,32 @@ async fn inventory_pnpm_lock_at(lock_path: &Path) -> Option> Some(out) } +/// Split a peer-paren-stripped, slash-stripped pnpm packages key into +/// `(name, version)`; `None` is skipped by the caller, never guessed. +/// `legacy` marks a key that carried the v5/v6 leading `/` — only those may +/// use the v5 `name/version` grammar. What tells v5 `/@scope/name/1.2.3` +/// apart from v6 `/@scope/name@1.2.3` is the segment after the last `/`: +/// a v5 version (its `_peer`/`_hash` suffix dropped) starts with a digit +/// and never contains `@`, while a v6 scoped key's trailing segment is +/// `name@version`. v5 non-default-registry keys (`example.com/name/1.2.3`) +/// carry no leading `/` and fall through to the `@` split, where they are +/// dropped fail-closed downstream. +fn split_pnpm_key(base: &str, legacy: bool) -> Option<(&str, &str)> { + if legacy { + if let Some((name, rest)) = base.rsplit_once('/') { + let version = rest.split('_').next().unwrap_or(rest); + if !name.is_empty() + && version.chars().next().is_some_and(|c| c.is_ascii_digit()) + && !version.contains('@') + { + return Some((name, version)); + } + } + } + let at = base.rfind('@').filter(|&p| p > 0)?; + Some((&base[..at], &base[at + 1..])) +} + // ─────────────────────────────── Rush monorepo ─────────────────────────────── /// Inventory a Rush monorepo's pnpm locks. Rush keeps a single @@ -1681,9 +1741,203 @@ snapshots: assert_eq!(entry(&entries, "peer-user").version, "4.0.0"); // registry entries carry no URL in v9 — constructed at fetch time. assert_eq!(entry(&entries, "left-pad").resolved, None); - for absent in ["local-thing", "vendored"] { - assert!(!entries.iter().any(|e| e.name == absent), "{entries:?}"); - } + // Exact set: the legacy v5/v6 grammars must not add or reshape v9 + // entries (local-thing and vendored stay skipped). + assert_eq!( + sorted_pairs(&entries), + vec![ + ("@scope/pkg".into(), "2.0.0".into()), + ("left-pad".into(), "1.3.0".into()), + ("peer-user".into(), "4.0.0".into()), + ] + ); + } + + fn sorted_pairs(entries: &[LockfileEntry]) -> Vec<(String, String)> { + let mut pairs: Vec<(String, String)> = entries + .iter() + .map(|e| (e.name.clone(), e.version.clone())) + .collect(); + pairs.sort(); + pairs + } + + // Real pnpm 7 shapes (lockfileVersion 5.4, captured from a pnpm 7.33.5 + // install: slash-separated `/name/version` keys, no `@` at all), plus + // synthetic keys in the same grammar: scoped, `_peer@x`-suffixed, + // `_`-suffixed, and a non-default-registry key (no leading `/`) + // that must stay out fail-closed. + const PNPM_LOCK_V5: &str = "lockfileVersion: 5.4 + +specifiers: + mkdirp: 0.5.5 + +dependencies: + mkdirp: 0.5.5 + +packages: + + /minimist/1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + dev: false + + /mkdirp/0.5.5: + resolution: {integrity: sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==} + hasBin: true + dependencies: + minimist: 1.2.8 + dev: false + + /@scope/pkg/2.0.0: + resolution: {integrity: sha512-scoped==} + dev: false + + /styled-thing/5.3.3_react@17.0.2: + resolution: {integrity: sha512-peered==} + dev: false + + /hashed-thing/1.0.0_abc123deadbeef: + resolution: {integrity: sha512-hashed==} + dev: false + + example.com/private-pkg/1.0.0: + resolution: {integrity: sha512-registry==} + dev: false +"; + + #[tokio::test] + async fn pnpm_v5_slash_keys_inventory_with_peer_and_hash_suffixes() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V5).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap(); + assert_eq!(flavor, NpmLockFlavor::Pnpm); + assert_eq!( + sorted_pairs(&entries), + vec![ + ("@scope/pkg".into(), "2.0.0".into()), + ("hashed-thing".into(), "1.0.0".into()), + ("minimist".into(), "1.2.8".into()), + ("mkdirp".into(), "0.5.5".into()), + ("styled-thing".into(), "5.3.3".into()), + ] + ); + assert_eq!( + entry(&entries, "minimist").integrity, + LockIntegrity::Sri( + "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==" + .into() + ) + ); + assert_eq!(entry(&entries, "minimist").purl, "pkg:npm/minimist@1.2.8"); + } + + // Real pnpm 8 shapes (lockfileVersion 6.0, captured from a pnpm 8.15.9 + // install: v9's `name@version` behind a leading `/`), plus synthetic + // scoped and peer-parenthesized keys in the same grammar. + const PNPM_LOCK_V6: &str = "lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +dependencies: + mkdirp: + specifier: 0.5.5 + version: 0.5.5 + +packages: + + /minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + dev: false + + /mkdirp@0.5.5: + resolution: {integrity: sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==} + hasBin: true + dependencies: + minimist: 1.2.8 + dev: false + + /@scope/pkg@2.0.0: + resolution: {integrity: sha512-scoped==} + dev: false + + /peer-user@4.0.0(left-pad@1.3.0): + resolution: {integrity: sha512-peer==} + dev: false +"; + + #[tokio::test] + async fn pnpm_v6_leading_slash_keys_inventory_with_peer_parens() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V6).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap(); + assert_eq!(flavor, NpmLockFlavor::Pnpm); + assert_eq!( + sorted_pairs(&entries), + vec![ + ("@scope/pkg".into(), "2.0.0".into()), + ("minimist".into(), "1.2.8".into()), + ("mkdirp".into(), "0.5.5".into()), + ("peer-user".into(), "4.0.0".into()), + ] + ); + assert_eq!( + entry(&entries, "mkdirp").integrity, + LockIntegrity::Sri( + "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==" + .into() + ) + ); + assert_eq!( + entry(&entries, "@scope/pkg").purl, + "pkg:npm/@scope/pkg@2.0.0" + ); + } + + /// A pnpm→yarn-berry migration leaves a stale root pnpm-lock.yaml behind + /// a `.pnp.cjs` loader. The probe's refusal there is a yarn refusal, not + /// a pnpm one — the legacy-lock fallback must NOT inventory the stale + /// lock as the live dependency set. + #[tokio::test] + async fn stale_pnpm_lock_behind_yarn_berry_pnp_marker_is_not_inventoried() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK).await; + write(tmp.path(), ".pnp.cjs", "/* yarn berry PnP loader */").await; + assert!( + inventory_npm_lock(tmp.path()).await.is_none(), + "a stale pnpm-lock.yaml behind a yarn-berry PnP marker must not be inventoried" + ); + } + + /// Same stale-lock hazard for a pnpm→bun migration: bun.lockb refuses + /// with a bun-specific code, so the pnpm fallback must stay out. + #[tokio::test] + async fn stale_pnpm_lock_behind_bun_lockb_is_not_inventoried() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK).await; + write(tmp.path(), "bun.lockb", "\0binary").await; + assert!( + inventory_npm_lock(tmp.path()).await.is_none(), + "a stale pnpm-lock.yaml behind bun.lockb must not be inventoried" + ); + } + + /// pnpm's own `node-linker=pnp` layout (`.pnp.cjs` + pnpm store + lock, + /// no yarn.lock) refuses with the pnpm-specific PnP code — the fallback + /// exists for exactly this project shape and must still read the lock. + #[tokio::test] + async fn pnpm_pnp_layout_still_inventories_root_lock() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK).await; + write(tmp.path(), ".pnp.cjs", "/* pnpm node-linker=pnp loader */").await; + write_nested(tmp.path(), "node_modules/.modules.yaml", "").await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap(); + assert_eq!(flavor, NpmLockFlavor::Pnpm); + assert_eq!(entry(&entries, "left-pad").version, "1.3.0"); } // ── Rush monorepo ─────────────────────────────────────────────────────── @@ -2299,7 +2553,9 @@ source = { editable = "." } write(tmp.path(), "package-lock.json", PACKAGE_LOCK).await; assert!(inventory_npm_lock(tmp.path()).await.is_none()); - // pnpm v6. + // A legacy pnpm lock fails the flavor probe and is read directly by + // the discovery fallback — with no packages section that finds + // nothing, so the result stays None rather than Some(empty). let tmp = tempfile::tempdir().unwrap(); write(tmp.path(), "pnpm-lock.yaml", "lockfileVersion: '6.0'\n").await; assert!(inventory_npm_lock(tmp.path()).await.is_none()); From bdc47f4a3a44170a360bd15f58a2dd5ebcb5b074 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 19 Aug 2026 07:54:14 -0700 Subject: [PATCH 2/5] =?UTF-8?q?feat(pnpm):=20pnpm=207-12=20vendor+hosted?= =?UTF-8?q?=20support=20=E2=80=94=20legacy=20grammars,=20zero-touch=20trus?= =?UTF-8?q?tLockfile,=20takeover=20reconciliation,=20revert=20guards=20(#2?= =?UTF-8?q?13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(pnpm): full pnpm 7-12 vendor+hosted support — legacy lock grammars, zero-touch trustLockfile, takeover reconciliation, revert guards Built and verified against real corepack-pinned pnpm 7.33.5 / 8.15.9 / 9.15.9 / 10.34.5 / 11.22.0 / 12.0.0-rc.7 (plus legacy 1-6 probes) in the 2026-08-18 e2e campaign. HOSTED, pnpm 7/8: the v5.4/v6.0 refusal is replaced by a real rewrite — every instance key of a dep is spliced (v5 /name/ver and _peer-suffixed, v6 (peer)-parenthesized; each owns its resolution), one ledger edit per instance; a post-splice residual detector refuses the dep set-wide if any instance shape the splice regex cannot claim remains (no partial rewrites). Frozen installs from empty stores land patched bytes on both majors; tamper fails ERR_PNPM_TARBALL_INTEGRITY. HOSTED, pnpm 11/12 zero-touch: rewriting a v9 root lock now auto-writes trustLockfile: true into pnpm-workspace.yaml (create with scaffold or byte-preserving append; ledger-recorded as redirect_pnpm_workspace_trust; --no-trust-lockfile-config opt-out; only ErrorKind::NotFound creates — an unreadable existing file falls back to guidance, never overwrite; re-scans heal a missing config on already-redirected locks). pnpm 11.22 and 12-rc frozen installs succeed with no flags and no CI changes; 9/10 ignore the key (verified); the sha512 pin still fails closed under trust. Warnings name the actual spliced host (userinfo stripped) and both per-major error codes, and pre-empt pnpm 12's own rebuild-the-lock advice that silently unpatches. VENDOR, pnpm 7/8: new pnpm-legacy backend (flavor-stamped so older binaries fail closed) — package.json pnpm.overrides + legacy lock surgery emitting exactly what those majors serialize (byte-stable under pnpm's own re-lock). pnpm <= 8 absolutizes file: specifiers, so frozen installs are path-bound: surfaced as vendor_pnpm_legacy_absolute_specifier, with plain `pnpm install --offline` as the moved-checkout path (marker bytes verified). Windows-shaped canonical paths are normalized (verbatim prefix stripped, forward slashes). CONVERSIONS + SAFETY: vendoring over a hosted-redirected npm-family purl now reconciles the redirect ledger (artifact-uuid-anchored matching — version-exact, v5 underscore keys claimed; degraded ledgers keep edits fail-closed), firing vendor_supersedes_redirect exactly once; vendor --revert byte-restores the hosted lock from the wiring originals. All six npm-family vendor backends refuse to delete an artifact the live lock still references when a repair-reconstructed entry has no wiring (vendor_wiring_unknown_revert_blocked — the revert-brick fix); repair stamps detected flavors and preserves corrupt artifacts when no rebuild source exists. Legacy-era diagnostics: shrinkwrap.yaml projects get pnpm-flavored no-lockfile guidance and join the lockfile-only supplement; vendored lock entries get redirect_pnpm_entry_vendored instead of entry-not-found. Tests: e2e_redirect_pnpm_build.rs (new hosted capstone: pnpm 7-11 real corepack legs incl. the zero-touch pnpm 11 proof, tamper negative, hermetic v5/v6 legs), e2e_vendor_pnpm_build.rs ladder (@9/@10/@11 + real pnpm 7/8 lifecycle legs), takeover/reconciliation/guard/heal unit + e2e suites — all RED-verified where behavior changed. CLI_CONTRACT.md and docs/ecosystems.md updated. Stacked on #203 (lock-inventory legacy grammars); trivially overlapping test hunks with #204/#208 carry identical content. Co-Authored-By: Claude Fable 5 * fix(pnpm-legacy): moved-checkout recovery needs --no-frozen-lockfile (pnpm defaults frozen on under CI) CI caught what local runs could not: pnpm turns --frozen-lockfile ON when CI=true, and the pnpm <= 8 moved-checkout recovery works precisely by re-resolving the path-bound absolute specifier — frozen semantics skip that re-resolution (pnpm 8: ERR_PNPM_OUTDATED_LOCKFILE; pnpm 7: stale-path install). The lifecycle legs' recovery step now passes --no-frozen-lockfile explicitly, and the vendor_pnpm_legacy_absolute_ specifier remedy (warning text, module doc, CLI_CONTRACT.md, docs/ecosystems.md) recommends `pnpm install --offline --no-frozen-lockfile` so real CI users get working advice. Verified: the full capstone (10 legs incl. real pnpm 7/8 lifecycles) passes under CI=true locally. Co-Authored-By: Claude Fable 5 * test(pnpm-legacy): lock oracle uses the real path normalizer (Windows byte-exactness) Windows CI proved the production normalizer right and the test oracle wrong: the hermetic splice legs built their expected absolute specifier with raw canonicalize().display() — the \\?\C:\ verbatim form the normalizer exists to strip. normalize_canonical_root is now pub and the oracle consumes it at both assertion sites, so the expected string is built by the same transformation the backend writes and cannot drift. Co-Authored-By: Claude Fable 5 * test(pnpm-legacy): in-file oracles also use the shared root normalizer Windows CI surfaced the same oracle-drift bug in the module's own unit tests: the fixture helper handed raw canonicalize().display() (verbatim \\?\C:\ form) to the ROOT_TOKEN substitution, the no-leak contains probe, and the moved-checkout fixture builder. All three now go through a canon_root_str() helper built on normalize_canonical_root, so every oracle spells the root exactly as the splice writes it. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 12 +- crates/socket-patch-cli/src/args.rs | 17 + .../src/commands/repair_vendor.rs | 329 +- .../src/commands/scan/hosted.rs | 832 ++++- .../socket-patch-cli/src/commands/scan/mod.rs | 161 +- .../socket-patch-cli/src/commands/vendor.rs | 91 +- .../tests/apply_invariants.rs | 126 + .../socket-patch-cli/tests/cli_global_args.rs | 13 +- .../socket-patch-cli/tests/cli_parse_get.rs | 1 + .../tests/cli_parse_repair.rs | 1 + .../socket-patch-cli/tests/cli_parse_scan.rs | 56 +- .../tests/cli_parse_vendor.rs | 1 + .../socket-patch-cli/tests/cli_parse_vex.rs | 4 + .../tests/e2e_redirect_pnpm_build.rs | 1218 ++++++++ .../tests/e2e_vendor_pnpm_build.rs | 618 +++- .../tests/in_process_redirect.rs | 562 +++- .../tests/in_process_redirect_pnpm.rs | 428 ++- .../tests/in_process_vendor.rs | 348 +++ .../tests/remove_rollback_api_overrides.rs | 24 +- .../tests/repair_vendor_e2e.rs | 250 +- .../tests/repair_vendor_flavors_e2e.rs | 130 + .../src/patch/redirect/mod.rs | 1187 +++++++- .../src/patch/redirect/state.rs | 546 +++- .../socket-patch-core/src/vendor/bun_lock.rs | 88 + .../src/vendor/lock_inventory.rs | 180 +- crates/socket-patch-core/src/vendor/mod.rs | 1 + .../src/vendor/npm_flavor.rs | 66 +- .../socket-patch-core/src/vendor/npm_lock.rs | 184 ++ .../socket-patch-core/src/vendor/pnpm_lock.rs | 347 ++- .../src/vendor/pnpm_lock_legacy.rs | 2702 +++++++++++++++++ .../src/vendor/yarn_berry_lock.rs | 86 + .../src/vendor/yarn_classic_lock.rs | 86 + docs/ecosystems.md | 19 +- 33 files changed, 10324 insertions(+), 390 deletions(-) create mode 100644 crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs create mode 100644 crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 30e0e1dd..ed27b79f 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -54,6 +54,7 @@ In v3.0 every subcommand accepts the same set of "global" flags via a single sha | `--lock-timeout` | — | `SOCKET_LOCK_TIMEOUT` | (none) | seconds (u64) | How long to wait for `<.socket>/apply.lock`. Unset and `0` both mean a single non-blocking try; a positive value retries with a 100 ms backoff. Only meaningful on the mutating subcommands | | `--debug` | — | `SOCKET_DEBUG` | `false` | bool | Verbose debug logs to stderr | | `--no-telemetry` | — | `SOCKET_TELEMETRY_DISABLED` | `false` | bool | Disable anonymous usage telemetry | +| `--no-trust-lockfile-config` | — | `SOCKET_NO_TRUST_LOCKFILE_CONFIG` | `false` | bool | Opt out of hosted mode's automatic `trustLockfile: true` write to `pnpm-workspace.yaml` (see the pnpm trust-config note under the scan arguments) | The `--offline` semantics unified in v3.0. Previously `apply` enforced strict airgap, `repair` skipped network ops, and `rollback` failed when blobs were missing. All three now mean the same thing: never contact the network, fail loudly when a required local source is missing. On `repair`, `--offline` and `--download-only` are mutually exclusive (exit 2). `scan` and `get` need remote data for their core function (patch discovery / patch fetch), so `--offline` refuses them up front — exit 1 with an error naming the offline gate (JSON: `status: "error"`), before any crawl, client build, or network contact. This covers `scan --vendor` too: offline vendored staging is `vendor --offline`'s job. @@ -84,6 +85,8 @@ Beyond the globals above, each subcommand defines a small set of local arguments | `repair` | `--download-only` | `SOCKET_DOWNLOAD_ONLY` | Repair-specific cleanup mode (mutually exclusive with `--offline`; combining them is a usage error, exit 2) | | `setup` | `--check`, `--remove` (mutually exclusive); `--exclude` (CSV member paths); honors global `--ecosystems` | `SOCKET_SETUP_EXCLUDE`, `SOCKET_ECOSYSTEMS` | Wire / verify / revert the automatic-patching install hooks. `--exclude` skips + persists workspace members (property 9). See [Setup command contract](#setup-command-contract) | +**pnpm hosted-mode contract (v3.5)**: `scan --mode hosted` rewrites pnpm locks of every major since pnpm 7 — lockfileVersion 5.4, 6.0, and 9.0. Legacy grammars are spliced across **every** instance key of the dep (v5 `/name/ver` + `_peer`-suffixed, v6 `/name@ver(peer)` — each owns its own `resolution:`), one revert-ledger `redirect_pnpm_resolution` edit per instance; a partial rewrite is never possible. When a **9.0 root lock** was rewritten this run, the CLI also ensures `pnpm-workspace.yaml` carries `trustLockfile: true` (created with the root-only `packages:` scaffold, or the single line appended to an existing file with all user bytes preserved) so pnpm ≥ 11's lockfile verification accepts the repointed tarballs with no flags and no CI changes (pnpm ≤ 10 ignores the key; the sha512 tarball pin still fails closed on tampered bytes). The write is recorded in the redirect ledger as a `redirect_pnpm_workspace_trust` edit (`created`/`added`), respects `--dry-run`, is skipped for legacy 5.4/6.0 locks and Rush repos, never overwrites an explicit user `trustLockfile:` value, and is disabled by `--no-trust-lockfile-config` (which restores the manual `--trust-lockfile` / committable-yaml guidance in the `redirect_pnpm_trust_lockfile` warning). Additive no-lockfile diagnostics: `redirect_pnpm_legacy_lockfile` (a pnpm ≤ 2-era `shrinkwrap.yaml` is present) and `redirect_pnpm_no_lockfile` (pnpm markers but no lock) replace the npm-flavored wording on marker-bearing projects; `redirect_pnpm_entry_vendored` names a dep whose lock entry is vendored (`socket-patch vendor --revert` to switch modes) instead of the misleading entry-not-found. **Takeover reconciliation (npm family)**: vendoring over a hosted-redirected purl drops that purl's records + package edits from `redirect-state.json` (the vendor wiring embeds the hosted-spliced fragments as `original`, so `vendor --revert` byte-restores the hosted lock); the `vendor_supersedes_redirect` warning fires exactly once, on the run that reconciles. + `scan --apply` opts JSON callers into the full discover → select → apply pipeline. Without it, `scan --json` stays read-only (discovery + `updates` array only). No effect outside `--json` mode — the non-JSON path always prompts the user interactively. `scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob, diff, and package-archive files from `.socket/`. Off by default (v3.0) so a temporary uninstall doesn't silently destroy manifest state. Only entries whose ecosystem this run actually crawled are eligible: a `pkg:/` with no crawler in this build (a newer CLI's ecosystem in the committed manifest) and the runtime-gated maven/nuget crawlers with their gate off are exempt — the crawl never looked for them, so their absence is not evidence of removal (same fail-safe as the `--ecosystems` filter, which narrows the query but never the prune's installed set). The pass also reconciles vendored state (runs FIRST, under the apply lock — lock contention skips it without failing the scan): vendored entries whose patch is gone from the manifest are reverted, vendored entries whose dependency is no longer in the lockfile graph are reverted AND their manifest entries dropped (detached entries are exempt from both — they are manifest- and lockfile-invisible by design; a missing or undeterminable lockfile keeps the entry, fail-safe), and orphan `.socket/vendor//` dirs with no ledger entry are swept. The JSON `gc` sub-object gains `revertedVendoredEntries` + `removedVendorOrphanDirs` (wet) / `revertableVendoredEntries` + `vendorOrphanDirs` (preview). @@ -124,6 +127,8 @@ Contract details: * **Built from the post-run manifest**, verified against on-disk state (unless `--vex-no-verify`). Generated for real applies, `--dry-run`, and read-only `scan` alike. * **JSON success surface**: `apply` adds a top-level `vex` object to its envelope; `scan` adds a top-level `vex` key to its result. Both carry `{ path, statements, format: "openvex-0.2.0" }`. * `apply`'s no-manifest early exit (the "No .socket folder found" success no-op) does **not** trigger VEX generation — there is nothing to attest. +* **Stale-doc removal (v3.5)**: a run that ends in a VEX error removes a recognizably-OpenVEX file (JSON whose `@context` names openvex.dev) already sitting at the output path — a pipeline reusing one path can never ship yesterday's attestation for a now-unpatched tree. Unrelated files at the path are never touched; a mid-write partial that no longer parses as JSON is left for downstream parsers to reject loudly. +* **Additive warnings (v3.5)**: `product_not_iri` (the `--product`/`--vex-product` override is neither a `pkg:` purl nor an absolute IRI; honored verbatim, warned) and `vendored_tree_out_of_sync` (a healthy vendored attestation stands on the committed artifact + lock wiring while the PRESENT installed tree hash-mismatches the patched bytes — run the package manager's install; the attestation itself is unchanged). Both ride stderr in human mode and `warnings[]` in the standalone `vex --json` envelope. ### VEX provenance markers (contract) @@ -483,7 +488,8 @@ to **six flavors**. | npm (package-lock) | deterministic patched tarball `[@scope/]-.tgz` | `package-lock.json` only (`npm-shrinkwrap.json` wins when present): every entry matching name+version gets `resolved: "file:…"` + recomputed `integrity`. `package.json` untouched | `npm ci` (integrity-verified). Plain `npm install` preserves the entry; `npm update ` re-resolves and drops it | | npm / yarn classic | (same tarball) | `yarn.lock` only: matching blocks get `resolved "file:./…#"` + `integrity` (both checksums recomputed; merged-key & `npm:`-alias blocks covered) | `yarn install --frozen-lockfile --offline` (sha1 fragment + sha512 SRI both enforced; byte-stable lock) | | npm / yarn berry (node-modules linker) | (same tarball) | root `package.json` `resolutions` + `yarn.lock` entry with `checksum: 10c0/` of the berry cache-zip (reproduced from the tarball offline). **PnP is refused** (`.pnp.*` → different artifact pipeline) | `yarn install --immutable --check-cache`, cold cache. Refused if `__metadata.cacheKey ≠ 10c0` or a non-default `compressionLevel` | -| npm / pnpm (lockfileVersion 9) | (same tarball) | root `package.json` `pnpm.overrides` (versioned selector) **+** `pnpm-lock.yaml` surgery (overrides / importer version / packages `resolution.integrity` / snapshots) | `pnpm install --frozen-lockfile --offline`, cold store (integrity-verified; byte-stable on pnpm 9 & 10). lockfileVersion ≠ 9 refused | +| npm / pnpm (lockfileVersion 9) | (same tarball) | root `package.json` `pnpm.overrides` (versioned selector) **+** `pnpm-lock.yaml` surgery (overrides / importer version / packages `resolution.integrity` / snapshots) | `pnpm install --frozen-lockfile --offline`, cold store (integrity-verified; byte-stable on pnpm 9 & 10). Other lockfileVersions: 5.4/6.0 route to the legacy backend below; anything else refused | +| npm / pnpm LEGACY (lockfileVersion 5.4 = pnpm 7, 6.0 = pnpm 8; flavor `pnpm-legacy`) | (same tarball) | root `package.json` `pnpm.overrides` **+** legacy lock surgery (overrides / root dep + specifiers / packages rekey to a bare `file:` key with recomputed integrity / in-package dep refs). **No `pnpm-workspace.yaml` is written** (pnpm ≤ 8 reads overrides only from package.json). The lock's SPECIFIER is machine-ABSOLUTE — pnpm ≤ 8 absolutizes `file:` overrides itself — surfaced as `vendor_pnpm_legacy_absolute_specifier`. Legacy WORKSPACE locks (`importers:`) refused | same-path `pnpm install --frozen-lockfile --offline`, cold store (byte-stable on pnpm 7.33.5 / 8.15.9). A checkout at a DIFFERENT path fails the frozen check (path-bound specifier) and must run `pnpm install --offline --no-frozen-lockfile` once (the flag matters on CI, where pnpm defaults frozen on), which installs the vendored tarball and re-resolves only the specifier line | | npm / bun (`bun.lock`) | (same tarball) | `bun.lock` only: the packages entry's registry 4-tuple → local 3-tuple with recomputed `sha512`. `bun.lockb` (binary) refused with a `--save-text-lockfile` pointer | `bun install --frozen-lockfile`, cold cache (integrity-enforced) | | cargo | crate dir `-/` (no `.cargo-checksum.json`) | `.cargo/config.toml` `[patch.crates-io]` path entry **+** Cargo.lock surgery (the `[[package]]` entry's `source`/`checksum` removed) | `cargo build --locked --offline` on a fresh checkout. Requires cargo ≥ 1.56 (`[patch]` in config files). Note: path deps build **without** `--cap-lints allow` | | golang | module dir `@/` | `go.mod` `replace => ./.socket/vendor/golang//@` | `go build` with `GOPROXY=off` + empty `GOMODCACHE` (directory replaces bypass go.sum entirely; survives `go mod tidy`) | @@ -855,6 +861,8 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `already_vendored` | `skipped` | vendor: artifact + wiring already in sync for this patch uuid. | | `unsafe_coordinates` | `failed` | vendor: purl/uuid would escape `.socket/vendor/` (tampered manifest/state); refused before any write. | | `revert_failed` | `failed` | vendor --revert: a recorded entry could not be reverted. | +| `vendor_wiring_unknown_revert_blocked` | `skipped` (beside the `failed`/`revert_failed` event) | vendor --revert: the ledger entry was reconstructed by `repair` without wiring records and the live lockfile still resolves through the artifact — the revert refuses (fail-closed) instead of deleting a tarball the lock points at. Recovery: `socket-patch repair`, then restore the pre-vendor lock (or re-lock without the override) and re-run the revert. | +| `ecosystem_not_setup` | `skipped` | vex: the patch is applied and byte-verified but its ecosystem has no install hook configured and is not declared in the manifest's `setup.manual`, so it is omitted from the document (Property 7). Previously invisible in `--json`. | | `vendor_multiple_lockfiles` / `pypi_multiple_lockfiles` | `skipped` (warning) | vendor: a sibling lockfile of another package manager will still install UNPATCHED bytes; names the wired winner + the ignored locks. | | `vendor_yarn_berry_unsupported` / `vendor_bun_lockb_unsupported` | `failed` | vendor (npm): yarn-berry PnP / bun binary lockfile — pointer to `yarn patch` / `bun install --save-text-lockfile`. | | `vendor_yarn_berry_cache_unsupported` | `failed` | vendor (yarn berry): lock `cacheKey ≠ 10c0` or non-default `.yarnrc.yml` `compressionLevel` — the cache-zip checksum is not reproducible. | @@ -911,7 +919,7 @@ The remaining commands still emit their pre-v3.0 ad-hoc JSON shapes and will mig - ⏳ `scan` — still emits the discovery + `apply.patches[*]` + `gc.*` shape documented in earlier drafts of this file. - ⏳ `get` — still emits per-patch action arrays. -- ⏳ `rollback` — still emits per-package result records. +- ⏳ `rollback` — still emits per-package result records. Additive (v3.5): a manifest entry with no matching installed package appears in `results[]` as a marker record `{ "purl", "path": null, "skipped": "package_not_installed" }` — no `success`/`error` keys, never counted in `rolledBack`/`failed`, never flips the status or exit code (rollback's job is "make the tree unpatched"; a not-installed package already satisfies that end state, deliberately asymmetric with apply's exit-1-on-unmatched). - ⏳ `setup` — still emits its own `{ status, updated, alreadyConfigured, errors, files }` shape (and the `--check` / `--remove` variants), now documented in full under [Setup command contract](#setup-command-contract). One command is **intentionally not** plain-envelope and will stay that way (not migration debt): diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index 6485d585..dac683d9 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -279,6 +279,21 @@ pub struct GlobalArgs { value_parser = parse_bool_flag, )] pub no_telemetry: bool, + + /// Hosted mode (`scan --mode hosted`): do NOT auto-configure + /// `trustLockfile: true` in pnpm-workspace.yaml after a pnpm-lock.yaml + /// (lockfileVersion >= 9) is repointed at the hosted patch server. + /// pnpm >= 11 rejects the repointed lock without that trust grant, so + /// opting out means every install needs `pnpm install --trust-lockfile` + /// instead (the run's warning spells out both recoveries). Only `scan` + /// reads this; other subcommands accept it silently. + #[arg( + long = "no-trust-lockfile-config", + env = "SOCKET_NO_TRUST_LOCKFILE_CONFIG", + default_value_t = false, + value_parser = parse_bool_flag, + )] + pub no_trust_lockfile_config: bool, } impl GlobalArgs { @@ -361,6 +376,7 @@ pub const GLOBAL_ARG_ENV_VARS: &[&str] = &[ "SOCKET_LOCK_TIMEOUT", "SOCKET_DEBUG", "SOCKET_TELEMETRY_DISABLED", + "SOCKET_NO_TRUST_LOCKFILE_CONFIG", ]; /// Every env var a **subcommand-local** flag binds (one per `env = "..."` @@ -450,6 +466,7 @@ impl Default for GlobalArgs { lock_timeout: None, debug: false, no_telemetry: false, + no_trust_lockfile_config: false, } } } diff --git a/crates/socket-patch-cli/src/commands/repair_vendor.rs b/crates/socket-patch-cli/src/commands/repair_vendor.rs index c4bdcd73..cf104f52 100644 --- a/crates/socket-patch-cli/src/commands/repair_vendor.rs +++ b/crates/socket-patch-cli/src/commands/repair_vendor.rs @@ -15,12 +15,17 @@ //! from the lockfile path itself (the contract's uuid-in-path rule), the //! record from the manifest (or the patch API, yielding a detached entry), //! and a fresh ledger entry is re-synthesized so sweep/GC/revert know the -//! artifact again. WIRING reconstruction is per-ecosystem: gem recognizes +//! artifact again — stamped with the npm lockfile FLAVOR the reference was +//! found in, so a later `vendor --revert` routes to the backend whose +//! unwired-revert guard probes the right lockfile. WIRING reconstruction is +//! per-ecosystem: gem recognizes //! its own Gemfile/lock wiring and rebuilds full revert-capable records //! ([`socket_patch_core::vendor::gem::reconstruct_gem_wiring`]); the other //! ecosystems' pre-vendor originals are registry integrity material no //! offline source can reproduce, so their entries keep empty wiring and the -//! gap is surfaced loudly (`vendor_wiring_unknown`) — a gem `--revert` of +//! gap is surfaced loudly (`vendor_wiring_unknown`, riding the envelope's +//! run-level `warnings[]` — the entry itself repaired fine, so it must not +//! ride `events[]` as a `skipped` consumers count) — a gem `--revert` of //! such an entry refuses instead of stranding the pair edit. Existing gem //! entries with EMPTY wiring (persisted by pre-reconstruction repairs) are //! backfilled the same way during the ledger-driven pass while healthy. @@ -46,7 +51,7 @@ use std::collections::{HashMap, HashSet}; use std::path::Path; -use socket_patch_core::api::client::get_api_client_with_overrides; +use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient}; use socket_patch_core::crawlers::CrawlerOptions; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::copy_tree::remove_tree; @@ -68,7 +73,7 @@ use crate::commands::vendor::{ record_warning, PristineFetch, }; use crate::ecosystem_dispatch::{find_packages_for_purls, partition_purls}; -use crate::json_envelope::{Envelope, PatchAction, PatchEvent}; +use crate::json_envelope::{Envelope, PatchAction, PatchEvent, RunWarning}; /// One broken vendored unit queued for rebuild. struct Candidate { @@ -181,6 +186,69 @@ fn synth_entry(eco: &str, uuid: &str, artifact_path: &str, base_purl: &str) -> V } } +/// The npm lockfile FLAVOR whose lock carries the +/// `.socket/vendor/npm//` reference, for stamping onto a +/// re-synthesized ledger entry. The strings are `VendorEntry::flavor`'s +/// stable vocabulary (guarded by npm_flavor's `flavor_strings_are_stable` +/// test). Stamping matters: `revert_npm_any` routes by flavor, and each +/// backend's unwired-revert guard probes ITS OWN lockfile — a +/// pnpm-reconstructed entry left at flavor-None would be guarded against +/// package-lock.json instead of pnpm-lock.yaml. Locks are checked in the +/// vendor router's own precedence order (bun > pnpm > yarn > npm) for the +/// pathological multi-lock case; content sniffs mirror +/// `detect_npm_lock_flavor` (crate-private to core, so re-derived here). +/// `None` when genuinely unknowable — no recognizable lock carries the +/// reference, or the referencing lock's grammar is unrecognized — which +/// routes to the package-lock backend, whose guard also fails closed on +/// unwired entries. +async fn detect_reference_flavor(project_root: &Path, eco: &str, uuid: &str) -> Option { + if eco != "npm" { + return None; + } + let needle = format!(".socket/vendor/npm/{uuid}/"); + let read = |name: &'static str| async move { + tokio::fs::read_to_string(project_root.join(name)) + .await + .ok() + }; + if read("bun.lock").await.is_some_and(|t| t.contains(&needle)) { + return Some("bun".to_string()); + } + if let Some(text) = read("pnpm-lock.yaml").await { + if text.contains(&needle) { + // Same version allowlist as core's `sniff_lock_grammar`. + return match text + .lines() + .find_map(|l| l.strip_prefix("lockfileVersion:")) + .map(|v| v.trim().trim_matches(['\'', '"'])) + { + Some("9.0") => Some("pnpm".to_string()), + Some("5.4") | Some("6.0") => Some("pnpm-legacy".to_string()), + _ => None, + }; + } + } + if let Some(text) = read("yarn.lock").await { + if text.contains(&needle) { + // Same head sniff as core's `sniff_yarn_lock`; berry wins. + let head: Vec<&str> = text.lines().take(30).collect(); + return if head.iter().any(|l| l.starts_with("__metadata:")) { + Some("yarn-berry".to_string()) + } else if head.iter().any(|l| l.trim() == "# yarn lockfile v1") { + Some("yarn-classic".to_string()) + } else { + None + }; + } + } + for name in ["npm-shrinkwrap.json", "package-lock.json"] { + if read(name).await.is_some_and(|t| t.contains(&needle)) { + return Some("package-lock".to_string()); + } + } + None +} + /// What wiring a re-synthesized ledger entry could recover. enum WiringReconstruction { /// The backend recognized its own wiring in the live project files: @@ -259,6 +327,22 @@ fn soft_restore_without_fingerprint( ); } +/// `vendor_wiring_unknown` advises about what a FUTURE `vendor --revert` +/// can restore — the entry itself was restored/verified fine, so the +/// advisory rides the envelope's run-level `warnings[]` (the documented +/// carrier for non-fatal advisories) rather than a per-purl `skipped` +/// event, which consumers count as work not done. The purl is baked into +/// `detail` by the callers so attribution survives the run-level move. +fn warn_wiring_unknown(env: &mut Envelope, common: &GlobalArgs, detail: String) { + if !common.silent && !common.json { + eprintln!("Warning (vendor_wiring_unknown): {detail}"); + } + env.warnings.push(RunWarning { + code: "vendor_wiring_unknown".to_string(), + detail, + }); +} + /// Best-effort removal of a vendored uuid dir — ahead of a rebuild (corrupt /// bytes must never blend into one) or after a failed post-verify (never /// leave unverifiable bytes behind). @@ -296,6 +380,9 @@ pub(crate) async fn repair_vendored_artifacts( }; // ── Pass 1: ledger-driven health check ─────────────────────────────── + // Shared across both passes so the API client (and its one-time + // token-shape stderr advisory) is constructed at most once per run. + let mut api_client: Option = None; let mut candidates: Vec = Vec::new(); let mut ledger_purls: Vec = state.entries.keys().cloned().collect(); ledger_purls.sort(); @@ -321,23 +408,25 @@ pub(crate) async fn repair_vendored_artifacts( } // Non-detached entry with no manifest at all: recover the // record from the API below, like a reconstruction. - (None, None) => match fetch_record_by_uuid(common, &entry.uuid).await { - Some((_, r)) => r, - None => { - fail( - env, - quiet, - purl, - "vendor_artifact_unrepairable", - format!( - "no manifest record for patch {} and the patch view could not \ + (None, None) => { + match fetch_record_by_uuid(common, &mut api_client, &entry.uuid).await { + Some((_, r)) => r, + None => { + fail( + env, + quiet, + purl, + "vendor_artifact_unrepairable", + format!( + "no manifest record for patch {} and the patch view could not \ be fetched (offline or API failure)", - entry.uuid - ), - ); - continue; + entry.uuid + ), + ); + continue; + } } - }, + } }; if record.uuid != entry.uuid { env.record( @@ -422,19 +511,16 @@ pub(crate) async fn repair_vendored_artifacts( rebuilt += 1; } Err(detail) => { - record_warning( + warn_wiring_unknown( env, - purl, - &VendorWarning::new( - "vendor_wiring_unknown", - format!( - "the ledger entry records no pre-vendor wiring \ - originals and they cannot be reconstructed from \ - the live files ({detail}); `vendor --revert` \ - cannot restore the project files for this entry" - ), - ), common, + format!( + "the ledger entry for {} records no pre-vendor wiring \ + originals and they cannot be reconstructed from the \ + live files ({detail}); `vendor --revert` cannot \ + restore the project files for this entry", + normalize_purl(purl) + ), ); } } @@ -492,7 +578,7 @@ pub(crate) async fn repair_vendored_artifacts( let (purl, record, detached) = match manifest.and_then(|m| m.patches.iter().find(|(_, r)| r.uuid == uuid)) { Some((p, r)) => (p.clone(), r.clone(), false), - None => match fetch_record_by_uuid(common, &uuid).await { + None => match fetch_record_by_uuid(common, &mut api_client, &uuid).await { Some((purl, r)) => (purl, r, true), None => { fail( @@ -512,6 +598,11 @@ pub(crate) async fn repair_vendored_artifacts( }, }; let mut entry = synth_entry(&eco, &uuid, &relpath, strip_purl_qualifiers(&purl)); + // Stamp the flavor the reference was found in (knowable right here: + // the scan above read specific lockfiles), so `vendor --revert` + // routes to the backend whose unwired-revert guard probes the RIGHT + // lockfile. Genuinely unknowable stays None (guarded fallback). + entry.flavor = detect_reference_flavor(&common.cwd, &eco, &uuid).await; entry.detached = detached; if detached { entry.record = Some(record.clone()); @@ -528,18 +619,15 @@ pub(crate) async fn repair_vendored_artifacts( } } WiringReconstruction::Unknown(detail) => { - record_warning( + warn_wiring_unknown( env, - &purl, - &VendorWarning::new( - "vendor_wiring_unknown", - format!( - "the ledger entry was reconstructed without pre-vendor wiring \ - originals ({detail}); `vendor --revert` cannot restore the \ - project files for this entry" - ), - ), common, + format!( + "the ledger entry for {} was reconstructed without pre-vendor \ + wiring originals ({detail}); `vendor --revert` cannot restore \ + the project files for this entry", + normalize_purl(&purl) + ), ); } } @@ -716,15 +804,14 @@ pub(crate) async fn repair_vendored_artifacts( } } - // ── Corrupt artifacts are deleted first ────────────────────────────── - // The backends' wired hot paths rebuild on MISSING; turning corrupt - // into missing gives every ecosystem one uniform rebuild trigger (and - // never leaves tampered bytes to be blended into a rebuild). - for c in &candidates { - if c.reason == "vendor_artifact_corrupt" { - remove_vendor_dir(&common.cwd, &c.entry.ecosystem, &c.entry.uuid).await; - } - } + // NOTE: corrupt artifacts are NOT deleted here. Deletion waits until + // the rebuild loop below, where the patch sources and a pristine + // package source are both in hand — see the comment there. Destroying + // the corrupt copy before the rebuild-source ladder runs would, on any + // no-source outcome (--offline, node_modules gone, fetch failure), + // convert a corrupt-but-diagnosable integrity-mismatch state into a + // bare ENOENT on the next install (the lock still points at the + // artifact) and erase the forensic evidence of the tamper. // ── Patch content (in memory, like all vendor flows) ──────────────── let records_map: HashMap = candidates @@ -947,13 +1034,19 @@ pub(crate) async fn repair_vendored_artifacts( let Some(pkg_path) = all_packages.get(&c.purl).cloned() else { continue; // failed above }; - if c.soft { - // The healthy-by-members live tree is exactly what cannot be - // trusted; with a pristine source secured, clear it so the - // backend's wired hot path materialises a fresh copy — the - // fingerprint below then derives from the member-verified - // rebuild, never the live bytes. (Deleted only now, after the - // patch sources and the pristine source are both in hand.) + // Clear the live uuid dir only NOW — the patch sources and the + // pristine source are both in hand, so a rebuild WILL replace it. + // The backends' wired hot paths rebuild on MISSING (one uniform + // trigger for every ecosystem), and the live bytes must never + // blend into the rebuild: + // - corrupt: the recorded fingerprint already condemned them; + // - soft: the healthy-by-members live tree is exactly what cannot + // be trusted — the fingerprint below derives from the + // member-verified rebuild, never the live bytes. + // Deleting any earlier destroys evidence: with no rebuild source + // the corrupt copy is all a human has left to diagnose (and the + // lock still points at it — see the NOTE above the staging step). + if c.soft || c.reason == "vendor_artifact_corrupt" { remove_vendor_dir(&common.cwd, &c.entry.ecosystem, &c.entry.uuid).await; } // For an unverified-source rebuild the rewired lockfile is the trust @@ -1210,12 +1303,29 @@ async fn fill_artifact_fingerprint(project_root: &Path, entry: &mut VendorEntry) } /// Fetch one patch view by uuid (proxy-aware) and shape it as a manifest -/// record; `None` offline or on any API failure. -async fn fetch_record_by_uuid(common: &GlobalArgs, uuid: &str) -> Option<(String, PatchRecord)> { +/// record; `None` offline or on any API failure. `client_cache` holds the +/// one API client the whole vendored-artifact phase shares — construction +/// re-prints the token-shape stderr advisory, so N uuid lookups must not +/// print it N times. Built lazily: a run with nothing to look up never +/// constructs (or warns) at all. +async fn fetch_record_by_uuid( + common: &GlobalArgs, + client_cache: &mut Option, + uuid: &str, +) -> Option<(String, PatchRecord)> { if common.offline { return None; } - let (client, _) = get_api_client_with_overrides(common.api_client_overrides()).await; + if client_cache.is_none() { + *client_cache = Some( + get_api_client_with_overrides(common.api_client_overrides()) + .await + .0, + ); + } + let client = client_cache + .as_ref() + .expect("client_cache was just initialized above"); let patch = client .fetch_patch(common.org.as_deref(), uuid) .await @@ -1284,6 +1394,105 @@ mod tests { ); } + /// The reconstruction stamps [`VendorEntry::flavor`] from whichever + /// lockfile carries the vendored reference, so `vendor --revert` routes + /// to the backend whose unwired-revert guard probes the RIGHT lockfile. + /// The strings must stay npm_flavor's stable vocabulary; unknowable + /// shapes stay `None` (the guarded package-lock fallback route). + #[tokio::test] + async fn detect_reference_flavor_maps_referencing_lock_to_stable_flavor() { + let uuid = "11111111-1111-4111-8111-111111111111"; + let mention = format!("resolved: file:.socket/vendor/npm/{uuid}/left-pad-1.3.0.tgz\n"); + let case = |files: Vec<(&'static str, String)>, want: Option<&'static str>| async move { + let tmp = tempfile::tempdir().unwrap(); + for (name, text) in &files { + tokio::fs::write(tmp.path().join(name), text).await.unwrap(); + } + assert_eq!( + detect_reference_flavor(tmp.path(), "npm", uuid).await, + want.map(str::to_string), + "files: {:?}", + files.iter().map(|(n, _)| n).collect::>() + ); + }; + + // Each flavor's lock, referenced → its stable string. + case( + vec![("package-lock.json", mention.clone())], + Some("package-lock"), + ) + .await; + case( + vec![("npm-shrinkwrap.json", mention.clone())], + Some("package-lock"), + ) + .await; + case( + vec![( + "pnpm-lock.yaml", + format!("lockfileVersion: '9.0'\n{mention}"), + )], + Some("pnpm"), + ) + .await; + case( + vec![("pnpm-lock.yaml", format!("lockfileVersion: 5.4\n{mention}"))], + Some("pnpm-legacy"), + ) + .await; + case( + vec![( + "pnpm-lock.yaml", + format!("lockfileVersion: '6.0'\n{mention}"), + )], + Some("pnpm-legacy"), + ) + .await; + case( + vec![("yarn.lock", format!("# yarn lockfile v1\n{mention}"))], + Some("yarn-classic"), + ) + .await; + case( + vec![("yarn.lock", format!("__metadata:\n version: 8\n{mention}"))], + Some("yarn-berry"), + ) + .await; + case(vec![("bun.lock", mention.clone())], Some("bun")).await; + + // Unknowable stays None: unrecognized grammars, or no referencing + // lock at all (an unreferenced lock must not claim the entry). + case( + vec![("pnpm-lock.yaml", format!("lockfileVersion: 5.3\n{mention}"))], + None, + ) + .await; + case(vec![("yarn.lock", mention.clone())], None).await; + case( + vec![("package-lock.json", "no reference here".to_string())], + None, + ) + .await; + case(vec![], None).await; + + // The referencing lock wins over an unreferenced sibling. + case( + vec![ + ("package-lock.json", "no reference here".to_string()), + ("yarn.lock", format!("# yarn lockfile v1\n{mention}")), + ], + Some("yarn-classic"), + ) + .await; + + // Non-npm ecosystems never carry an npm flavor. + let tmp = tempfile::tempdir().unwrap(); + tokio::fs::write(tmp.path().join("package-lock.json"), &mention) + .await + .unwrap(); + assert_eq!(detect_reference_flavor(tmp.path(), "gem", uuid).await, None); + } + /// `base_purl` is stored VERBATIM percent-encoded (`pkg:npm/%40scope/…`, /// manifest/ledger key parity — see npm_common's coordinate tests), but /// the registry fetch and the berry cache-checksum recipe both need the diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index a3b24ef8..df9a114a 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -15,6 +15,14 @@ const REDIRECT_CANDIDATE_FILES: &[&str] = &[ "package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", + // pnpm-family MARKERS, never rewritten: `shrinkwrap.yaml` is the + // pnpm <=2-era lock (npm never emits that filename) and + // `node_modules/.modules.yaml` is pnpm's installer state file. The npm + // rewriter's no-lockfile diagnostic keys its family wording off their + // presence — without them a pnpm 1/2 project gets told "no + // package-lock.json present", npm advice that dead-ends. + "shrinkwrap.yaml", + "node_modules/.modules.yaml", "yarn.lock", // A berry lock's cache-config gate reads `.yarnrc.yml`; bun's text lock is // `bun.lock` (its binary `bun.lockb` is auto-migrated in `run_redirect`). @@ -81,6 +89,248 @@ fn parse_purl_simple(purl: &str) -> Option<(String, String, String)> { Some((typ.to_string(), name, version)) } +/// `scheme://[user[:pass]@]host[:port]/…` → `host[:port]`, NEVER userinfo. +/// For user-facing messages that name where a lockfile now points — the +/// hosted artifact host follows `--api-url`, so hardcoding `patch.socket.dev` +/// would misname it in custom-server environments. The port is kept (it is +/// part of the authority the lock records); credentials are stripped: a +/// credentialed artifact URL (`https://user:secret@host/…`) must never leak +/// `user:secret` into the warning text or the persisted `--json` envelope — +/// both land in CI logs. Split by hand because this crate has no URL-parser +/// dependency (reqwest is dev-only here); per RFC 3986 a raw `@` in the +/// authority can ONLY be the userinfo terminator (it is percent-encoded +/// everywhere else), so the tail after the LAST `@` is exactly host[:port]. +fn url_host(url: &str) -> Option<&str> { + let rest = url.split_once("://").map_or(url, |(_, r)| r); + let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest); + let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h); + (!host.is_empty()).then_some(host) +} + +/// Repo-relative path of the pnpm workspace manifest the trustLockfile +/// auto-config edits (the same file the vendor backend's override surface +/// uses). +const PNPM_WORKSPACE_REL: &str = "pnpm-workspace.yaml"; + +/// `FileEdit.kind` recorded when the hosted flow ensures `trustLockfile: +/// true` in pnpm-workspace.yaml. `action: "created"` — the workspace file +/// itself was created (a revert deletes it); `action: "added"` — the single +/// `trustLockfile: true` line was appended to an existing file (a revert +/// removes exactly that line). Additive ledger vocabulary: older ledgers +/// without it load unchanged (kind is an opaque string to the loader). +const REDIRECT_PNPM_WORKSPACE_TRUST_EDIT_KIND: &str = "redirect_pnpm_workspace_trust"; + +/// The honest-tradeoff + don't-rebuild tail shared by every trustLockfile +/// warning variant. The tradeoff sentence is a security disclosure, not +/// prose garnish: `trustLockfile: true` disables pnpm's lockfile +/// re-verification for the WHOLE lock, so it must be stated wherever the +/// setting is written or recommended. +const PNPM_TRUST_TRADEOFF_AND_CAUTION: &str = + "Note: trustLockfile makes pnpm skip its lockfile re-verification \ + (minimumReleaseAge / trustPolicy re-checks) for ALL lockfile entries, \ + not just the patched ones — the per-entry sha512 integrity pins are \ + still enforced. Do NOT follow pnpm's advice to rebuild the lockfile \ + (`pnpm clean --lockfile`): that silently discards the redirect and \ + reinstalls the vulnerable upstream artifact. pnpm <=10 ignores the \ + setting and installs work unchanged"; + +/// The policy preamble shared by every trustLockfile warning variant: +/// what was repointed, and how pnpm >=11 fails without trust. +fn pnpm_trust_policy_preamble(server: &str) -> String { + format!( + "pnpm-lock.yaml was repointed at {server}; pnpm >=11 rejects the \ + rewritten lock (pnpm 11: ERR_PNPM_TARBALL_URL_MISMATCH, pnpm 12: \ + ERR_PNPM_LOCKFILE_RESOLUTION_VERIFICATION)" + ) +} + +/// The pre-auto-config guidance, kept verbatim for the runs where the +/// auto-config does not apply (legacy 5.x/6.0 locks, Rush nested locks, +/// `--no-trust-lockfile-config`): both verified recoveries, spelled exactly. +fn pnpm_trust_manual_guidance(server: &str) -> String { + format!( + "{}. Install with `pnpm install --trust-lockfile`, or commit \ + `trustLockfile: true` in pnpm-workspace.yaml so every install \ + accepts the patched artifacts. Do NOT follow pnpm's advice to \ + rebuild the lockfile (`pnpm clean --lockfile`): that silently \ + discards the redirect and reinstalls the vulnerable upstream \ + artifact. pnpm <=10 installs work unchanged", + pnpm_trust_policy_preamble(server), + ) +} + +/// The LEGACY-lock variant (lockfileVersion 5.x/6.0 — pnpm 7/8): those +/// majors have neither the pnpm >=11 lockfile trust policy nor any trust +/// flag or setting, so installs consume the redirected lock unchanged and +/// no trust step exists or is needed. Deliberately NEVER mentions +/// `pnpm install --trust-lockfile`: pnpm 7/8 reject the flag as an unknown +/// option, so headlining it here would hand users a command that errors. +fn pnpm_trust_legacy_detail(server: &str) -> String { + format!( + "pnpm-lock.yaml was repointed at {server}. This is a legacy \ + (lockfileVersion 5.x/6.0) lock read by pnpm 7/8, which have no \ + lockfile trust policy: installs work unchanged on pnpm 7/8 and no \ + trust step exists or is needed. Do NOT regenerate the lockfile \ + (deleting it, or re-resolving on a newer pnpm): that silently \ + discards the redirect and reinstalls the vulnerable upstream \ + artifact. If the project later moves to pnpm >=9, re-run \ + `socket-patch scan --mode hosted` so the regenerated lock is \ + redirected (and trust-configured) again" + ) +} + +/// The unreadable-workspace fallback: pnpm-workspace.yaml EXISTS but could +/// not be read (permissions, invalid UTF-8, I/O error). Planning a Create +/// here would OVERWRITE the user's file with the root-only scaffold — +/// destroying their `packages:` globs — so the auto-config stands down and +/// the warning names the file, the error, and both manual recoveries. +fn pnpm_trust_workspace_unreadable_detail(server: &str, err: &std::io::Error) -> String { + format!( + "{}. {PNPM_WORKSPACE_REL} exists but could not be read ({err}); it \ + was left untouched — auto-configuring trust would risk overwriting \ + it. Fix the file, then install with `pnpm install --trust-lockfile` \ + or add `trustLockfile: true` to it yourself so every install \ + accepts the patched artifacts. Do NOT follow pnpm's advice to \ + rebuild the lockfile (`pnpm clean --lockfile`): that silently \ + discards the redirect and reinstalls the vulnerable upstream \ + artifact. pnpm <=10 installs work unchanged", + pnpm_trust_policy_preamble(server), + ) +} + +/// The pnpm-workspace.yaml read, classified for the trust auto-config: +/// `Ok(Some(text))` — read fine; `Ok(None)` — ABSENT (`ErrorKind::NotFound`, +/// the only state where planning a Create is safe); `Err(e)` — present but +/// unreadable, so the caller must fall back to warning-only guidance. It +/// was: a bare `.ok()` collapsed EVERY read error to `None`, so a +/// present-but-unreadable workspace file was planned as a Create and +/// OVERWRITTEN with the root-only scaffold, destroying the user's +/// `packages:` globs. +fn read_workspace_for_trust(path: &std::path::Path) -> std::io::Result> { + match std::fs::read_to_string(path) { + Ok(text) => Ok(Some(text)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e), + } +} + +/// HEAL-ON-RERUN probe: does this (unspliced) root pnpm-lock.yaml already +/// carry a granted hosted artifact URL from an EARLIER run? Same spelling +/// set as the confirmation probe (raw / `\/`-escaped via +/// `artifact_url_present`, plus the percent-encoded form) so a writer's +/// spelling can never be one this probe misses. Lets an idempotent re-scan +/// plan the trust config for a project that missed it once (opted-out first +/// run, or a crash between the lock write and the workspace write) — the +/// splice-only trigger skipped both forever on such projects. +fn pnpm_lock_carries_hosted_redirect( + lock_text: &str, + overrides: &[socket_patch_core::patch::redirect::DepOverride], +) -> bool { + overrides.iter().filter(|o| o.ecosystem == "npm").any(|o| { + let encoded = socket_patch_core::utils::uri::encode_uri_component(&o.artifact_url); + socket_patch_core::patch::redirect::artifact_url_present(lock_text, &o.artifact_url) + || lock_text.contains(encoded.as_str()) + }) +} + +/// The HEAL-ON-RERUN gate: when this run spliced no root pnpm-lock.yaml +/// (`root_spliced` false) but the on-disk root lock is v9 and already +/// carries a granted hosted artifact URL, return its text so the trust +/// block engages anyway. Legacy (<9) and unparseable-version locks stay +/// `None` (fail closed: never write config for a lock era we can't read), +/// as does a root lock this run DID splice (the splice path covers it). +fn pnpm_heal_root<'a>( + root_spliced: bool, + disk_root: Option<&'a String>, + overrides: &[socket_patch_core::patch::redirect::DepOverride], +) -> Option<&'a String> { + if root_spliced { + return None; + } + disk_root.filter(|text| { + pnpm_lock_version_major(text).is_some_and(|major| major >= 9) + && pnpm_lock_carries_hosted_redirect(text, overrides) + }) +} + +/// The auto-config variant: trust was (or, on `--dry-run`, would be) +/// configured in pnpm-workspace.yaml, so installs need no flags. +fn pnpm_trust_configured_detail(server: &str, created: bool, dry_run: bool) -> String { + let how = match (created, dry_run) { + (true, false) => "`trustLockfile: true` was written to a new", + (false, false) => "`trustLockfile: true` was merged into the existing", + (true, true) => "`trustLockfile: true` would be written to a new (--dry-run)", + (false, true) => "`trustLockfile: true` would be merged into the existing (--dry-run)", + }; + format!( + "{}, so {how} {PNPM_WORKSPACE_REL} — commit it alongside the lock; \ + installs need no extra flags. {PNPM_TRUST_TRADEOFF_AND_CAUTION}", + pnpm_trust_policy_preamble(server), + ) +} + +/// `lockfileVersion` major sniffed from a pnpm-lock.yaml head. pnpm 9-12 +/// emit `lockfileVersion: '9.0'` (single doc, first line — verified against +/// real 7/8/9/10/11/12-rc locks in the 2026-08-18 matrix); pnpm 8 emits +/// `'6.0'`, pnpm 7 an unquoted `5.4`. `None` when no parseable version line +/// exists — callers treat that as "not trust-policy era" and stay +/// hands-off (fail closed: never write config for a lock we can't read). +fn pnpm_lock_version_major(lock_text: &str) -> Option { + lock_text.lines().find_map(|line| { + let rest = line.strip_prefix("lockfileVersion:")?; + let value = rest.trim().trim_matches(|c| c == '\'' || c == '"'); + value.split('.').next()?.parse::().ok() + }) +} + +/// The planned pnpm-workspace.yaml `trustLockfile: true` edit. +enum TrustPlan { + /// No workspace file: create it (root-only `packages` scaffold — pnpm 9 + /// refuses a workspace file with no `packages` field — plus the trust + /// key; the same scaffold shape the vendor backend creates). + Create(String), + /// Workspace file exists without a `trustLockfile:` key: append exactly + /// one line after the last non-empty line, every other byte preserved. + Append(String), + /// Already `trustLockfile: true` — nothing to write. + AlreadyTrue, + /// The user explicitly set `trustLockfile: ` (non-true). Their + /// call is respected — flipping an explicit security setting behind the + /// user's back is worse than a failing install with a clear warning. + UserSet(String), +} + +/// Decide how to ensure `trustLockfile: true` in pnpm-workspace.yaml. +/// Line splices only (never a YAML library), mirroring the vendor backend's +/// workspace surgery: untouched lines stay byte-identical, so a revert can +/// remove exactly what was added. +fn plan_workspace_trust(existing: Option<&str>) -> TrustPlan { + let Some(text) = existing else { + return TrustPlan::Create("packages:\n - '.'\ntrustLockfile: true\n".to_string()); + }; + // Top-level key only: an indented `trustLockfile:` under some other + // mapping is not the setting pnpm reads. + for line in text.split('\n') { + if let Some(rest) = line.strip_prefix("trustLockfile:") { + let value = rest.trim().trim_matches(|c| c == '\'' || c == '"'); + if value == "true" { + return TrustPlan::AlreadyTrue; + } + return TrustPlan::UserSet(value.to_string()); + } + } + let mut lines: Vec = text.split('\n').map(str::to_string).collect(); + // After the last non-empty line (no blank separator): a revert removes + // exactly one line and the file's trailing bytes stay put. + let anchor = lines + .iter() + .rposition(|l| !l.trim().is_empty()) + .map(|i| i + 1) + .unwrap_or(lines.len()); + lines.insert(anchor, "trustLockfile: true".to_string()); + TrustPlan::Append(lines.join("\n")) +} + /// The hosted-mode JSON error envelope, for bail-outs that return before the /// success envelope at the bottom of [`run_redirect`] is built. When the /// classic scan object (`scan_result`, threaded in from `run`) is present it @@ -588,8 +838,10 @@ pub(super) async fn run_redirect( } } - let rewrite = rewrite_registry_redirect(&files, &overrides); - let rewritten: Vec = rewrite.files.keys().cloned().collect(); + // `mut`: the pnpm trustLockfile auto-config below may fold a + // pnpm-workspace.yaml write (plus its ledger edit) into the rewrite set so + // it rides the same atomic-write / ledger-first machinery as the locks. + let mut rewrite = rewrite_registry_redirect(&files, &overrides); // The lockb→text migration is only KEPT when the rewrite actually landed // in the migrated bun.lock. Otherwise nothing was redirected there and the @@ -650,30 +902,196 @@ pub(super) async fn run_redirect( // pnpm >=11 enforces a lockfile supply-chain policy: it compares each // resolution's tarball URL against the registry's published metadata and - // REFUSES the lock when they differ - // (`ERR_PNPM_TARBALL_URL_MISMATCH … has a tarball URL (https://patch.socket.dev/…) - // that does not match the registry's published metadata`). The hosted - // rewrite deliberately repoints tarball URLs at patch.socket.dev, so a - // pnpm >=11 install rejects the rewritten lock until the user opts in with - // `pnpm install --trust-lockfile` (which installs the patched artifact - // cleanly). Warn whenever the rewrite actually landed in ANY pnpm-lock.yaml - // — the plain root lock or a Rush nested/subspace lock (basename check). + // REFUSES a lock whose URLs differ. The failure spelling changed across + // majors (both observed against real installs): pnpm 11 fails with + // ERR_PNPM_TARBALL_URL_MISMATCH (ERR_PNPM_META_FETCH_FAIL when the + // registry is unreachable); pnpm 12 fails with + // ERR_PNPM_LOCKFILE_RESOLUTION_VERIFICATION, and its OWN error text tells + // users to rebuild the lock (`pnpm clean --lockfile` + install) — which + // silently discards the redirect and reinstalls the vulnerable upstream, + // so the warning must pre-empt that advice. The recoveries verified on + // both majors are the per-run `pnpm install --trust-lockfile` flag and + // the committable pnpm-workspace.yaml `trustLockfile: true` key; the + // `.npmrc` `trust-lockfile=true` spelling is IGNORED by pnpm and must + // never be recommended. + // + // ZERO-TOUCH DEFAULT: when this run rewrote the ROOT pnpm-lock.yaml and + // its lockfileVersion is >= 9 (pnpm 9-12 emit '9.0'; 5.x/6.0 locks mean + // pnpm 7/8, which have neither the policy nor the flag — those legacy + // locks get their own installs-work-unchanged guidance instead, never + // the `--trust-lockfile` headline pnpm 7/8 reject as an unknown option), + // the run auto-ensures `trustLockfile: true` in pnpm-workspace.yaml so + // CI needs no modification and installs need no flags. The same + // auto-config re-engages on a run that spliced NOTHING when the root v9 + // lock already carries a granted hosted artifact URL (see HEAL-ON-RERUN + // below), so a missed config is healed by re-running the scan. Verified against real installs (2026-08-18 matrix + + // tolerance spikes): pnpm 9.15.9 / 10.34.5 silently ignore the key + // (frozen installs stay green), pnpm 11.22.0 / 12.0.0-rc.7 accept the + // redirected lock with it, and the per-entry sha512 integrity pin still + // fails closed on tampered bytes. An explicit user `trustLockfile: + // ` is RESPECTED (never flipped — the warning explains the + // manual recoveries instead), and `--no-trust-lockfile-config` opts out + // entirely. Rush nested/subspace locks are excluded: rush runs pnpm in + // common/temp, which never reads the repo-root pnpm-workspace.yaml, so a + // root write would be config theater — those runs keep the manual + // guidance. The warning names the host(s) the lock now points at: the + // hosted artifact host follows --api-url, so it is not always + // patch.socket.dev. let mut pnpm_warnings: Vec = Vec::new(); - if rewrite.files.keys().any(|key| { - std::path::Path::new(key) - .file_name() - .and_then(|n| n.to_str()) - == Some("pnpm-lock.yaml") - }) { - pnpm_warnings.push(serde_json::json!({ - "code": "redirect_pnpm_trust_lockfile", - "detail": - "pnpm-lock.yaml was repointed at patch.socket.dev; pnpm >=11 rejects \ - the rewritten lock with ERR_PNPM_TARBALL_URL_MISMATCH (its tarball \ - URL no longer matches the registry's published metadata). Install \ - with `pnpm install --trust-lockfile` to accept the patched artifacts", - })); + // The pnpm-workspace.yaml content + ledger edit this run will fold into + // the rewrite set (decided inside the borrow scope, applied after it). + let mut trust_config_write: Option<(String, socket_patch_core::patch::redirect::FileEdit)> = + None; + { + // pnpm locks spliced THIS run (any depth — the rewriter is + // basename-generalized). + let mut pnpm_lock_texts: Vec<&String> = rewrite + .files + .iter() + .filter(|(key, _)| { + std::path::Path::new(key) + .file_name() + .and_then(|n| n.to_str()) + == Some("pnpm-lock.yaml") + }) + .map(|(_, content)| content) + .collect(); + // HEAL-ON-RERUN: a root v9 lock that ALREADY carries a granted hosted + // artifact URL (spliced by an earlier run) still plans the trust + // config even though this run spliced nothing — so a project that + // missed the config once (opted-out first run, or a crash between the + // lock write and the workspace write) is healed by simply re-running + // the scan. Without this, the idempotent no-op re-scan skipped both + // the config and the warning forever. An AlreadyTrue workspace keeps + // the re-run a byte-stable no-op. + let heal_root: Option<&String> = pnpm_heal_root( + rewrite.files.contains_key("pnpm-lock.yaml"), + files.get("pnpm-lock.yaml"), + &overrides, + ); + if let Some(text) = heal_root { + pnpm_lock_texts.push(text); + } + if !pnpm_lock_texts.is_empty() { + // Name only the hosts whose artifact URL actually landed in a + // touched pnpm lock's final text (spliced this run, or the + // already-redirected heal root): an npm override may have matched + // only a sibling lock (e.g. package-lock.json), and naming its host + // here would point users at a server the pnpm lock never references. + // Same presence predicate as the confirmation probe below (raw / + // `\/`-escaped via artifact_url_present, plus the percent-encoded + // spelling) so a writer's spelling can never be one this filter + // misses. + let mut hosts: Vec<&str> = overrides + .iter() + .filter(|o| o.ecosystem == "npm") + .filter(|o| { + let encoded = + socket_patch_core::utils::uri::encode_uri_component(&o.artifact_url); + pnpm_lock_texts.iter().any(|text| { + socket_patch_core::patch::redirect::artifact_url_present( + text, + &o.artifact_url, + ) || text.contains(encoded.as_str()) + }) + }) + .filter_map(|o| url_host(&o.artifact_url)) + .collect(); + hosts.sort_unstable(); + hosts.dedup(); + let server = if hosts.is_empty() { + "the hosted patch server".to_string() + } else { + format!("the hosted patch server ({})", hosts.join(", ")) + }; + // Root-lock gate (see the block comment above): only the plain + // project lock at lockfileVersion >= 9 gets the auto-config — + // spliced this run, or detected already-redirected (heal path). + let root_lock_v9 = heal_root.is_some() + || rewrite + .files + .get("pnpm-lock.yaml") + .and_then(|text| pnpm_lock_version_major(text)) + .is_some_and(|major| major >= 9); + // Every touched pnpm lock is a KNOWN legacy (5.x/6.0) format — + // pnpm 7/8 territory, where neither the trust policy nor the + // `--trust-lockfile` flag exists (the flag is rejected as an + // unknown option), so the manual guidance's headline would hand + // users a command that errors. An unparseable version stays on + // the manual guidance: never claim "no trust step needed" for a + // lock whose era is unknown. + let all_locks_legacy = pnpm_lock_texts + .iter() + .all(|text| pnpm_lock_version_major(text).is_some_and(|major| major < 9)); + let detail = if all_locks_legacy { + pnpm_trust_legacy_detail(&server) + } else if !root_lock_v9 || args.common.no_trust_lockfile_config { + pnpm_trust_manual_guidance(&server) + } else { + match read_workspace_for_trust(&args.common.cwd.join(PNPM_WORKSPACE_REL)) { + // Present but UNREADABLE: never plan a Create (it would + // overwrite the user's workspace file) — fall back to + // warning-only guidance naming the file and the error. + Err(e) => pnpm_trust_workspace_unreadable_detail(&server, &e), + Ok(ws_existing) => match plan_workspace_trust(ws_existing.as_deref()) { + TrustPlan::Create(text) => { + trust_config_write = Some(( + text, + socket_patch_core::patch::redirect::FileEdit { + path: PNPM_WORKSPACE_REL.into(), + kind: REDIRECT_PNPM_WORKSPACE_TRUST_EDIT_KIND.into(), + action: "created".into(), + key: Some("trustLockfile".into()), + original: None, + new: Some(serde_json::json!("true")), + }, + )); + pnpm_trust_configured_detail(&server, true, args.common.dry_run) + } + TrustPlan::Append(text) => { + trust_config_write = Some(( + text, + socket_patch_core::patch::redirect::FileEdit { + path: PNPM_WORKSPACE_REL.into(), + kind: REDIRECT_PNPM_WORKSPACE_TRUST_EDIT_KIND.into(), + action: "added".into(), + key: Some("trustLockfile".into()), + original: None, + new: Some(serde_json::json!("true")), + }, + )); + pnpm_trust_configured_detail(&server, false, args.common.dry_run) + } + TrustPlan::AlreadyTrue => format!( + "{}, and {PNPM_WORKSPACE_REL} already carries `trustLockfile: \ + true` — keep it committed alongside the lock; installs need \ + no extra flags. {PNPM_TRUST_TRADEOFF_AND_CAUTION}", + pnpm_trust_policy_preamble(&server), + ), + TrustPlan::UserSet(value) => format!( + "{}. {PNPM_WORKSPACE_REL} explicitly sets `trustLockfile: \ + {value}`, which was respected and left untouched — install \ + with `pnpm install --trust-lockfile`, or set `trustLockfile: \ + true` yourself so every install accepts the patched \ + artifacts. {PNPM_TRUST_TRADEOFF_AND_CAUTION}", + pnpm_trust_policy_preamble(&server), + ), + }, + } + }; + pnpm_warnings.push(serde_json::json!({ + "code": "redirect_pnpm_trust_lockfile", + "detail": detail, + })); + } } + if let Some((text, edit)) = trust_config_write { + rewrite.files.insert(PNPM_WORKSPACE_REL.to_string(), text); + // Appended last: `--revert` walks edits in reverse, so the trust key + // is unwound before the lock originals are restored. + rewrite.edits.push(edit); + } + let rewritten: Vec = rewrite.files.keys().cloned().collect(); // A dep counts as REDIRECTED only if its hosted-artifact URL (or its // per-dependency registry index URL) actually landed in the project's @@ -1022,8 +1440,372 @@ pub(super) async fn run_redirect( #[cfg(test)] mod tests { - use super::{build_redirect_json_envelope, parse_purl_simple, REDIRECT_CANDIDATE_FILES}; + use super::{ + build_redirect_json_envelope, parse_purl_simple, plan_workspace_trust, pnpm_heal_root, + pnpm_lock_carries_hosted_redirect, pnpm_lock_version_major, pnpm_trust_configured_detail, + pnpm_trust_legacy_detail, pnpm_trust_manual_guidance, + pnpm_trust_workspace_unreadable_detail, read_workspace_for_trust, TrustPlan, + REDIRECT_CANDIDATE_FILES, + }; use socket_patch_core::constants::npm_family; + use socket_patch_core::patch::redirect::DepOverride; + + /// Lock-head version sniff against the byte-real heads the 2026-08-18 + /// matrix captured from pnpm 7/8/9-12: quoted `'9.0'` and `'6.0'`, + /// unquoted `5.4`; a headless/garbled lock yields `None` (hands-off). + #[test] + fn pnpm_lock_version_major_sniffs_real_lock_heads() { + assert_eq!( + pnpm_lock_version_major("lockfileVersion: '9.0'\n\nsettings:\n"), + Some(9), + "pnpm 9-12 emit a quoted '9.0'" + ); + assert_eq!( + pnpm_lock_version_major("lockfileVersion: '6.0'\n\nsettings:\n"), + Some(6), + "pnpm 8 emits a quoted '6.0'" + ); + assert_eq!( + pnpm_lock_version_major("lockfileVersion: 5.4\n\nspecifiers:\n"), + Some(5), + "pnpm 7 emits an unquoted 5.4" + ); + // Not necessarily the first line (a comment/BOM-damaged head). + assert_eq!( + pnpm_lock_version_major("# managed\nlockfileVersion: \"9.0\"\n"), + Some(9) + ); + assert_eq!( + pnpm_lock_version_major("importers:\n .:\n"), + None, + "no version line → None, callers stay hands-off" + ); + assert_eq!( + pnpm_lock_version_major("lockfileVersion: banana\n"), + None, + "unparseable version → None, never a guess" + ); + } + + /// No pnpm-workspace.yaml → create the root-only scaffold + trust key + /// (the exact bytes the vendor backend's scaffold precedent uses, with + /// `trustLockfile: true` in place of the override). + #[test] + fn plan_workspace_trust_creates_the_scaffold() { + match plan_workspace_trust(None) { + TrustPlan::Create(text) => { + assert_eq!(text, "packages:\n - '.'\ntrustLockfile: true\n"); + } + _ => panic!("no workspace file must plan a Create"), + } + } + + /// An existing workspace file gains exactly one line after its last + /// non-empty line; every other byte — including a trailing blank line and + /// comments — is preserved so a revert can remove exactly that line. + #[test] + fn plan_workspace_trust_appends_preserving_user_bytes() { + let user = "# team workspace\npackages:\n - 'apps/*'\n - 'libs/*'\n\ncatalog:\n react: ^18.0.0\n"; + match plan_workspace_trust(Some(user)) { + TrustPlan::Append(text) => { + assert_eq!( + text, + "# team workspace\npackages:\n - 'apps/*'\n - 'libs/*'\n\ncatalog:\n react: ^18.0.0\ntrustLockfile: true\n", + "one line appended after the last non-empty line, all user bytes intact" + ); + } + _ => panic!("a file without the key must plan an Append"), + } + // No trailing newline: the file's (lack of) trailing bytes stays put. + match plan_workspace_trust(Some("packages:\n - '.'")) { + TrustPlan::Append(text) => { + assert_eq!(text, "packages:\n - '.'\ntrustLockfile: true"); + } + _ => panic!("expected Append"), + } + } + + /// `trustLockfile: true` already present (any quoting) → nothing to do; + /// an explicit non-true value is the USER's security call and is + /// respected, never flipped. + #[test] + fn plan_workspace_trust_respects_existing_key() { + for spelled in [ + "packages:\n - '.'\ntrustLockfile: true\n", + "trustLockfile: 'true'\npackages:\n - '.'\n", + "trustLockfile: \"true\"\n", + ] { + assert!( + matches!(plan_workspace_trust(Some(spelled)), TrustPlan::AlreadyTrue), + "already-true must be a no-op for {spelled:?}" + ); + } + match plan_workspace_trust(Some("packages:\n - '.'\ntrustLockfile: false\n")) { + TrustPlan::UserSet(value) => assert_eq!(value, "false"), + _ => panic!("an explicit false must be respected as UserSet"), + } + // An INDENTED trustLockfile under some other mapping is not the + // top-level setting pnpm reads — it must not be mistaken for one. + match plan_workspace_trust(Some( + "catalogMode:\n trustLockfile: false\npackages:\n - '.'\n", + )) { + TrustPlan::Append(text) => assert!(text.ends_with("trustLockfile: true\n")), + _ => panic!("an indented key must not block the top-level append"), + } + } + + /// The warning variants: the configured text says trust is in place and + /// installs need no flags; the dry-run text says WOULD; both carry the + /// whole-lock tradeoff disclosure and the don't-rebuild caution; the + /// manual-guidance text keeps both verified recoveries. None may leak a + /// URL authority `@` (the userinfo-stripping contract). + #[test] + fn pnpm_trust_warning_variants_carry_the_load_bearing_sentences() { + let server = "the hosted patch server (patch.test)"; + for created in [true, false] { + let configured = pnpm_trust_configured_detail(server, created, false); + assert!(configured.contains("trustLockfile: true"), "{configured}"); + assert!(configured.contains("pnpm-workspace.yaml"), "{configured}"); + assert!( + configured.contains("commit it alongside the lock"), + "{configured}" + ); + assert!(configured.contains("no extra flags"), "{configured}"); + assert!(!configured.contains("would be"), "{configured}"); + let dry = pnpm_trust_configured_detail(server, created, true); + assert!(dry.contains("would be"), "{dry}"); + assert!(dry.contains("--dry-run"), "{dry}"); + for text in [&configured, &dry] { + assert!(text.contains("ALL lockfile entries"), "{text}"); + assert!(text.contains("minimumReleaseAge"), "{text}"); + assert!(text.contains("sha512 integrity pins are"), "{text}"); + assert!(text.contains("pnpm clean --lockfile"), "{text}"); + assert!(text.contains("pnpm <=10"), "{text}"); + assert!( + text.contains("ERR_PNPM_TARBALL_URL_MISMATCH") + && text.contains("ERR_PNPM_LOCKFILE_RESOLUTION_VERIFICATION"), + "{text}" + ); + assert!(!text.contains('@'), "no URL authority may leak: {text}"); + assert!(!text.contains(".npmrc"), "{text}"); + } + } + let manual = pnpm_trust_manual_guidance(server); + assert!(manual.contains("--trust-lockfile"), "{manual}"); + assert!( + manual.contains("trustLockfile: true") && manual.contains("pnpm-workspace.yaml"), + "{manual}" + ); + assert!(manual.contains("pnpm clean --lockfile"), "{manual}"); + assert!(manual.contains("pnpm <=10"), "{manual}"); + assert!(!manual.contains('@'), "{manual}"); + } + + /// FINDING-10 regression: the legacy-lock (5.x/6.0 — pnpm 7/8) guidance + /// must NEVER mention `--trust-lockfile` (pnpm 7/8 reject the flag as an + /// unknown option) nor the `trustLockfile` setting (pnpm 7/8 ignore it); + /// it must say installs work unchanged with no trust step, keep the + /// don't-regenerate caution, and leak no URL authority. RED-verified: the + /// pre-fix manual guidance headlined `pnpm install --trust-lockfile` for + /// legacy locks, which errors out on pnpm 7/8. + #[test] + fn pnpm_trust_legacy_detail_never_recommends_the_trust_flag() { + let server = "the hosted patch server (patch.test)"; + let legacy = pnpm_trust_legacy_detail(server); + assert!( + !legacy.contains("trust-lockfile"), + "pnpm 7/8 reject --trust-lockfile as an unknown option: {legacy}" + ); + assert!( + !legacy.contains("trustLockfile"), + "pnpm 7/8 ignore the setting — recommending it is noise: {legacy}" + ); + assert!(legacy.contains("pnpm 7/8"), "{legacy}"); + assert!(legacy.contains("installs work unchanged"), "{legacy}"); + assert!(legacy.contains("no trust step"), "{legacy}"); + // The vulnerable-reinstall caution survives the split: regenerating + // the lock still silently discards the redirect. + assert!(legacy.contains("Do NOT regenerate"), "{legacy}"); + assert!(legacy.contains("vulnerable upstream"), "{legacy}"); + assert!(!legacy.contains('@'), "no URL authority may leak: {legacy}"); + } + + /// FINDING-5 regression: a PRESENT-but-unreadable pnpm-workspace.yaml + /// must classify as `Err` — never as `Ok(None)`, which plans a Create + /// that overwrites the user's file (destroying their `packages:` globs). + /// Absent stays `Ok(None)` (the only Create-safe state); readable stays + /// `Ok(Some)`. RED-verified: the pre-fix `.ok()` collapsed the + /// invalid-UTF-8 read error below to `None`. + #[test] + fn read_workspace_for_trust_distinguishes_unreadable_from_absent() { + let tmp = tempfile::tempdir().unwrap(); + // Absent → Ok(None). + assert!(matches!( + read_workspace_for_trust(&tmp.path().join("pnpm-workspace.yaml")), + Ok(None) + )); + // Readable → Ok(Some(text)). + let readable = tmp.path().join("readable.yaml"); + std::fs::write(&readable, "packages:\n - '.'\n").unwrap(); + assert!(matches!( + read_workspace_for_trust(&readable), + Ok(Some(text)) if text.contains("packages") + )); + // Invalid UTF-8 → Err(InvalidData), cross-platform. + let invalid = tmp.path().join("invalid.yaml"); + std::fs::write(&invalid, b"packages:\n - 'apps/*'\n\xff\xfe\x80").unwrap(); + let err = read_workspace_for_trust(&invalid) + .expect_err("invalid UTF-8 must classify as Err, never as absent→Create"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + // chmod 000 (unix): PermissionDenied → Err. Root ignores mode bits, + // so only the failing-read outcome is asserted strictly. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let locked = tmp.path().join("locked.yaml"); + std::fs::write(&locked, "packages:\n - '.'\n").unwrap(); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); + match read_workspace_for_trust(&locked) { + Err(e) => assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied), + // Running as root: mode bits don't apply; the invalid-UTF-8 + // case above already proved the Err classification. + Ok(Some(_)) => {} + Ok(None) => panic!("an unreadable file must never classify as absent"), + } + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o644)).unwrap(); + } + // The fallback detail names the file, the error, and both manual + // recoveries — and never plans a write (it returns prose only). + let server = "the hosted patch server (patch.test)"; + let detail = pnpm_trust_workspace_unreadable_detail( + server, + &std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied"), + ); + assert!(detail.contains("pnpm-workspace.yaml"), "{detail}"); + assert!(detail.contains("could not be read"), "{detail}"); + assert!(detail.contains("permission denied"), "{detail}"); + assert!(detail.contains("left untouched"), "{detail}"); + assert!( + detail.contains("--trust-lockfile") && detail.contains("trustLockfile: true"), + "{detail}" + ); + assert!(detail.contains("pnpm clean --lockfile"), "{detail}"); + } + + fn npm_override(artifact_url: &str) -> DepOverride { + DepOverride { + ecosystem: "npm".to_string(), + name: "in-proc-heal".to_string(), + namespace: None, + version: "1.0.0".to_string(), + token: "tok".to_string(), + patch_uuid: "11111111-1111-4111-8111-111111111111".to_string(), + artifact_url: artifact_url.to_string(), + berry_zip_url: None, + registry_override: None, + integrity: Default::default(), + } + } + + /// FINDING-6 regression (heal-on-rerun probe): a root lock ALREADY + /// carrying a granted hosted artifact URL from an earlier run — raw, + /// `\/`-escaped, or percent-encoded — is detected even when this run + /// spliced nothing, so the trust config can be (re)planned for a project + /// that missed it (opted-out first run, or a crash between the lock + /// write and the workspace write). A pristine lock, and a lock whose + /// only match is a NON-npm override's URL, must stay undetected. + #[test] + fn pnpm_lock_carries_hosted_redirect_detects_prior_run_splices() { + let url = "http://patch.test/patch/npm/in-proc-heal/1.0.0/tok/uuid/in-proc-heal-1.0.0.tgz"; + let mut cargo = npm_override("http://patch.test/crates/heal-1.0.0.crate"); + cargo.ecosystem = "cargo".to_string(); + let overrides = vec![npm_override(url), cargo]; + + // The exact splice shape an earlier run wrote (heal-on-rerun with a + // pre-redirected lock and a missing workspace file: this probe is + // what re-engages the trust planning on the re-scan). + let redirected = format!( + "lockfileVersion: '9.0'\n\npackages:\n in-proc-heal@1.0.0:\n \ + resolution: {{integrity: sha512-PATCHED==, tarball: {url}}}\n" + ); + assert!(pnpm_lock_carries_hosted_redirect(&redirected, &overrides)); + + // The percent-encoded spelling counts too (same predicate set as the + // confirmation probe). + let encoded = socket_patch_core::utils::uri::encode_uri_component(url); + let encoded_lock = format!("lockfileVersion: '9.0'\npackages:\n x: {encoded}\n"); + assert!(pnpm_lock_carries_hosted_redirect(&encoded_lock, &overrides)); + + // Pristine lock: nothing to heal. + assert!(!pnpm_lock_carries_hosted_redirect( + "lockfileVersion: '9.0'\n\npackages:\n in-proc-heal@1.0.0:\n \ + resolution: {integrity: sha512-UPSTREAM==}\n", + &overrides + )); + + // A non-npm override's URL in the text is not a pnpm redirect. + assert!(!pnpm_lock_carries_hosted_redirect( + "lockfileVersion: '9.0'\n# http://patch.test/crates/heal-1.0.0.crate\n", + &overrides + )); + + // No grants at all → never engages. + assert!(!pnpm_lock_carries_hosted_redirect(&redirected, &[])); + } + + /// FINDING-6 regression (heal-on-rerun gate, the production + /// `pnpm_heal_root` wiring): a re-scan that spliced NOTHING over a + /// pre-redirected root v9 lock with a MISSING pnpm-workspace.yaml must + /// engage the trust block (heal → plan Create), while a legacy + /// pre-redirected lock, an unparseable-version lock, a pristine lock, + /// and a root lock this run DID splice all stay out of the heal path. + /// RED-verified by construction: the pre-fix trigger was + /// `!spliced.is_empty()` alone, i.e. this gate always answered None. + #[test] + fn pnpm_heal_root_re_engages_trust_planning_for_pre_redirected_v9_locks() { + let url = "http://patch.test/patch/npm/in-proc-heal/1.0.0/tok/uuid/in-proc-heal-1.0.0.tgz"; + let overrides = vec![npm_override(url)]; + let redirected_v9 = format!( + "lockfileVersion: '9.0'\n\npackages:\n in-proc-heal@1.0.0:\n \ + resolution: {{integrity: sha512-PATCHED==, tarball: {url}}}\n" + ); + + // The heal scenario: nothing spliced this run, root lock already + // redirected, workspace file missing → the gate engages and the + // planning it feeds produces the Create the crashed/opted-out first + // run never wrote. + let healed = pnpm_heal_root(false, Some(&redirected_v9), &overrides) + .expect("a pre-redirected root v9 lock must re-engage the trust block"); + assert_eq!(healed, &redirected_v9); + assert!( + matches!(plan_workspace_trust(None), TrustPlan::Create(_)), + "with the workspace file missing, the healed run must plan the Create" + ); + + // Root lock spliced THIS run: the splice path covers it — no heal. + assert!(pnpm_heal_root(true, Some(&redirected_v9), &overrides).is_none()); + + // Pristine v9 lock (no redirect landed): nothing to heal. + let pristine = "lockfileVersion: '9.0'\n\npackages:\n in-proc-heal@1.0.0:\n \ + resolution: {integrity: sha512-UPSTREAM==}\n" + .to_string(); + assert!(pnpm_heal_root(false, Some(&pristine), &overrides).is_none()); + + // Legacy pre-redirected lock: pnpm 7/8 need no trust config — the + // heal gate must not drag a 5.x/6.0 lock into the v9 auto-config. + let redirected_v6 = format!( + "lockfileVersion: '6.0'\n\npackages:\n /in-proc-heal@1.0.0:\n \ + resolution: {{integrity: sha512-PATCHED==, tarball: {url}}}\n" + ); + assert!(pnpm_heal_root(false, Some(&redirected_v6), &overrides).is_none()); + + // Unparseable version: fail closed, hands off. + let headless = format!("packages:\n x:\n resolution: {{tarball: {url}}}\n"); + assert!(pnpm_heal_root(false, Some(&headless), &overrides).is_none()); + + // No root lock at all (e.g. Rush): nothing to heal. + assert!(pnpm_heal_root(false, None, &overrides).is_none()); + } #[test] fn parse_purl_simple_percent_decodes_name_and_version() { diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index d112ced1..586839da 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -54,8 +54,12 @@ const DEFAULT_BATCH_SIZE: usize = 100; pub enum ScanMode { /// Rewrite lockfiles so ONLY patched dependencies resolve to Socket's /// hosted patch server (== `--redirect`): no artifact bytes land in the - /// repo, but installs must reach the patch server. - #[value(alias = "host")] + /// repo, but installs must reach the patch server. Hidden value aliases + /// mirror the legacy flag spellings symmetrically: `host` matches the + /// old mode name, `redirect` matches the `--redirect` boolean (vendored + /// accepts `vendor` for the same reason; `apply` is NOT an alias of + /// agent — applying is not a scan mode name anywhere else). + #[value(alias = "host", alias = "redirect")] Hosted, /// Commit patched artifacts to `.socket/vendor/` (== `--vendor`): /// hermetic, offline-safe installs at the cost of repo size. @@ -454,9 +458,13 @@ fn download_params(args: &ScanArgs, save_only: bool, json: bool, silent: bool) - // mode's ledger on disk asserting wiring that is no longer live (and, for // vendored→hosted, the orphaned tarball behind). Anything auditing a ledger // as "what is live" (including `vex`) is then misled. Detect the overlap so -// each flow can warn; reconciliation (removing the stale ledger / orphaned -// artifacts) is deliberately deferred so neither mode silently mutates the -// other's ledger. +// each flow can warn. Reconciliation is per direction: the VENDORED flows +// clean the superseded redirect-ledger halves themselves (cargo via +// `revert_cargo_redirect_purl` before vendoring, npm-family via +// `note_vendor_supersedes_redirect` after — always announced by the +// takeover warning, never silent); the HOSTED direction stays warn-only +// (removing a vendored ledger entry means deleting committed artifacts — +// `remove `'s job, on the operator's say-so). // // The overlap alone only proves BOTH ledgers name the same package(s) — NOT // which one won. The takeover DIRECTION is decided by the ACTUAL current @@ -907,13 +915,15 @@ pub(super) fn mode_takeover_detail(superseded: &[String], current_is_hosted: boo "vendored artifacts superseded the hosted redirect ledger for: {list}. \ `.socket/vendor/redirect-state.json` still records a hosted redirect for \ these package(s), but the lockfile now points at the committed \ - `.socket/vendor/` files. Re-run `socket-patch vendor` (or `scan \ - --mode vendored`) to reconcile these package(s) automatically: it \ - reverts their stale hosted edits from the ledger and drops both \ - halves of each superseded entry — the `records` entry AND its \ - matching `edits`. To clean up by hand instead, delete only these \ - package(s)' entries under `records` AND their matching entries \ - under `edits`, so audits and VEX do not read superseded wiring. \ + `.socket/vendor/` files. The vendored flows (`socket-patch vendor`, \ + `scan --mode vendored`) reconcile npm-family and cargo package(s) \ + automatically on their next non-dry run, dropping both halves of \ + each superseded entry — the `records` entry AND its matching \ + `edits` (cargo additionally reverts the stale hosted edits on disk \ + first). For other package(s), or if the automatic reconciliation \ + could not run, clean up by hand: delete only these package(s)' \ + entries under `records` AND their matching entries under `edits`, \ + so audits and VEX do not read superseded wiring. \ Both halves matter: the leftover `edits` are that package's stale \ pre-redirect originals, which a later redirect revert would replay \ over the live vendored wiring — and an `edits` entry left behind \ @@ -927,12 +937,76 @@ pub(super) fn mode_takeover_detail(superseded: &[String], current_is_hosted: boo } } +/// Detail for the vendored-direction takeover warning on the run that +/// RECONCILED the ledger in place (non-dry-run, npm-family): past tense — +/// it states what was dropped and where the revert data now lives, so the +/// operator is told the takeover happened without being handed remediation +/// that is already done. The warning code stays `vendor_supersedes_redirect` +/// (envelope contract: codes are additive and stable; only the free-text +/// detail differs), and it fires exactly once — the reconciled ledger no +/// longer overlaps, so re-runs stay silent. +pub(super) fn mode_takeover_reconciled_detail(reconciled: &[String]) -> String { + let list = reconciled.join(", "); + format!( + "vendored artifacts superseded the hosted redirect ledger for: {list}; \ + reconciled automatically. Both halves of each superseded entry — the \ + package's `records` entry AND its matching `edits` — were dropped \ + from `.socket/vendor/redirect-state.json` (an emptied ledger is \ + deleted). The lockfile points at the committed `.socket/vendor/` \ + files, and the pre-vendor lock values (including the hosted-spliced \ + fragment) are preserved as the vendor ledger's wiring originals, so \ + `vendor --revert` still restores the hosted wiring losslessly. \ + Ledger data for other, still-redirected package(s) was left \ + untouched. No action needed." + ) +} + +/// Drop the superseded purls' `records` + `edits` from the redirect ledger +/// and persist it (atomic write; an emptied ledger is deleted — the same +/// delete-when-empty contract every other persist follows). Called ONLY with +/// purls [`classify_overlap_takeover`] proved vendored-live AND hosted-dead +/// against the LIVE lockfile: the gate that makes the warning truthful is +/// the one that makes the drop lossless (the vendor ledger's wiring +/// `original` embeds the hosted-spliced fragment, so `vendor --revert` needs +/// nothing from these records). `Ok(false)` when nothing matched (degenerate +/// — the caller falls back to the manual advisory rather than claiming a +/// reconciliation that did not happen); `Err` when the ledger could not be +/// read back or persisted (fail closed: the atomic writer leaves the on-disk +/// ledger either untouched or fully pre-drop, and the caller surfaces the +/// failure inside the warning). +async fn reconcile_superseded_redirect(cwd: &Path, purls: &[String]) -> Result { + let mut state = match socket_patch_core::patch::redirect::load_redirect_state(cwd).await { + Ok(Some(state)) => state, + Ok(None) => return Ok(false), + Err(corrupt) => return Err(corrupt.to_string()), + }; + let mut dropped = false; + for purl in purls { + dropped |= socket_patch_core::patch::redirect::drop_superseded_purl(&mut state, purl); + } + if !dropped { + return Ok(false); + } + socket_patch_core::patch::redirect::persist_redirect_state(cwd, &state) + .await + .map_err(|e| e.to_string())?; + Ok(true) +} + /// Cross-mode takeover advisory shared by every VENDORED flow (`vendor`, /// `scan --mode vendored`): when this ledger and a committed hosted redirect /// ledger both claim package(s) AND the live lockfile proves vendored won, /// the redirect ledger records for those package(s) are stale. Warn once at -/// the envelope level (JSON `warnings[]` and stderr) without deleting -/// anything — the per-package reconciliation lives in the vendor engine. +/// the envelope level (JSON `warnings[]` and stderr) — and, for npm-family +/// package(s) on a non-dry run, reconcile the ledger in place at the same +/// time (mirroring the cargo branch in `vendor.rs`, which reverts + drops +/// BEFORE vendoring because `[patch.crates-io]` cannot stack on the hosted +/// registry pin; npm-family needs no on-disk revert — vendoring already +/// overwrote the hosted splice and recorded it as the wiring `original`). +/// Without the drop, the stale records fed VEX/updates forever and this +/// warning re-fired on every subsequent run (`already_vendored` no-ops drop +/// nothing). The reverse direction (`redirect_supersedes_vendored`) is +/// deliberately untouched. pub(super) async fn note_vendor_supersedes_redirect( env: &mut crate::json_envelope::Envelope, cwd: &Path, @@ -947,14 +1021,59 @@ pub(super) async fn note_vendor_supersedes_redirect( if superseded.is_empty() { return; } - let detail = mode_takeover_detail(&superseded, /*current_is_hosted=*/ false); - if !common.silent && !common.json { - eprintln!("Warning ({VENDOR_SUPERSEDES_REDIRECT}): {detail}"); + fn push_warning(env: &mut crate::json_envelope::Envelope, common: &GlobalArgs, detail: String) { + if !common.silent && !common.json { + eprintln!("Warning ({VENDOR_SUPERSEDES_REDIRECT}): {detail}"); + } + env.warnings.push(crate::json_envelope::RunWarning { + code: VENDOR_SUPERSEDES_REDIRECT.to_string(), + detail, + }); + } + // Reconciliation is gated three ways, each fail-closed to the manual + // advisory: never under --dry-run (this advisory runs even on preview + // flows, and a dry run must not mutate the ledger); only npm-family + // purls (cargo goes through `revert_cargo_redirect_purl`'s on-disk + // revert in vendor.rs, and other ecosystems' vendor wiring has not been + // verified to embed the hosted originals); and only purls the live-lock + // classification above already proved no longer resolve the hosted URL. + let (reconcilable, manual): (Vec, Vec) = if common.dry_run { + (Vec::new(), superseded) + } else { + superseded + .into_iter() + .partition(|purl| purl.starts_with("pkg:npm/")) + }; + if !manual.is_empty() { + push_warning( + env, + common, + mode_takeover_detail(&manual, /*current_is_hosted=*/ false), + ); + } + if reconcilable.is_empty() { + return; + } + match reconcile_superseded_redirect(cwd, &reconcilable).await { + Ok(true) => push_warning(env, common, mode_takeover_reconciled_detail(&reconcilable)), + // Nothing matched to drop — do not claim a reconciliation that did + // not happen; hand out the manual remediation instead. + Ok(false) => push_warning( + env, + common, + mode_takeover_detail(&reconcilable, /*current_is_hosted=*/ false), + ), + Err(e) => push_warning( + env, + common, + format!( + "{} Automatic reconciliation failed ({e}); the ledger was left \ + as it was, so this warning will fire again until the cleanup \ + above succeeds.", + mode_takeover_detail(&reconcilable, /*current_is_hosted=*/ false) + ), + ), } - env.warnings.push(crate::json_envelope::RunWarning { - code: VENDOR_SUPERSEDES_REDIRECT.to_string(), - detail, - }); } pub async fn run(mut args: ScanArgs) -> i32 { diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 3b925b5e..8197aade 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -543,6 +543,13 @@ async fn run_vendor( .await; if has_errors { + // A run where EVERY event failed still reads as "partialFailure": + // the envelope has no "completed with zero successes" status, and + // status=error is reserved for pre-event failures (it implies a + // top-level error payload and empty events[] — see json_envelope.rs). + // Escalating here without an envelope-level API broke that contract, + // and scan --vendor / vendor --revert report the same outcome as + // partialFailure, so this stays aligned with them. env.mark_partial_failure(); 1 } else { @@ -686,6 +693,11 @@ pub(crate) async fn vendor_records( service: Option<&VendorServiceConfig>, ) -> bool { let mut has_errors = false; + // Lockfile flavors the backends wired THIS run (from the returned ledger + // entries, not the whole ledger — an old pnpm entry must not re-flavor + // the hints of a run that vendored only cargo). Drives the human + // committable-files + reinstall hints below. + let mut wired_flavors: HashSet = HashSet::new(); let manifest_purls: Vec = records.keys().cloned().collect(); let partitioned = partition_purls(&manifest_purls, common.ecosystems.as_deref()); @@ -1195,6 +1207,9 @@ pub(crate) async fn vendor_records( record_warning(env, candidate, w, common); } if let Some(entry) = entry { + if let Some(flavor) = entry.flavor.as_deref() { + wired_flavors.insert(flavor.to_string()); + } has_errors |= persist_vendor_entry( common, env, &mut state, candidate, entry, detached, record, ) @@ -1235,11 +1250,16 @@ pub(crate) async fn vendor_records( HashSet::new() }; for purl in &unmatched { + // Honesty order: every purl here is first and foremost a crawler + // miss — nothing on disk matched — so the on-disk cause leads. + // The --offline note is strictly secondary and only stated when + // it is actually what blocked the fallback (the lockfile resolves + // the package, so a non-offline run would have auto-fetched it). let detail = if lock_resolvable.contains(purl) { - "no installed package found; --offline prevents fetching it from the \ - registry (the lockfile resolves it)" + "no installed package found on disk; the lockfile resolves it, but \ + --offline prevents fetching the pristine artifact from the registry" } else { - "no installed package found" + "no installed package found on disk" }; env.record( PatchEvent::new(PatchAction::Skipped, purl.clone()) @@ -1262,15 +1282,59 @@ pub(crate) async fn vendor_records( env.summary.applied, env.summary.skipped, env.summary.failed ); if env.summary.applied > 0 && !common.dry_run { - println!( - "Commit .socket/vendor/ and the updated lockfiles to make the patches portable." - ); + // pnpm >=11 reads `overrides` ONLY from pnpm-workspace.yaml (the + // package.json `pnpm.overrides` mirror is ignored), so pnpm-wired + // runs must name that file among the committables: a checkout + // that loses it silently unvendors on the next install. + if wired_flavors.contains("pnpm") { + println!( + "Commit .socket/vendor/, package.json, pnpm-lock.yaml, and \ + pnpm-workspace.yaml to make the patches portable (pnpm >=11 reads \ + the vendored override only from pnpm-workspace.yaml)." + ); + } else { + println!( + "Commit .socket/vendor/ and the updated lockfiles to make the patches \ + portable." + ); + } + let mut installs: Vec<&str> = wired_flavors + .iter() + .filter_map(|f| flavor_install_command(f)) + .collect(); + installs.sort_unstable(); + for cmd in installs { + println!( + "Run `{cmd}` to update the installed tree — vendoring rewires the \ + lockfile only, so the current node_modules keeps the unpatched bytes \ + until reinstalled." + ); + } } } has_errors } +/// The install command that re-materializes the project tree from the wired +/// lockfile, per npm-family flavor. Vendoring edits ONLY the lockfile/config +/// wiring — the already-installed node_modules keeps its pre-vendor bytes +/// until the package manager reinstalls from the rewired lock (verified +/// against real pnpm installs) — so a successful vendor must say how to +/// update it. `None` for flavors whose consuming step is not an install. +fn flavor_install_command(flavor: &str) -> Option<&'static str> { + match flavor { + "package-lock" => Some("npm install"), + "yarn-classic" | "yarn-berry" => Some("yarn install"), + // pnpm-legacy (lockfileVersion 5.4/6.0): plain `pnpm install` is also + // the moved-checkout recovery — pnpm <= 8 absolutizes file: override + // specifiers, so `--frozen-lockfile` only passes at the vendoring path. + "pnpm" | "pnpm-legacy" => Some("pnpm install"), + "bun" => Some("bun install"), + _ => None, + } +} + /// Ledger entries whose patch is gone from the manifest — the stale test /// shared by [`reconcile_dropped`] and [`run_vendor_gc`]. Respects this /// run's --ecosystems scope: a `vendor --ecosystems npm` invocation must @@ -1994,12 +2058,27 @@ mod gc_tests { } /// (a) the patch is gone from the manifest: revert + drop the entry. + /// + /// The fixture entry carries EMPTY wiring (a synthetic ledger, not a + /// vendor-produced one), so the lock must no longer resolve through + /// the artifact for the revert to proceed: the unwired-revert guard + /// refuses to delete an artifact a live lock still points at (the + /// repair-reconstruction brick; pinned end-to-end in + /// repair_vendor_e2e / repair_vendor_flavors_e2e). Re-lock the + /// fixture to the registry — the realistic reclaim shape. #[tokio::test] async fn vendor_gc_reverts_manifest_dropped_entry() { let (tmp, common, manifest_path) = gc_fixture(false).await; write_manifest(&manifest_path, &PatchManifest::new()) .await .unwrap(); + tokio::fs::write( + tmp.path().join("package-lock.json"), + "{\"packages\":{\"node_modules/left-pad\":{\"resolved\":\ + \"https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz\"}}}", + ) + .await + .unwrap(); let out = run_vendor_gc(&common, &manifest_path, false).await; assert_eq!(out.dropped_reverted, vec![PURL.to_string()], "{out:?}"); diff --git a/crates/socket-patch-cli/tests/apply_invariants.rs b/crates/socket-patch-cli/tests/apply_invariants.rs index fbb3c554..eea408d3 100644 --- a/crates/socket-patch-cli/tests/apply_invariants.rs +++ b/crates/socket-patch-cli/tests/apply_invariants.rs @@ -562,3 +562,129 @@ fn apply_with_unreadable_socket_dir_fails_closed() { "expected the manifest_unreadable envelope error; envelope: {v}" ); } + +/// Lay down a project with TWO npm manifest patches, BOTH with their +/// patched blobs staged (so the offline no-local-source guard never +/// fires and the only difference between them is matchability): +/// - `scopedpkg@1.0.0` is installed on disk and fully applicable; +/// - `__ghost_pkg__@9.9.9` matches nothing on disk. +fn write_partial_match_project(root: &Path) { + let before = git_sha256(SCOPED_ORIGINAL); + let after = git_sha256(SCOPED_PATCHED); + let ghost_after = git_sha256(b"ghost patched\n"); + + std::fs::write( + root.join("package.json"), + r#"{"name":"partial-match-test","version":"0.0.0"}"#, + ) + .expect("write package.json"); + + let pkg = root.join("node_modules").join("scopedpkg"); + std::fs::create_dir_all(&pkg).expect("create package dir"); + std::fs::write( + pkg.join("package.json"), + r#"{"name":"scopedpkg","version":"1.0.0"}"#, + ) + .expect("write pkg package.json"); + std::fs::write(pkg.join("index.js"), SCOPED_ORIGINAL).expect("write index.js"); + + let socket = root.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).expect("create blobs"); + std::fs::write(socket.join("blobs").join(&after), SCOPED_PATCHED).expect("write blob"); + std::fs::write(socket.join("blobs").join(&ghost_after), b"ghost patched\n") + .expect("write ghost blob"); + let before_ghost = git_sha256(b"ghost original\n"); + let manifest = format!( + r#"{{ + "patches": {{ + "{SCOPED_NPM_PURL}": {{ + "uuid": "33333333-3333-4333-8333-333333333333", + "exportedAt": "2024-01-01T00:00:00Z", + "files": {{ + "package/index.js": {{ "beforeHash": "{before}", "afterHash": "{after}" }} + }}, + "vulnerabilities": {{}}, + "description": "installed npm patch with local sources", + "license": "MIT", + "tier": "free" + }}, + "pkg:npm/__ghost_pkg__@9.9.9": {{ + "uuid": "44444444-4444-4444-8444-444444444444", + "exportedAt": "2024-01-01T00:00:00Z", + "files": {{ + "package/index.js": {{ "beforeHash": "{before_ghost}", "afterHash": "{ghost_after}" }} + }}, + "vulnerabilities": {{}}, + "description": "npm patch whose package is not installed", + "license": "MIT", + "tier": "free" + }} + }} +}}"# + ); + std::fs::write(socket.join("manifest.json"), manifest).expect("write manifest"); +} + +/// PINS the current (asymmetric) unmatched-purl exit semantics so any +/// change to them is deliberate. Today: +/// +/// - a manifest whose patches ALL miss (no installed package matches) +/// exits 1 with `status: partialFailure`, while +/// - a MIXED manifest (one patch applied, one unmatched) exits 0 with +/// `status: success` and only a per-purl `skipped` event +/// (`errorCode: package_not_installed`) recording the miss. +/// +/// The asymmetry is a known open decision, not an endorsement: CI that +/// gates on the exit code reads the mixed run as fully patched even when +/// a manifest-listed CVE patch was silently not applied (2026-08-18 pnpm +/// matrix, apply-pnpm8 leg), while install hooks want the all-miss case +/// to STOP exiting 1 (it fails `npm install` from the postinstall hook). +/// Resolving either direction changes a documented contract for the +/// other consumer — whoever does it must update BOTH halves of this test +/// and the hook/CI guidance together. +#[test] +fn unmatched_purl_exit_semantics_are_pinned() { + // Mixed manifest: applied + unmatched → exit 0, status success, + // with the miss visible only as a skipped event. + let tmp = tempfile::tempdir().expect("tempdir"); + write_partial_match_project(tmp.path()); + let (code, stdout) = run_apply(tmp.path(), &["--offline", "--silent"]); + let v: serde_json::Value = + serde_json::from_str(&stdout).expect("apply --json must emit valid JSON"); + assert_eq!(code, 0, "mixed manifest pins exit 0 today; envelope:\n{v}"); + assert_eq!( + v["status"], "success", + "mixed manifest pins status=success; {v}" + ); + assert_eq!(v["summary"]["applied"], 1, "{v}"); + let events = v["events"].as_array().expect("events array"); + assert!( + events + .iter() + .any(|e| e["action"] == "applied" && e["purl"] == SCOPED_NPM_PURL), + "installed patch must be applied; {events:?}" + ); + let ghost = events + .iter() + .find(|e| e["purl"] == "pkg:npm/__ghost_pkg__@9.9.9") + .unwrap_or_else(|| panic!("unmatched purl must surface as an event; {events:?}")); + assert_eq!(ghost["action"], "skipped", "{ghost}"); + assert_eq!( + ghost["errorCode"], "package_not_installed", + "the miss must be machine-identifiable; {ghost}" + ); + + // All-miss manifest (same ghost patch alone, blob still staged so the + // offline source guard is not what fires) → exit 1, partialFailure. + let tmp2 = tempfile::tempdir().expect("tempdir"); + write_partial_match_project(tmp2.path()); + std::fs::remove_dir_all(tmp2.path().join("node_modules")).expect("uninstall scopedpkg"); + let (code2, stdout2) = run_apply(tmp2.path(), &["--offline", "--silent"]); + let v2: serde_json::Value = + serde_json::from_str(&stdout2).expect("apply --json must emit valid JSON"); + assert_eq!( + code2, 1, + "all-miss manifest pins exit 1 today; envelope:\n{v2}" + ); + assert_eq!(v2["status"], "partialFailure", "{v2}"); +} diff --git a/crates/socket-patch-cli/tests/cli_global_args.rs b/crates/socket-patch-cli/tests/cli_global_args.rs index 6a111b75..b3d02e65 100644 --- a/crates/socket-patch-cli/tests/cli_global_args.rs +++ b/crates/socket-patch-cli/tests/cli_global_args.rs @@ -108,6 +108,9 @@ fn global_flag_cases() -> Vec<(&'static str, Option<&'static str>, fn(&GlobalArg ("--yes", None, |c| assert!(c.yes)), ("--debug", None, |c| assert!(c.debug)), ("--no-telemetry", None, |c| assert!(c.no_telemetry)), + ("--no-trust-lockfile-config", None, |c| { + assert!(c.no_trust_lockfile_config) + }), ("--lock-timeout", Some("30"), |c| { assert_eq!(c.lock_timeout, Some(30)) }), @@ -220,17 +223,18 @@ fn global_flag_cases_cover_every_global_field() { lock_timeout: _, debug: _, no_telemetry: _, + no_trust_lockfile_config: _, strict: _, vendor_source: _, vendor_url: _, patch_server_url: _, } = common; - // 23 fields ↔ 23 long-flag cases. Bump both this count and add a case when + // 24 fields ↔ 24 long-flag cases. Bump both this count and add a case when // the destructure above forces you to add a field. assert_eq!( global_flag_cases().len(), - 23, + 24, "every GlobalArgs field needs a long-flag case in global_flag_cases()", ); @@ -651,7 +655,7 @@ fn bool_env_vars_reject_zero_and_falsey() { #[serial_test::serial] fn empty_bool_env_var_resolves_to_false_not_crash() { // (env var, accessor) for every boolean global. - let bool_vars: [(&str, fn(&GlobalArgs) -> bool); 10] = [ + let bool_vars: [(&str, fn(&GlobalArgs) -> bool); 11] = [ ("SOCKET_OFFLINE", |c| c.offline), ("SOCKET_STRICT", |c| c.strict), ("SOCKET_GLOBAL", |c| c.global), @@ -662,6 +666,9 @@ fn empty_bool_env_var_resolves_to_false_not_crash() { ("SOCKET_YES", |c| c.yes), ("SOCKET_DEBUG", |c| c.debug), ("SOCKET_TELEMETRY_DISABLED", |c| c.no_telemetry), + ("SOCKET_NO_TRUST_LOCKFILE_CONFIG", |c| { + c.no_trust_lockfile_config + }), ]; let saved = save_and_clear_global_env(); diff --git a/crates/socket-patch-cli/tests/cli_parse_get.rs b/crates/socket-patch-cli/tests/cli_parse_get.rs index 7e01c06f..34cc1dda 100644 --- a/crates/socket-patch-cli/tests/cli_parse_get.rs +++ b/crates/socket-patch-cli/tests/cli_parse_get.rs @@ -54,6 +54,7 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_LOCK_TIMEOUT", "SOCKET_DEBUG", "SOCKET_TELEMETRY_DISABLED", + "SOCKET_NO_TRUST_LOCKFILE_CONFIG", // GetArgs-specific "SOCKET_SAVE_ONLY", "SOCKET_ONE_OFF", diff --git a/crates/socket-patch-cli/tests/cli_parse_repair.rs b/crates/socket-patch-cli/tests/cli_parse_repair.rs index 1c31261f..2d4443b9 100644 --- a/crates/socket-patch-cli/tests/cli_parse_repair.rs +++ b/crates/socket-patch-cli/tests/cli_parse_repair.rs @@ -61,6 +61,7 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_LOCK_TIMEOUT", "SOCKET_DEBUG", "SOCKET_TELEMETRY_DISABLED", + "SOCKET_NO_TRUST_LOCKFILE_CONFIG", // RepairArgs-specific "SOCKET_DOWNLOAD_ONLY", ]; diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs index adb8c974..2247ae36 100644 --- a/crates/socket-patch-cli/tests/cli_parse_scan.rs +++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs @@ -42,6 +42,7 @@ const SCAN_ENV_VARS: &[&str] = &[ "SOCKET_JSON", "SOCKET_LOCK_TIMEOUT", "SOCKET_MANIFEST_PATH", + "SOCKET_NO_TRUST_LOCKFILE_CONFIG", "SOCKET_OFFLINE", "SOCKET_ORG_SLUG", "SOCKET_PATCH_SERVER_URL", @@ -764,12 +765,15 @@ fn scrub_covers_every_scan_env_var_clap_consults() { } } -// --- hidden `--mode` value aliases ("vendor" / "host") ---------------------- +// --- hidden `--mode` value aliases ("vendor" / "host" / "redirect") --------- // -// `--mode vendor` and `--mode host` are UNDOCUMENTED spellings accepted for -// muscle-memory reasons (they match the boolean flag names). They are clap -// value aliases on the `ScanMode` variants, which clap keeps out of help -// output — the tests below lock in both the acceptance and the hiding. +// `--mode vendor`, `--mode host`, and `--mode redirect` are UNDOCUMENTED +// spellings accepted for muscle-memory reasons (they match the legacy +// boolean flag names / the old mode name). They are clap value aliases on +// the `ScanMode` variants, which clap keeps out of help output — the tests +// below lock in both the acceptance and the hiding. `--mode apply` is +// deliberately NOT an alias: applying is not a scan mode name anywhere +// (the canonical name is `agent`), so it must stay rejected. #[test] #[serial_test::serial] @@ -801,6 +805,46 @@ fn mode_alias_host_folds_to_redirect() { ); } +#[test] +#[serial_test::serial] +fn mode_alias_redirect_folds_to_hosted() { + // `redirect` is the legacy FLAG spelling (`--redirect`); pre-fix the + // value aliases were asymmetric — `--mode vendor`/`--mode host` parsed + // while `--mode redirect` was rejected, even though `--redirect` itself + // still folds to hosted. + assert_eq!( + parse_scan(&["--mode", "redirect"]).mode, + Some(ScanMode::Hosted) + ); + let folded = parse_and_resolve(&["--mode", "redirect"]).expect("fold ok"); + assert_eq!( + folded.mode, + Some(ScanMode::Hosted), + "--mode redirect (hidden alias) == --mode hosted" + ); + // The alias agrees with its own boolean: redundant, not contradictory. + let folded = parse_and_resolve(&["--mode", "redirect", "--redirect"]).expect("fold ok"); + assert_eq!(folded.mode, Some(ScanMode::Hosted)); +} + +#[test] +#[serial_test::serial] +fn mode_apply_stays_rejected() { + // `apply` is NOT a scan mode name (canonical: `agent`); accepting it + // would mint a fourth spelling nothing else recognizes. Clap must + // reject it at parse time like any unknown value. + let parsed = + with_clean_env(|| Cli::try_parse_from(["socket-patch", "scan", "--mode", "apply"])); + let Err(err) = parsed else { + panic!("--mode apply must be rejected"); + }; + let rendered = err.to_string(); + assert!( + rendered.contains("apply"), + "the error must echo the rejected value: {rendered}" + ); +} + #[test] #[serial_test::serial] fn mode_alias_host_with_vendor_boolean_errors_with_canonical_name() { @@ -852,7 +896,7 @@ fn mode_aliases_hidden_from_help() { "scan --help must itemize `{canonical}`; help was:\n{long}" ); } - for alias in ["host:", "vendor:"] { + for alias in ["host:", "vendor:", "redirect:"] { // "host:" is NOT a substring of "hosted:" (the canonical item has // 'e' after "host"), so any literal hit is a genuine leak. assert!( diff --git a/crates/socket-patch-cli/tests/cli_parse_vendor.rs b/crates/socket-patch-cli/tests/cli_parse_vendor.rs index 64d94830..33606eb8 100644 --- a/crates/socket-patch-cli/tests/cli_parse_vendor.rs +++ b/crates/socket-patch-cli/tests/cli_parse_vendor.rs @@ -58,6 +58,7 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_LOCK_TIMEOUT", "SOCKET_DEBUG", "SOCKET_TELEMETRY_DISABLED", + "SOCKET_NO_TRUST_LOCKFILE_CONFIG", // VendorArgs-specific "SOCKET_FORCE", "SOCKET_VENDOR_REVERT", diff --git a/crates/socket-patch-cli/tests/cli_parse_vex.rs b/crates/socket-patch-cli/tests/cli_parse_vex.rs index 6e8328d7..65543c6b 100644 --- a/crates/socket-patch-cli/tests/cli_parse_vex.rs +++ b/crates/socket-patch-cli/tests/cli_parse_vex.rs @@ -57,6 +57,7 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_LOCK_TIMEOUT", "SOCKET_DEBUG", "SOCKET_TELEMETRY_DISABLED", + "SOCKET_NO_TRUST_LOCKFILE_CONFIG", // VexArgs / VexEmbedArgs "SOCKET_VEX", "SOCKET_VEX_OUTPUT", @@ -213,6 +214,7 @@ struct Snap { lock_timeout: Option, debug: bool, no_telemetry: bool, + no_trust_lockfile_config: bool, output: Option, product: Option, no_verify: bool, @@ -245,6 +247,7 @@ fn snapshot(a: &VexArgs) -> Snap { lock_timeout: a.common.lock_timeout, debug: a.common.debug, no_telemetry: a.common.no_telemetry, + no_trust_lockfile_config: a.common.no_trust_lockfile_config, output: a.output.clone(), product: a.product.clone(), no_verify: a.no_verify, @@ -284,6 +287,7 @@ fn expected_defaults() -> Snap { lock_timeout: None, debug: false, no_telemetry: false, + no_trust_lockfile_config: false, output: None, product: None, no_verify: false, diff --git a/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs new file mode 100644 index 00000000..fee0f6e6 --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs @@ -0,0 +1,1218 @@ +//! Real-install redirect capstone e2e for pnpm — the pnpm counterpart of +//! `tests/e2e_redirect_npm_build.rs`. +//! +//! `scan --mode hosted` never lands patched bytes in the repo: it splices the +//! patched package's `resolution:` in pnpm-lock.yaml to `{integrity: +//! , tarball: }` (a wiremock standing in for +//! patch.socket.dev) and records the patch in the redirect ledger. The +//! corepack legs prove every link of that chain against the REAL pnpm: +//! +//! 1. `corepack pnpm@ install left-pad@1.3.0` into a tempdir project +//! (network used for fixture setup only, private `--store-dir`). +//! 2. Build a PATCHED tarball from the actually-installed bytes (marker +//! comment prepended to `index.js`) and serve it from wiremock, alongside +//! the discovery / reference / view API mocks. +//! 3. `scan --mode hosted --json --yes` (the real binary): the lock's +//! `resolution:` now pins the wiremock tarball URL + the patched +//! tarball's sha512, the ledger holds the `redirect_pnpm_resolution` +//! edit + the patch record, and a second scan is idempotent (lock +//! byte-stable, no duplicate ledger edits). +//! 4. FRESH-CHECKOUT PROOF: only package.json + pnpm-lock.yaml + `.socket/` +//! travel, the `.npmrc` registry points at a DEAD port, the store is +//! empty — `pnpm install --frozen-lockfile` MUST land the marker bytes, +//! because the only reachable artifact URL is the hosted tarball. +//! +//! The tamper twin serves DIFFERENT bytes under the honest sha512 pin: the +//! fresh install must FAIL on the integrity check — the lockfile pin is +//! enforcement, not decoration. +//! +//! Version ladder (the `e2e_vendor_pnpm_build.rs` convention): pnpm@10 is the +//! PRIMARY leg (skips only when corepack/pnpm is unfetchable or the fixture +//! install cannot reach the registry); pnpm@7, pnpm@8, pnpm@9 and pnpm@11 are +//! opportunistic. The pnpm@7/@8 legs prove the LEGACY lock grammars end to +//! end: their pnpm-emitted v5.4 / v6 locks are spliced by the same rewrite +//! and both majors frozen-install the hosted tarball from an empty store +//! (verified live 2026-08-18, corepack pnpm@7.33.5 / pnpm@8.15.9). +//! +//! TRUST AUTO-CONFIG: a scan that rewrites a ROOT v9 lock also ensures +//! `trustLockfile: true` in pnpm-workspace.yaml (ledger edit kind +//! `redirect_pnpm_workspace_trust`; the workspace file joins +//! `rewrittenFiles`), because pnpm >=11's lockfile supply-chain policy +//! rejects the rewritten lock otherwise. Legacy 5.x/6.0 locks mean pnpm 7/8 +//! — no policy, no setting — so they rewrite ONLY the lock and keep the +//! manual `--trust-lockfile` guidance (the gate is lock-major >= 9). Two +//! pnpm@11 legs pin both sides empirically: the ZERO-TOUCH leg commits the +//! scan-written workspace file and the plain dead-registry frozen install +//! succeeds with NO flags; the `--no-trust-lockfile-config` control pins the +//! opt-out (no workspace write) plus the old behavior it restores — the +//! plain frozen install fails against a dead registry +//! (ERR_PNPM_META_FETCH_FAIL there — ERR_PNPM_TARBALL_URL_MISMATCH needs +//! reachable registry metadata) and the manual `--trust-lockfile` flag +//! recovers. +//! +//! Two synthetic legs need no pnpm at all (hermetic wiremock, never ignored), +//! pinning the rewrite grammar against byte-accurate locks captured from the +//! 2026-08-18 pnpm matrix sweep: a v5.4 lock (`/name/version:` key) and a v6 +//! plain key (`/name@version:`) each splice in place with sibling lines +//! byte-preserved. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +use sha2::{Digest, Sha512}; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[path = "common/cache_env.rs"] +mod cache_env; + +const ORG: &str = "test-org"; +const DEP: &str = "left-pad"; +const DEP_VERSION: &str = "1.3.0"; +const PURL: &str = "pkg:npm/left-pad@1.3.0"; +/// Canonical lowercase patch uuid (a dedicated path level of the hosted URL). +const UUID: &str = "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d"; +/// Access-token uuid segment of the hosted download URL (opaque to the CLI — +/// it just writes the URL the reference endpoint hands back). +const TOKEN: &str = "22222222-2222-4222-8222-222222222222"; +/// Marker prepended to the dep's entry point by the synthetic patch. +const MARKER: &str = "/* SOCKET-PATCHED */\n"; +const GHSA: &str = "GHSA-redirect-pnpm"; +/// Pinned pnpm majors via corepack — @10 is the required leg, the others are +/// opportunistic (the vendor capstone's ladder convention). @7 and @8 are the +/// legacy-lock legs: they emit lockfileVersion 5.4 / 6.0, proving the v5/v6 +/// rewrite installs for real. +const PNPM_PRIMARY: &str = "pnpm@10"; +const PNPM_SECONDARY: &str = "pnpm@9"; +const PNPM_TERTIARY: &str = "pnpm@11"; +const PNPM_LEGACY_V5: &str = "pnpm@7"; +const PNPM_LEGACY_V6: &str = "pnpm@8"; +/// left-pad@1.3.0's registry integrity, byte-accurate from the matrix legs' +/// pnpm-emitted locks — the synthetic legs' pristine `resolution:` value. +const UPSTREAM_SHA512: &str = "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="; +/// Synthetic patched integrity for the no-install legs (nothing downloads it, +/// so it only has to be distinct from the upstream value). +const PATCHED_SHA512: &str = "sha512-PATCHEDpatchedPATCHEDpatched0123456789=="; + +// ── self-contained helpers ──────────────────────────────────────────── + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_socket-patch")) +} + +/// Probe corepack from a NEUTRAL temp dir (a `packageManager` field in an +/// ancestor package.json — e.g. this monorepo root — otherwise makes corepack +/// refuse a different manager). +fn has_corepack_pm(pm: &str) -> bool { + let Ok(probe) = tempfile::tempdir() else { + return false; + }; + // Isolated too: this probe is what actually downloads the package manager + // the first time, and corepack stores it under `COREPACK_HOME`. + let mut cmd = Command::new("corepack"); + cmd.args([pm, "--version"]) + .current_dir(probe.path()) + .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0"); + cache_env::isolate(&mut cmd); + cmd.stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Remove ambient `SOCKET_*` / `PNPM_*` / `npm_config_*` vars. +/// +/// Seed-then-scrub (mirrors e2e_vendor_pnpm_build.rs): pnpm lets EVERY +/// `.npmrc` setting be overridden by an `npm_config_*` env var (env outranks +/// the project npmrc), so an ambient `npm_config_node_linker=pnp` alone can +/// turn a capstone red. The explicit env_remove below clears the seed too, +/// but if the prefix scrub is ever dropped the seed (rather than a +/// developer's ambient shell, which this suite can't rely on) turns the test +/// red immediately. +fn scrub_socket_env(cmd: &mut Command) { + cmd.env("npm_config_node_linker", "pnp"); + for (k, _) in std::env::vars_os() { + let key = k.to_string_lossy(); + if (key.starts_with("SOCKET_") + || key.starts_with("PNPM_") + || key.to_ascii_lowercase().starts_with("npm_config_")) + && key != "SOCKET_NO_CONFIG" + { + cmd.env_remove(&k); + } + } + cmd.env_remove("VIRTUAL_ENV"); + cmd.env_remove("npm_config_node_linker"); +} + +fn corepack(cwd: &Path, pm: &str, args: &[&str]) -> Output { + let mut cmd = Command::new("corepack"); + cmd.arg(pm).args(args).current_dir(cwd); + scrub_socket_env(&mut cmd); + // After the scrub: it strips ambient `PNPM_*` / `npm_config_*`, which + // would otherwise take the sandbox values back out again. + cache_env::isolate(&mut cmd); + cmd.env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0"); + cmd.output().expect("failed to run corepack") +} + +fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { + let mut cmd = Command::new(binary()); + cmd.args(args).current_dir(cwd); + scrub_socket_env(&mut cmd); + let out = cmd.output().expect("failed to run socket-patch binary"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +/// `scan --mode hosted` (the real binary) over the project at `root`. +/// `extra_args` rides at the end (e.g. `--no-trust-lockfile-config`). +fn scan_hosted(root: &Path, api_url: &str, extra_args: &[&str]) -> (i32, String, String) { + let mut args = vec![ + "scan", + "--mode", + "hosted", + "--json", + "--yes", + "--cwd", + root.to_str().unwrap(), + "--api-url", + api_url, + "--org", + ORG, + "--api-token", + "fake", + ]; + args.extend_from_slice(extra_args); + run_socket(root, &args) +} + +fn parse_envelope(stdout: &str) -> serde_json::Value { + serde_json::from_str(stdout).unwrap_or_else(|e| { + panic!("scan --mode hosted --json output is not JSON: {e}\nstdout:\n{stdout}") + }) +} + +fn warning_codes(env: &serde_json::Value) -> Vec { + env["redirect"]["warnings"] + .as_array() + .map(|ws| { + ws.iter() + .map(|w| w["code"].as_str().unwrap_or("").to_string()) + .collect() + }) + .unwrap_or_default() +} + +/// Standard-base64-encoded sha512 of `bytes` — the body of the npm-family +/// `sha512-…` SRI integrity string. +fn sha512_sri_b64(bytes: &[u8]) -> String { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD.encode(Sha512::digest(bytes)) +} + +fn copy_dir_recursive(src: &Path, dst: &Path) { + std::fs::create_dir_all(dst).unwrap(); + for entry in std::fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let to = dst.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy_dir_recursive(&entry.path(), &to); + } else { + std::fs::copy(entry.path(), &to).unwrap(); + } + } +} + +fn hosted_url_for(base: &str) -> String { + format!("{base}/patch/npm/{DEP}/{DEP_VERSION}/{TOKEN}/{UUID}/{DEP}-{DEP_VERSION}.tgz") +} + +/// A patched npm tarball built from the ACTUALLY-installed package: every +/// installed file travels under the `package/` prefix, with the entry point +/// swapped for `patched_index`. Built with the tar crate rather than a system +/// `tar` so the suite has no external-binary dependency (pnpm installs from +/// tar-crate output fine — `e2e_redirect_rush_sim.rs` proved it). +fn make_tgz_from_installed(pkg_dir: &Path, patched_index: &[u8]) -> Vec { + // node_modules/ is a symlink into .pnpm under pnpm's layout, and a + // symlinked dir must be walked through its real path. + let pkg_dir = pkg_dir + .canonicalize() + .expect("installed package dir must resolve"); + let mut files: Vec = Vec::new(); + let mut stack = vec![pkg_dir.clone()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).unwrap() { + let p = entry.unwrap().path(); + if p.is_dir() { + stack.push(p); + } else { + files.push(p); + } + } + } + files.sort(); + let mut builder = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::default(), + )); + for p in &files { + let rel = p.strip_prefix(&pkg_dir).unwrap(); + // Tar entry names always use `/` regardless of host separator. + let name = format!( + "package/{}", + rel.components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect::>() + .join("/") + ); + let bytes = if rel == Path::new("index.js") { + patched_index.to_vec() + } else { + std::fs::read(p).unwrap() + }; + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder + .append_data(&mut header, &name, bytes.as_slice()) + .unwrap(); + } + builder.into_inner().unwrap().finish().unwrap() +} + +/// Mount discovery + by-package + reference + view (same contract as +/// `tests/in_process_redirect_pnpm.rs` / `e2e_redirect_npm_build.rs`). +/// `before_hash`/`after_hash` are the view record's file hashes. +async fn mount_api_mocks( + server: &MockServer, + hosted_url: &str, + sri: &str, + before_hash: &str, + after_hash: &str, +) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [{ + "purl": PURL, + "patches": [{ + "uuid": UUID, "purl": PURL, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", + "title": "pnpm redirect capstone fixture" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": UUID, "purl": PURL, + "publishedAt": "2026-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "results": { + UUID: { + "status": "granted", + "url": hosted_url, + "purl": PURL, + "artifacts": [{ + "kind": "tarball", + "url": hosted_url, + "integrity": { "sha512": sri } + }], + "registryOverride": null + } + } + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": UUID, + "purl": PURL, + "publishedAt": "2026-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": before_hash, + "afterHash": after_hash, + } + }, + "vulnerabilities": { + GHSA: { + "cves": ["CVE-2026-2222"], + "summary": "pnpm redirect capstone vuln", + "severity": "high", + "description": "d" + } + }, + "description": "x", "license": "MIT", "tier": "free" + }))) + .mount(server) + .await; +} + +/// The hosted tarball route pnpm hits at install time. Separate from the API +/// mocks because the tamper twin serves different bytes than the pinned sri. +async fn mount_tarball_route(server: &MockServer, served: Vec) { + Mock::given(method("GET")) + .and(path(format!( + "/patch/npm/{DEP}/{DEP_VERSION}/{TOKEN}/{UUID}/{DEP}-{DEP_VERSION}.tgz" + ))) + .respond_with(ResponseTemplate::new(200).set_body_raw(served, "application/octet-stream")) + .mount(server) + .await; +} + +/// Everything the post-redirect legs need. `tmp` owns the whole tree; +/// `_server` keeps the hosted-tarball route alive through the fresh installs. +struct PnpmRedirectFixture { + tmp: tempfile::TempDir, + proj: PathBuf, + patched: Vec, + _server: MockServer, +} + +/// Steps 1–3 of the module doc against the REAL `corepack `: fixture +/// install, patched tarball + API mocks, `scan --mode hosted`, and the +/// envelope/lockfile/ledger/idempotency assertions. When +/// `tamper_served_tarball` is set, the tarball route serves DIFFERENT bytes +/// than the sha512 pinned into the lockfile — the negative twin's premise. +/// `no_trust_config` runs every scan with `--no-trust-lockfile-config` and +/// flips the trust-auto-config expectations to the opted-out contract. +/// `None` = skip (message already printed). +async fn redirect_scanned_pnpm_project( + pm: &str, + tag: &str, + tamper_served_tarball: bool, + no_trust_config: bool, +) -> Option { + if !has_corepack_pm(pm) { + println!("SKIP e2e_redirect_pnpm_build ({tag}): `corepack {pm}` unavailable"); + return None; + } + + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + std::fs::write( + proj.join("package.json"), + format!( + r#"{{ "name": "pnpm-redirect-capstone", "version": "0.0.0", "private": true, "dependencies": {{ "{DEP}": "{DEP_VERSION}" }} }}"# + ), + ) + .unwrap(); + + // 1. REAL fixture: pnpm install (network allowed here, private store). + let store = tmp.path().join("pnpm-store"); + let install = corepack( + &proj, + pm, + &["install", "--store-dir", store.to_str().unwrap()], + ); + if !install.status.success() { + println!( + "SKIP e2e_redirect_pnpm_build ({tag}): fixture `{pm} install` failed \ + (registry unreachable?):\n{}", + String::from_utf8_lossy(&install.stderr) + ); + return None; + } + + let orig = std::fs::read(proj.join("node_modules").join(DEP).join("index.js")) + .expect("installed index.js"); + assert!( + !orig.starts_with(MARKER.as_bytes()), + "pristine install must not carry the marker" + ); + let patched: Vec = [MARKER.as_bytes(), orig.as_slice()].concat(); + + // 2. Patched tarball from the ACTUAL installed bytes. The lockfile pin is + // ALWAYS the real tarball's sha512; the negative twin only tampers + // what the route SERVES, so the pin is what catches the swap. + let tgz = make_tgz_from_installed(&proj.join("node_modules").join(DEP), &patched); + let sri = format!("sha512-{}", sha512_sri_b64(&tgz)); + let served: Vec = if tamper_served_tarball { + let tampered: Vec = [b"/* SOCKET-TAMPERED */\n", orig.as_slice()].concat(); + make_tgz_from_installed(&proj.join("node_modules").join(DEP), &tampered) + } else { + tgz.clone() + }; + + // 3. API mocks + the hosted tarball route the fresh installs will hit. + let server = MockServer::start().await; + let hosted_url = hosted_url_for(&server.uri()); + mount_api_mocks( + &server, + &hosted_url, + &sri, + &compute_git_sha256_from_bytes(&orig), + &compute_git_sha256_from_bytes(&patched), + ) + .await; + mount_tarball_route(&server, served).await; + + let lock_path = proj.join("pnpm-lock.yaml"); + let lock_before = std::fs::read_to_string(&lock_path).expect("pnpm-lock.yaml after install"); + let pkg_before = std::fs::read(proj.join("package.json")).unwrap(); + // Whether the fixture install left a workspace file behind decides the + // trust edit's action: "created" (new file) vs "added" (line appended). + let ws_path = proj.join("pnpm-workspace.yaml"); + let ws_existed_before = ws_path.exists(); + // The pristine resolution line — captured (not hardcoded) so "the upstream + // integrity is gone" can be asserted against whatever the registry served. + let upstream_resolution = lock_before + .lines() + .find(|l| l.trim_start().starts_with("resolution: {integrity:")) + .expect("pristine lock must carry an inline resolution") + .to_string(); + + let scan_extra: &[&str] = if no_trust_config { + &["--no-trust-lockfile-config"] + } else { + &[] + }; + let (code, stdout, stderr) = scan_hosted(&proj, &server.uri(), scan_extra); + assert_eq!( + code, 0, + "scan --mode hosted failed ({tag}).\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env = parse_envelope(&stdout); + assert_eq!(env["status"], "success", "envelope: {env}"); + assert_eq!( + env["redirect"]["redirected"], 1, + "exactly one dep redirected: {env}" + ); + // The zero-touch trustLockfile auto-config fires only for root v9 locks + // (and not under `--no-trust-lockfile-config`): pnpm 7/8 (5.x/6.0) have + // neither the lockfile policy nor the setting, so legacy runs rewrite + // ONLY the lock and keep the manual flag guidance. + let v9_lock = lock_before.starts_with("lockfileVersion: '9.0'"); + let auto_trust = v9_lock && !no_trust_config; + let expected_rewrites = if auto_trust { + serde_json::json!(["pnpm-lock.yaml", "pnpm-workspace.yaml"]) + } else { + serde_json::json!(["pnpm-lock.yaml"]) + }; + assert_eq!( + env["redirect"]["rewrittenFiles"], expected_rewrites, + "the rewritten set must match the lock's grammar + trust config ({tag}): {env}" + ); + // The install-guidance warning: assert the CODE and the recovery's stable + // spelling only — the detail prose is not part of the contract. + assert!( + warning_codes(&env).contains(&"redirect_pnpm_trust_lockfile".to_string()), + "a landed pnpm rewrite must warn about pnpm >=11 installs: {env}" + ); + let trust_detail = env["redirect"]["warnings"] + .as_array() + .unwrap() + .iter() + .find(|w| w["code"] == "redirect_pnpm_trust_lockfile") + .and_then(|w| w["detail"].as_str()) + .unwrap_or_default() + .to_string(); + if auto_trust { + assert!( + trust_detail.contains("trustLockfile: true"), + "the v9 warning must name the auto-configured trustLockfile key; got: {trust_detail}" + ); + } else if v9_lock { + // Opted out on a v9 lock: the manual two-recovery guidance stands. + assert!( + trust_detail.contains("trust-lockfile"), + "the opted-out v9 warning must name the manual trust-lockfile recovery; \ + got: {trust_detail}" + ); + } else { + // Legacy (5.x/6.0) lock: pnpm 7/8 reject `--trust-lockfile` as an + // unknown option, so the guidance must never mention it — installs + // work unchanged and no trust step exists on those majors. + assert!( + !trust_detail.contains("trust-lockfile"), + "the legacy-lock warning must not recommend --trust-lockfile (pnpm 7/8 \ + reject the flag); got: {trust_detail}" + ); + assert!( + trust_detail.contains("pnpm 7/8") && trust_detail.contains("installs work unchanged"), + "the legacy-lock warning must say installs work unchanged on pnpm 7/8; \ + got: {trust_detail}" + ); + } + + // Trust auto-config surface: the workspace file itself. Auto runs write + // `trustLockfile: true`; legacy / opted-out runs must leave the file + // exactly as the fixture install left it (absent, for these fixtures). + let ws_after_scan = if auto_trust { + let ws = std::fs::read_to_string(&ws_path) + .expect("a v9 rewrite must auto-write pnpm-workspace.yaml"); + assert!( + ws.contains("trustLockfile: true"), + "the scan-written workspace file must carry the trust key ({tag}); got:\n{ws}" + ); + Some(ws) + } else { + assert_eq!( + ws_path.exists(), + ws_existed_before, + "a legacy-lock or --no-trust-lockfile-config run must not create \ + pnpm-workspace.yaml ({tag})" + ); + None + }; + + // Lock splice: `{integrity: , tarball: }` with + // the upstream resolution line fully replaced; package.json untouched + // (hosted mode edits only the lock). + let lock_after = std::fs::read_to_string(&lock_path).unwrap(); + assert!( + lock_after.contains(&format!( + "resolution: {{integrity: {sri}, tarball: {hosted_url}}}" + )), + "resolution must be spliced to the patched sri + hosted tarball; got:\n{lock_after}" + ); + assert!( + !lock_after.contains(&upstream_resolution), + "the upstream resolution line must be replaced; got:\n{lock_after}" + ); + assert_eq!( + std::fs::read(proj.join("package.json")).unwrap(), + pkg_before, + "hosted mode must not edit package.json" + ); + + // Ledger: the lock edit (with the original resolution preserved for + // revert) + the embedded patch record a post-install `vex` verifies. + let ledger_path = proj.join(".socket/vendor/redirect-state.json"); + let ledger: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&ledger_path).unwrap()).unwrap(); + let edits = ledger["edits"].as_array().unwrap().clone(); + assert!( + edits.iter().any(|e| e["kind"] == "redirect_pnpm_resolution" + && e["key"] == format!("{DEP}@{DEP_VERSION}") + && e["path"] == "pnpm-lock.yaml"), + "the ledger must record the redirect_pnpm_resolution edit: {ledger}" + ); + let trust_edits: Vec<&serde_json::Value> = edits + .iter() + .filter(|e| e["kind"] == "redirect_pnpm_workspace_trust") + .collect(); + if auto_trust { + // Exactly one trust edit, so `--revert` unwinds exactly one write. + assert_eq!( + trust_edits.len(), + 1, + "a v9 rewrite must record exactly one workspace trust edit: {ledger}" + ); + let edit = trust_edits[0]; + assert_eq!(edit["path"], "pnpm-workspace.yaml", "trust edit: {edit}"); + assert_eq!(edit["key"], "trustLockfile", "trust edit: {edit}"); + // "created" = new file (revert deletes it); "added" = line appended + // to a pre-existing file (revert removes only that line). + let expected_action = if ws_existed_before { + "added" + } else { + "created" + }; + assert_eq!(edit["action"], expected_action, "trust edit: {edit}"); + } else { + assert!( + trust_edits.is_empty(), + "legacy-lock / --no-trust-lockfile-config runs must record no workspace \ + trust edit: {ledger}" + ); + } + assert!( + ledger["records"][PURL]["vulnerabilities"][GHSA].is_object(), + "the ledger must embed the patch record + vulnerability: {ledger}" + ); + + // Idempotency: the second scan still counts the dep as redirected (the + // hosted URL is already in the lock) but rewrites nothing — lock AND + // workspace file byte-stable — and appends no duplicate edits (which + // would poison a revert). + let (code, stdout, stderr) = scan_hosted(&proj, &server.uri(), scan_extra); + assert_eq!( + code, 0, + "second scan --mode hosted failed ({tag}).\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env2 = parse_envelope(&stdout); + assert_eq!( + env2["redirect"]["redirected"], 1, + "an already-redirected dep still counts: {env2}" + ); + assert_eq!( + env2["redirect"]["rewrittenFiles"], + serde_json::json!([]), + "the re-run must rewrite nothing: {env2}" + ); + assert_eq!( + std::fs::read_to_string(&lock_path).unwrap(), + lock_after, + "the re-run must leave the lock byte-stable" + ); + if let Some(ws) = &ws_after_scan { + assert_eq!( + &std::fs::read_to_string(&ws_path).unwrap(), + ws, + "the re-run must leave pnpm-workspace.yaml byte-stable" + ); + } + let ledger2: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&ledger_path).unwrap()).unwrap(); + assert_eq!( + edits.len(), + ledger2["edits"].as_array().unwrap().len(), + "a re-run must not append duplicate ledger edits: {ledger2}" + ); + + Some(PnpmRedirectFixture { + tmp, + proj, + patched, + _server: server, + }) +} + +/// New dir holding ONLY what a git checkout would carry — package.json, +/// pnpm-lock.yaml, `.socket/` (plus, when `with_workspace_yaml`, the +/// `trustLockfile: true` pnpm-workspace.yaml the scan wrote — the zero-touch +/// committed checkout) — with the registry pointed at a DEAD port and an +/// EMPTY store, then `corepack install --frozen-lockfile` (+ +/// `extra_args`). Returns the fresh dir and the pnpm output (asserted by each +/// leg: success for the real tarball, integrity failure for the tampered +/// one, policy failure for pnpm 11 without the trust config or flag). +fn fresh_checkout_install( + fx: &PnpmRedirectFixture, + pm: &str, + label: &str, + extra_args: &[&str], + with_workspace_yaml: bool, +) -> (PathBuf, Output) { + let fresh = fx.tmp.path().join(format!("fresh-{label}")); + std::fs::create_dir_all(&fresh).unwrap(); + std::fs::copy(fx.proj.join("package.json"), fresh.join("package.json")).unwrap(); + std::fs::copy(fx.proj.join("pnpm-lock.yaml"), fresh.join("pnpm-lock.yaml")).unwrap(); + if with_workspace_yaml { + std::fs::copy( + fx.proj.join("pnpm-workspace.yaml"), + fresh.join("pnpm-workspace.yaml"), + ) + .expect("the v9 scan must have written pnpm-workspace.yaml"); + } + copy_dir_recursive(&fx.proj.join(".socket"), &fresh.join(".socket")); + // Dead registry: the only reachable artifact URL is the wiremock hosted + // tarball, so a successful install can only have come from it. The retry + // clamps keep the negative legs from pnpm's default 10s + 60s retry + // ladder against the dead port (the max/min timeouts back the retry + // count up, should a pnpm major ever treat 0 as unset). + std::fs::write( + fresh.join(".npmrc"), + "registry=http://127.0.0.1:1/\n\ + fetch-retries=0\n\ + fetch-retry-mintimeout=100\n\ + fetch-retry-maxtimeout=500\n", + ) + .unwrap(); + let fresh_store = fx.tmp.path().join(format!("fresh-store-{label}")); + let mut args = vec![ + "install", + "--frozen-lockfile", + "--store-dir", + fresh_store.to_str().unwrap(), + ]; + args.extend_from_slice(extra_args); + let out = corepack(&fresh, pm, &args); + (fresh, out) +} + +fn assert_marker_landed(fresh: &Path, patched: &[u8], ci: &Output, tag: &str) { + assert!( + ci.status.success(), + "fresh-checkout `pnpm install --frozen-lockfile` must succeed from the hosted \ + patch tarball ({tag}).\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr), + ); + let installed = std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(); + assert!( + installed.starts_with(MARKER.as_bytes()), + "pnpm must install the PATCHED bytes from the hosted patch ({tag}); got:\n{}", + String::from_utf8_lossy(&installed[..installed.len().min(120)]) + ); + assert_eq!( + installed, patched, + "fresh install must be byte-identical to the patched content ({tag})" + ); +} + +// ── corepack legs (gating mirrors e2e_redirect_rush_sim.rs) ─────────── + +// multi_thread: the CLI/pnpm subprocesses block a worker thread while +// wiremock keeps serving the API + tarball routes on the others. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +#[ignore = "wall-bound real-pnpm install (~60s); runs on all 3 OSes as an e2e CI matrix leg"] +async fn pnpm10_redirect_fresh_checkout_frozen_install_lands_patched_bytes() { + let Some(fx) = redirect_scanned_pnpm_project(PNPM_PRIMARY, "pnpm10", false, false).await else { + return; + }; + + // 4. FRESH-CHECKOUT PROOF: pnpm pulls the patched bytes from the hosted + // patch server because the committed lockfile says so. + let (fresh, ci) = fresh_checkout_install(&fx, PNPM_PRIMARY, "pnpm10", &[], false); + assert_marker_landed(&fresh, &fx.patched, &ci, "pnpm10"); +} + +/// Negative twin: the hosted route serves TAMPERED bytes while the lockfile +/// pins the REAL tarball's sha512 — the fresh frozen install must refuse to +/// install and must not land the marker. This is what makes the redirect +/// safe to commit: a compromised or swapped hosted artifact cannot slip past +/// the pin. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +#[ignore = "wall-bound real-pnpm install (~60s); runs on all 3 OSes as an e2e CI matrix leg"] +async fn pnpm10_redirect_tampered_hosted_tarball_fails_fresh_frozen_install() { + let Some(fx) = + redirect_scanned_pnpm_project(PNPM_PRIMARY, "pnpm10-tampered", true, false).await + else { + return; + }; + + let (fresh, ci) = fresh_checkout_install(&fx, PNPM_PRIMARY, "pnpm10-tampered", &[], false); + assert!( + !ci.status.success(), + "pnpm MUST fail when the served tarball does not match the pinned sha512.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr), + ); + let chatter = format!( + "{}\n{}", + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr) + ); + assert!( + chatter.to_lowercase().contains("integrity") + || chatter.to_lowercase().contains("checksum") + || chatter.contains("ERR_PNPM"), + "the failure must be the integrity check, not something incidental:\n{chatter}" + ); + // The marker bytes must not have landed anywhere pnpm links from. + if let Ok(bytes) = std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")) { + assert!( + !bytes.starts_with(MARKER.as_bytes()), + "no marker bytes may land from a tampered tarball" + ); + } +} + +/// Opportunistic pnpm@9 leg (the vendor capstone's secondary convention): +/// same positive chain, no tamper twin needed — @10 already carries it. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +#[ignore = "wall-bound real-pnpm install (~60s); runs on all 3 OSes as an e2e CI matrix leg"] +async fn pnpm9_redirect_fresh_checkout_frozen_install_lands_patched_bytes() { + let Some(fx) = redirect_scanned_pnpm_project(PNPM_SECONDARY, "pnpm9", false, false).await + else { + return; + }; + let (fresh, ci) = fresh_checkout_install(&fx, PNPM_SECONDARY, "pnpm9", &[], false); + assert_marker_landed(&fresh, &fx.patched, &ci, "pnpm9"); +} + +/// pnpm@11 ZERO-TOUCH leg: pnpm 11's lockfile supply-chain policy verifies +/// each resolution's tarball URL against registry metadata and would reject +/// the rewritten lock, but the scan auto-writes `trustLockfile: true` into +/// pnpm-workspace.yaml — so a fresh checkout that carries the scan's outputs +/// (the workspace file is scan-written and commit-intended, exactly like the +/// lock) frozen-installs against the DEAD registry with NO FLAGS and lands +/// the marker bytes. This is the shipped headline: CI needs no modification. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +#[ignore = "wall-bound real-pnpm install (~60s); runs on all 3 OSes as an e2e CI matrix leg"] +async fn pnpm11_zero_touch_frozen_install_lands_patched_bytes_via_auto_trust_config() { + let Some(fx) = redirect_scanned_pnpm_project(PNPM_TERTIARY, "pnpm11", false, false).await + else { + return; + }; + let (fresh, ci) = fresh_checkout_install(&fx, PNPM_TERTIARY, "pnpm11-zero-touch", &[], true); + assert_marker_landed(&fresh, &fx.patched, &ci, "pnpm11 zero-touch"); +} + +/// `--no-trust-lockfile-config` control: pins the opt-out (the scan writes +/// no pnpm-workspace.yaml — asserted inside the fixture helper) AND the old +/// behavior it restores. Without the trust config the PLAIN frozen install +/// fails against the dead registry — with ERR_PNPM_META_FETCH_FAIL, not the +/// live-registry-only ERR_PNPM_TARBALL_URL_MISMATCH, so only the ERR_PNPM +/// family and the non-zero exit are asserted (and no claim is made about the +/// marker: pnpm downloads the hosted tarball before the policy check fails). +/// The manual `--trust-lockfile` flag recovery must then succeed against the +/// same dead registry and land the marker bytes. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +#[ignore = "wall-bound real-pnpm install (~60s); runs on all 3 OSes as an e2e CI matrix leg"] +async fn pnpm11_no_trust_config_opt_out_frozen_install_needs_manual_trust_lockfile() { + let Some(fx) = + redirect_scanned_pnpm_project(PNPM_TERTIARY, "pnpm11-opt-out", false, true).await + else { + return; + }; + + let (_fresh, plain) = + fresh_checkout_install(&fx, PNPM_TERTIARY, "pnpm11-opt-out-plain", &[], false); + assert!( + !plain.status.success(), + "pnpm 11's lockfile policy must reject the plain frozen install against a dead \ + registry when the scan was opted out of the trustLockfile config.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&plain.stdout), + String::from_utf8_lossy(&plain.stderr), + ); + let chatter = format!( + "{}\n{}", + String::from_utf8_lossy(&plain.stdout), + String::from_utf8_lossy(&plain.stderr) + ); + assert!( + chatter.contains("ERR_PNPM"), + "the plain-install failure must be a pnpm error, not something incidental:\n{chatter}" + ); + + // Manual recovery: the per-run flag, exactly as the warning detail says. + let (fresh, trusted) = fresh_checkout_install( + &fx, + PNPM_TERTIARY, + "pnpm11-opt-out-trust", + &["--trust-lockfile"], + false, + ); + assert_marker_landed(&fresh, &fx.patched, &trusted, "pnpm11 --trust-lockfile"); +} + +/// Legacy pnpm@7 leg: the fixture install emits a lockfileVersion 5.4 lock +/// (`/name/version:` path-style key), the scan splices its resolution like +/// any other grammar, and the fresh dead-registry frozen install proves +/// pnpm 7 fetches the hosted tarball from the spliced entry and enforces the +/// sha512 pin (empty store, marker bytes land). +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +#[ignore = "wall-bound real-pnpm install (~60s); runs on all 3 OSes as an e2e CI matrix leg"] +async fn pnpm7_v5_lock_redirect_fresh_checkout_frozen_install_lands_patched_bytes() { + let Some(fx) = redirect_scanned_pnpm_project(PNPM_LEGACY_V5, "pnpm7", false, false).await + else { + return; + }; + let lock = std::fs::read_to_string(fx.proj.join("pnpm-lock.yaml")).unwrap(); + assert!( + lock.starts_with("lockfileVersion: 5.4") + && lock.contains(&format!("/{DEP}/{DEP_VERSION}:")), + "anchor: pnpm@7 must have emitted a v5.4 path-style lock; got:\n{lock}" + ); + let (fresh, ci) = fresh_checkout_install(&fx, PNPM_LEGACY_V5, "pnpm7", &[], false); + assert_marker_landed(&fresh, &fx.patched, &ci, "pnpm7"); +} + +/// Legacy pnpm@8 leg: same chain over the lockfileVersion 6.0 grammar +/// (`/name@version:` key). pnpm 8 has no lockfile supply-chain policy, so the +/// plain frozen install must succeed against the dead registry. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +#[ignore = "wall-bound real-pnpm install (~60s); runs on all 3 OSes as an e2e CI matrix leg"] +async fn pnpm8_v6_lock_redirect_fresh_checkout_frozen_install_lands_patched_bytes() { + let Some(fx) = redirect_scanned_pnpm_project(PNPM_LEGACY_V6, "pnpm8", false, false).await + else { + return; + }; + let lock = std::fs::read_to_string(fx.proj.join("pnpm-lock.yaml")).unwrap(); + assert!( + lock.starts_with("lockfileVersion: '6.0'") + && lock.contains(&format!("/{DEP}@{DEP_VERSION}:")), + "anchor: pnpm@8 must have emitted a v6 lock; got:\n{lock}" + ); + let (fresh, ci) = fresh_checkout_install(&fx, PNPM_LEGACY_V6, "pnpm8", &[], false); + assert_marker_landed(&fresh, &fx.patched, &ci, "pnpm8"); +} + +// ── synthetic legs (hermetic — no pnpm binary, never ignored) ───────── + +/// A project whose only lockfile is the synthesized `lock`, with an installed +/// node_modules stub so the crawler discovers the dep (a real pnpm project +/// always has one). +fn write_synthetic_project(root: &Path, lock: &str) { + std::fs::write( + root.join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{DEP}": "{DEP_VERSION}" }} }}"# + ), + ) + .unwrap(); + let pkg = root.join("node_modules").join(DEP); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{DEP}", "version": "{DEP_VERSION}" }}"#), + ) + .unwrap(); + std::fs::write(root.join("pnpm-lock.yaml"), lock).unwrap(); +} + +/// Byte-accurate pnpm 7 (lockfileVersion 5.4) lock, copied from the matrix +/// sweep's hosted-pnpm7 fixture: unquoted `5.4`, `/name/version:` package +/// key, `specifiers:` section, `dev: false` flag. +fn v5_lock() -> String { + format!( + "lockfileVersion: 5.4 + +specifiers: + {DEP}: {DEP_VERSION} + +dependencies: + {DEP}: {DEP_VERSION} + +packages: + + /{DEP}/{DEP_VERSION}: + resolution: {{integrity: {UPSTREAM_SHA512}}} + deprecated: use String.prototype.padStart() + dev: false +" + ) +} + +/// Byte-accurate pnpm 8 (lockfileVersion '6.0') lock from the matrix sweep's +/// hosted-pnpm8 fixture: quoted `'6.0'`, `/name@version:` package key. +fn v6_lock() -> String { + format!( + "lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +dependencies: + {DEP}: + specifier: {DEP_VERSION} + version: {DEP_VERSION} + +packages: + + /{DEP}@{DEP_VERSION}: + resolution: {{integrity: {UPSTREAM_SHA512}}} + deprecated: use String.prototype.padStart() + dev: false +" + ) +} + +/// pnpm v5.x lock keys (`/name/version:`) are inside the redirect grammar: +/// the resolution is spliced in place with the path-style key and every +/// sibling line (`deprecated:`, `dev:`) byte-preserved — proven installable +/// by the gated pnpm@7 leg above (matrix: hosted-pnpm7, live splice-install +/// verification 2026-08-18). +#[tokio::test(flavor = "multi_thread")] +async fn pnpm_v5_lock_key_rewrite_splices_in_place() { + let server = MockServer::start().await; + let hosted_url = hosted_url_for("http://patch.test"); + mount_api_mocks( + &server, + &hosted_url, + PATCHED_SHA512, + &"a".repeat(64), + &"b".repeat(64), + ) + .await; + + let tmp = tempfile::tempdir().unwrap(); + write_synthetic_project(tmp.path(), &v5_lock()); + let lock_path = tmp.path().join("pnpm-lock.yaml"); + + let (code, stdout, stderr) = scan_hosted(tmp.path(), &server.uri(), &[]); + assert_eq!( + code, 0, + "scan --mode hosted failed on the v5 lock.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env = parse_envelope(&stdout); + assert_eq!(env["status"], "success", "envelope: {env}"); + assert_eq!( + env["redirect"]["redirected"], 1, + "the v5 path-style key must be redirectable: {env}" + ); + assert_eq!( + env["redirect"]["rewrittenFiles"], + serde_json::json!(["pnpm-lock.yaml"]), + "the v5 lock must be the rewritten file: {env}" + ); + assert!( + warning_codes(&env).contains(&"redirect_pnpm_trust_lockfile".to_string()), + "a landed v5 rewrite must still carry the install guidance: {env}" + ); + + // The spliced block, byte-exact: the `/name/version:` key keeps its + // path-style shape, the resolution carries {integrity, tarball}, and the + // sibling lines survive untouched. + let lock_after = std::fs::read_to_string(&lock_path).unwrap(); + let spliced = format!( + " /{DEP}/{DEP_VERSION}:\n resolution: {{integrity: {PATCHED_SHA512}, tarball: {hosted_url}}}\n deprecated: use String.prototype.padStart()\n dev: false\n" + ); + assert!( + lock_after.contains(&spliced), + "the v5 packages entry must be spliced in place; want:\n{spliced}\ngot:\n{lock_after}" + ); + assert!( + !lock_after.contains(UPSTREAM_SHA512), + "the upstream integrity must be replaced; got:\n{lock_after}" + ); + + let ledger: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(), + ) + .unwrap(); + let edit = ledger["edits"] + .as_array() + .unwrap() + .iter() + .find(|e| { + e["kind"] == "redirect_pnpm_resolution" && e["key"] == format!("{DEP}@{DEP_VERSION}") + }) + .unwrap_or_else(|| panic!("the ledger must record the v5 redirect edit: {ledger}")); + assert!( + edit["original"] + .as_str() + .unwrap_or_default() + .contains(UPSTREAM_SHA512), + "the ledger must preserve the original upstream integrity for revert: {edit}" + ); +} + +/// pnpm v6 PLAIN lock keys (`/name@version:` with no peer suffix) stay inside +/// the redirect grammar — verified against a real pnpm 8 install in the +/// matrix sweep (hosted-pnpm8): the resolution is spliced in place with its +/// sibling lines (`deprecated:`, `dev:`) byte-preserved. +#[tokio::test(flavor = "multi_thread")] +async fn pnpm_v6_plain_lock_key_rewrite_stays_supported() { + let server = MockServer::start().await; + let hosted_url = hosted_url_for("http://patch.test"); + mount_api_mocks( + &server, + &hosted_url, + PATCHED_SHA512, + &"a".repeat(64), + &"b".repeat(64), + ) + .await; + + let tmp = tempfile::tempdir().unwrap(); + write_synthetic_project(tmp.path(), &v6_lock()); + let lock_path = tmp.path().join("pnpm-lock.yaml"); + + let (code, stdout, stderr) = scan_hosted(tmp.path(), &server.uri(), &[]); + assert_eq!( + code, 0, + "scan --mode hosted failed on the v6 lock.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env = parse_envelope(&stdout); + assert_eq!(env["status"], "success", "envelope: {env}"); + assert_eq!( + env["redirect"]["redirected"], 1, + "the plain v6 key must stay redirectable: {env}" + ); + assert_eq!( + env["redirect"]["rewrittenFiles"], + serde_json::json!(["pnpm-lock.yaml"]), + "the v6 lock must be the rewritten file: {env}" + ); + assert!( + warning_codes(&env).contains(&"redirect_pnpm_trust_lockfile".to_string()), + "a landed v6 rewrite must still carry the install guidance: {env}" + ); + + // The trust AUTO-CONFIG must NOT fire for a legacy lock: the gate is + // lock-major >= 9, and a 6.0 lock means pnpm 8 — no lockfile policy, no + // trustLockfile setting (pnpm 7/8 reject the flag spelling too, so the + // warning must NOT recommend `--trust-lockfile`: it gets the legacy + // installs-work-unchanged guidance instead). No workspace file appears. + assert!( + !tmp.path().join("pnpm-workspace.yaml").exists(), + "a v6-lock scan must not auto-write pnpm-workspace.yaml: {env}" + ); + let v6_detail = env["redirect"]["warnings"] + .as_array() + .unwrap() + .iter() + .find(|w| w["code"] == "redirect_pnpm_trust_lockfile") + .and_then(|w| w["detail"].as_str()) + .unwrap_or_default(); + assert!( + !v6_detail.contains("trust-lockfile"), + "the legacy-lock warning must not recommend --trust-lockfile (pnpm 7/8 \ + reject the flag as unknown); got: {v6_detail}" + ); + assert!( + v6_detail.contains("pnpm 7/8") && v6_detail.contains("installs work unchanged"), + "the legacy-lock warning must say installs work unchanged on pnpm 7/8; \ + got: {v6_detail}" + ); + + // The spliced block, byte-exact: the `/name@version:` key keeps its + // shape, the resolution carries {integrity, tarball}, and the sibling + // lines survive untouched. + let lock_after = std::fs::read_to_string(&lock_path).unwrap(); + let spliced = format!( + " /{DEP}@{DEP_VERSION}:\n resolution: {{integrity: {PATCHED_SHA512}, tarball: {hosted_url}}}\n deprecated: use String.prototype.padStart()\n dev: false\n" + ); + assert!( + lock_after.contains(&spliced), + "the v6 packages entry must be spliced in place; want:\n{spliced}\ngot:\n{lock_after}" + ); + assert!( + !lock_after.contains(UPSTREAM_SHA512), + "the upstream integrity must be replaced; got:\n{lock_after}" + ); + + let ledger: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(), + ) + .unwrap(); + let edit = ledger["edits"] + .as_array() + .unwrap() + .iter() + .find(|e| { + e["kind"] == "redirect_pnpm_resolution" && e["key"] == format!("{DEP}@{DEP_VERSION}") + }) + .unwrap_or_else(|| panic!("the ledger must record the v6 redirect edit: {ledger}")); + assert!( + edit["original"] + .as_str() + .unwrap_or_default() + .contains(UPSTREAM_SHA512), + "the ledger must preserve the original upstream integrity for revert: {edit}" + ); + assert!( + !ledger["edits"] + .as_array() + .unwrap() + .iter() + .any(|e| e["kind"] == "redirect_pnpm_workspace_trust"), + "a v6-lock scan must record no workspace trust edit: {ledger}" + ); +} diff --git a/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs index 2d7e1635..47975d12 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs @@ -1,8 +1,9 @@ //! Real-pnpm capstone e2e for `socket-patch vendor` — the committability //! proof for the pnpm (lockfileVersion 9.0) flavor. //! -//! Drives the REAL `corepack pnpm@10` (and pnpm@9 when fetchable — both emit -//! byte-identical 9.0 locks, spike P1/P2): +//! Drives the REAL `corepack pnpm@10` (and pnpm@9 / pnpm@11 when fetchable — +//! all three emit byte-identical single-document 9.0 locks, spike P1/P2 + +//! matrix leg vendor-pnpm11): //! 1. `pnpm install` of left-pad@1.3.0 into a tempdir (private `--store-dir`). //! 2. Hand-stage a `.socket/` manifest + blob from the ACTUAL installed //! bytes (a marker comment prepended to `index.js`). @@ -25,6 +26,17 @@ //! LOCAL capstone (not behind docker-e2e): skips with a `println` + return //! when `corepack pnpm@10` is unavailable or the fixture install cannot reach //! the registry; every assertion after that is HARD. +//! +//! Below the capstone: the LEGACY grammars — pnpm 7 (`lockfileVersion: 5.4`) +//! and pnpm 8 (`'6.0'`), wired by the pnpm-legacy backend. HERMETIC legs +//! (no corepack, no network, always run) prove the splice reproduces the +//! pnpm-captured end state byte-for-byte, is idempotent, and reverts +//! byte-identical; gated `pnpm7_real_*`/`pnpm8_real_*` legs run the full +//! lifecycle against the real pinned pnpm, including the SAME-PATH +//! frozen+offline empty-store proof and the MOVED-CHECKOUT `--offline` +//! proof (pnpm <= 8 absolutizes file: override specifiers, so frozen +//! installs are path-bound — the moved-checkout frozen FAILURE is asserted +//! as the documented limitation). use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; @@ -38,10 +50,12 @@ const UUID: &str = "1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab"; const MARKER: &str = "/* SOCKET-PATCHED */\n"; const DEP: &str = "left-pad"; const DEP_VERSION: &str = "1.3.0"; -/// Pinned pnpm majors via corepack — @10 is required, @9 is run too when -/// fetchable (the spike proved both emit byte-identical 9.0 locks). +/// Pinned pnpm majors via corepack — @10 is required, @9 and @11 are run too +/// when fetchable (the spike proved @9/@10 emit byte-identical 9.0 locks; the +/// matrix proved @11 (11.22.0) does as well — single-document, same grammar). const PNPM_PRIMARY: &str = "pnpm@10"; const PNPM_SECONDARY: &str = "pnpm@9"; +const PNPM_TERTIARY: &str = "pnpm@11"; // ── self-contained helpers ──────────────────────────────────────────── @@ -192,6 +206,22 @@ fn pnpm_vendor_fresh_checkout_frozen_offline_install_and_revert() { } else { eprintln!("note: {PNPM_SECONDARY} not fetchable; ran {PNPM_PRIMARY} only"); } + + // Ladder top: pnpm 11 (matrix leg vendor-pnpm11, 11.22.0) emits the same + // single-document 9.0 lock as 10 and its supply-chain verification accepts + // the vendored file: tarball, so the whole lifecycle carries hard + // assertions here too. pnpm >= 11 reads `overrides` ONLY from + // pnpm-workspace.yaml (the package.json `pnpm` table is ignored), which + // makes the workspace-file assertions inside run_pnpm_capstone the + // load-bearing wiring surface on this leg — the fresh-checkout + // frozen+offline install below would resolve the unpatched registry + // tarball without it. + if has_corepack_pm(PNPM_TERTIARY) { + eprintln!("--- also exercising {PNPM_TERTIARY} ---"); + run_pnpm_capstone(PNPM_TERTIARY); + } else { + eprintln!("note: {PNPM_TERTIARY} not fetchable; skipped"); + } } fn run_pnpm_capstone(pm: &str) { @@ -507,3 +537,583 @@ fn run_pnpm_capstone(pm: &str) { ); eprintln!("REVERT OK ({pm})"); } + +// ── pre-9.0 LEGACY lock legs (hermetic splice-shape + gated real-pnpm) ──── +// +// pnpm 7 (lockfileVersion 5.4) and pnpm 8 ('6.0') are wired by the +// pnpm-legacy vendor backend since 2026-08-18 (they used to refuse). The +// hermetic legs below prove the splice reproduces the captured pnpm-emitted +// shape byte-for-byte with no corepack/network; the `pnpm7_real_*` / +// `pnpm8_real_*` legs run the full lifecycle against the REAL pinned pnpm +// when corepack can fetch it (mirroring the @9/@11 opportunistic pattern — +// @10 stays the primary capstone above). + +/// Pinned legacy majors (the exact versions the vendor-legacy spike +/// captured the lock grammars from). +const PNPM_LEGACY_7: &str = "pnpm@7.33.5"; +const PNPM_LEGACY_8: &str = "pnpm@8.15.9"; + +/// Byte-accurate lock captured from a REAL `pnpm@7.33.5 install` of +/// left-pad@1.3.0 (matrix leg vendor-pnpm7): unquoted `lockfileVersion: 5.4`, +/// `specifiers:` section, `/left-pad/1.3.0:` packages key. +const PNPM7_LOCK: &str = "lockfileVersion: 5.4 + +specifiers: + left-pad: 1.3.0 + +dependencies: + left-pad: 1.3.0 + +packages: + + /left-pad/1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + deprecated: use String.prototype.padStart() + dev: false +"; + +/// Byte-accurate lock captured from a REAL `pnpm@8.15.9 install` of the same +/// fixture (matrix leg vendor-pnpm8): quoted `lockfileVersion: '6.0'`, +/// `settings:` section, `/left-pad@1.3.0:` packages key (the `@` rekeying +/// pnpm 8 introduced). +const PNPM8_LOCK: &str = "lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +dependencies: + left-pad: + specifier: 1.3.0 + version: 1.3.0 + +packages: + + /left-pad@1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + deprecated: use String.prototype.padStart() + dev: false +"; + +/// Expected pnpm-7-shaped lock AFTER vendoring, exactly as `pnpm@7.33.5 +/// install` itself serialized the same end state (spike p7): `overrides:` +/// at the ROOT_KEYS_ORDER slot, the SPECIFIER absolutized against the +/// project root (pnpm <= 8 absolutizes file: overrides itself — the +/// documented portability caveat), the dep value + rekeyed packages entry +/// relative, `name:`/`version:` spelled out, `deprecated:` dropped. +/// `{ABS}` / `{INT}` are substituted per run. +const PNPM7_AFTER_TEMPLATE: &str = "lockfileVersion: 5.4 + +overrides: + left-pad@1.3.0: file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz + +specifiers: + left-pad: file:{ABS}/.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz + +dependencies: + left-pad: file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz + +packages: + + file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz: + resolution: {integrity: {INT}, tarball: file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz} + name: left-pad + version: 1.3.0 + dev: false +"; + +/// Expected pnpm-8-shaped lock AFTER vendoring (spike p8) — the nested +/// specifier/version grammar, same override/rekey shape. +const PNPM8_AFTER_TEMPLATE: &str = "lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + left-pad@1.3.0: file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz + +dependencies: + left-pad: + specifier: file:{ABS}/.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz + version: file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz + +packages: + + file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz: + resolution: {integrity: {INT}, tarball: file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz} + name: left-pad + version: 1.3.0 + dev: false +"; + +#[test] +fn pnpm7_lock_v54_hermetic_splice_idempotency_and_revert() { + run_legacy_hermetic(PNPM7_LOCK, PNPM7_AFTER_TEMPLATE, "5.4"); +} + +#[test] +fn pnpm8_lock_v60_hermetic_splice_idempotency_and_revert() { + run_legacy_hermetic(PNPM8_LOCK, PNPM8_AFTER_TEMPLATE, "6.0"); +} + +/// The tarball's SRI (`sha512-`), the integrity spelling pnpm locks +/// record. +fn tarball_integrity(tgz: &Path) -> String { + use base64::Engine as _; + use sha2::Sha512; + let bytes = std::fs::read(tgz).expect("vendored tarball"); + format!( + "sha512-{}", + base64::engine::general_purpose::STANDARD.encode(Sha512::digest(&bytes)) + ) +} + +/// Hermetic (no corepack, no network) proof that the legacy splice +/// reproduces the pnpm-captured end state byte-for-byte, is idempotent +/// (`skipped`/`already_vendored`, byte-stable), and reverts byte-identical. +/// The absolute-specifier portability caveat must be surfaced as the +/// `vendor_pnpm_legacy_absolute_specifier` run warning, and NO +/// pnpm-workspace.yaml may appear (pnpm <= 8 reads overrides only from +/// package.json). +fn run_legacy_hermetic(lock_text: &str, after_template: &str, version: &str) { + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + let dep_dir = proj.join("node_modules").join(DEP); + std::fs::create_dir_all(&dep_dir).unwrap(); + std::fs::write( + dep_dir.join("package.json"), + format!("{{\"name\":\"{DEP}\",\"version\":\"{DEP_VERSION}\"}}\n"), + ) + .unwrap(); + let orig = b"module.exports = function leftPad(str) { return str; };\n".to_vec(); + std::fs::write(dep_dir.join("index.js"), &orig).unwrap(); + let patched: Vec = [MARKER.as_bytes(), orig.as_slice()].concat(); + let purl = format!("pkg:npm/{DEP}@{DEP_VERSION}"); + stage_patch(&proj, &purl, "package/index.js", &orig, &patched); + + let pkg_doc = serde_json::json!({ + "name": "pnpm-legacy-hermetic", + "version": "0.0.0", + "private": true, + "dependencies": { DEP: DEP_VERSION }, + }); + let pkg_path = proj.join("package.json"); + let lock_path = proj.join("pnpm-lock.yaml"); + std::fs::write( + &pkg_path, + format!("{}\n", serde_json::to_string_pretty(&pkg_doc).unwrap()), + ) + .unwrap(); + std::fs::write(&lock_path, lock_text).unwrap(); + let pkg_before = std::fs::read(&pkg_path).unwrap(); + let lock_before = std::fs::read(&lock_path).unwrap(); + + // 1. Vendor: exits 0 and wires both surfaces. + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "vendor must wire a {version} lock.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env = parse_envelope(&stdout); + assert_eq!(env["status"], "success", "envelope: {env}"); + assert_eq!(env["summary"]["applied"], 1, "one package vendored: {env}"); + assert_eq!(env["summary"]["failed"], 0, "no failures: {env}"); + // The path-bound frozen-install caveat is machine-readable (vendor + // warnings surface as non-counting skipped events carrying the code). + assert!( + env["events"] + .as_array() + .unwrap() + .iter() + .any(|e| e["errorCode"] == "vendor_pnpm_legacy_absolute_specifier"), + "the absolute-specifier caveat must be surfaced: {env}" + ); + + let tgz_rel = format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz"); + assert!( + proj.join(&tgz_rel).is_file(), + "tarball missing at {tgz_rel}" + ); + + // package.json gained the versioned override. + let pkg_json: serde_json::Value = + serde_json::from_slice(&std::fs::read(&pkg_path).unwrap()).unwrap(); + assert_eq!( + pkg_json["pnpm"]["overrides"][format!("{DEP}@{DEP_VERSION}")].as_str(), + Some(format!("file:{tgz_rel}").as_str()), + "package.json must gain pnpm.overrides: {pkg_json}" + ); + + // The lock is byte-identical to what the REAL pnpm serialized for this + // end state (spike p7/p8), with the live absolute root + integrity. + let abs = socket_patch_core::vendor::pnpm_lock_legacy::normalize_canonical_root( + &std::fs::canonicalize(&proj).unwrap().display().to_string(), + ); + let expected = after_template + .replace("{UUID}", UUID) + .replace("{ABS}", &abs) + .replace("{INT}", &tarball_integrity(&proj.join(&tgz_rel))); + let lock_after = std::fs::read_to_string(&lock_path).unwrap(); + assert_eq!( + lock_after, expected, + "{version} lock must match the pnpm-captured after shape byte-for-byte" + ); + + // pnpm <= 8 reads overrides only from package.json — creating a + // workspace file would flip the project into workspace mode. + assert!( + !proj.join("pnpm-workspace.yaml").exists(), + "legacy wiring must not create pnpm-workspace.yaml" + ); + + // 2. Idempotency: re-run skips (`already_vendored`), all bytes stable. + let pkg_wired = std::fs::read(&pkg_path).unwrap(); + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!(code, 0, "re-vendor.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let env2 = parse_envelope(&stdout); + assert!( + env2["events"] + .as_array() + .unwrap() + .iter() + .any(|e| e["action"] == "skipped" && e["errorCode"] == "already_vendored"), + "in-sync rerun must report already_vendored: {env2}" + ); + assert_eq!( + std::fs::read_to_string(&lock_path).unwrap(), + lock_after, + "re-vendor must leave the lock byte-identical" + ); + assert_eq!( + std::fs::read(&pkg_path).unwrap(), + pkg_wired, + "re-vendor must leave package.json byte-identical" + ); + + // 3. Revert: both files byte-restored, artifact gone. + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--revert", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!(code, 0, "revert.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let renv = parse_envelope(&stdout); + assert_eq!(renv["summary"]["removed"], 1, "one entry reverted: {renv}"); + assert_eq!( + std::fs::read(&pkg_path).unwrap(), + pkg_before, + "revert must restore package.json byte-identical" + ); + assert_eq!( + std::fs::read(&lock_path).unwrap(), + lock_before, + "revert must restore pnpm-lock.yaml byte-identical" + ); + assert!( + !proj.join(".socket/vendor").exists(), + ".socket/vendor must be fully removed after revert" + ); +} + +// ── gated real-pnpm legacy lifecycle legs ───────────────────────────── + +#[test] +fn pnpm7_real_lifecycle_same_path_frozen_and_moved_checkout_offline() { + if !has_corepack_pm(PNPM_LEGACY_7) { + println!("SKIP: `corepack {PNPM_LEGACY_7}` unavailable"); + return; + } + run_legacy_capstone(PNPM_LEGACY_7, "lockfileVersion: 5.4"); +} + +#[test] +fn pnpm8_real_lifecycle_same_path_frozen_and_moved_checkout_offline() { + if !has_corepack_pm(PNPM_LEGACY_8) { + println!("SKIP: `corepack {PNPM_LEGACY_8}` unavailable"); + return; + } + run_legacy_capstone(PNPM_LEGACY_8, "lockfileVersion: '6.0'"); +} + +/// Full lifecycle against the REAL pinned legacy pnpm, spike-proven flags: +/// +/// 1. online fixture install (skip when the registry is unreachable); +/// 2. vendor --json --offline wires package.json + the legacy lock (no +/// pnpm-workspace.yaml); +/// 3. SAME-PATH strict proof: wipe node_modules, EMPTY store, +/// `install --frozen-lockfile --offline` → marker bytes (the absolute +/// specifier matches this checkout, spike probe A); +/// 4. MOVED-CHECKOUT proof: committables copied to a different dir with a +/// dead-registry .npmrc and an EMPTY store — `--frozen-lockfile` MUST +/// fail (pnpm <= 8's path-bound frozen check, spike probe B pins the +/// documented limitation) and plain `install --offline` MUST land the +/// marker bytes (probe C); +/// 5. idempotent re-vendor (byte-stable, already_vendored); +/// 6. revert restores both files byte-identical and removes .socket/vendor. +fn run_legacy_capstone(pm: &str, lock_head: &str) { + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + let pkg_doc = serde_json::json!({ + "name": "pnpm-legacy-capstone", + "version": "0.0.0", + "private": true, + "dependencies": { DEP: DEP_VERSION }, + }); + std::fs::write( + proj.join("package.json"), + format!("{}\n", serde_json::to_string_pretty(&pkg_doc).unwrap()), + ) + .unwrap(); + + // 1. REAL fixture install (network allowed here, private store). + let store = tmp.path().join("pnpm-store"); + let install = corepack( + &proj, + pm, + &["install", "--store-dir", store.to_str().unwrap()], + ); + if !install.status.success() { + println!( + "SKIP legacy capstone ({pm}): fixture `pnpm install` failed (registry \ + unreachable?):\n{}", + String::from_utf8_lossy(&install.stderr) + ); + return; + } + + let installed_index = proj.join("node_modules").join(DEP).join("index.js"); + let orig = std::fs::read(&installed_index).expect("installed index.js"); + let patched: Vec = [MARKER.as_bytes(), orig.as_slice()].concat(); + let purl = format!("pkg:npm/{DEP}@{DEP_VERSION}"); + stage_patch(&proj, &purl, "package/index.js", &orig, &patched); + + let lock_path = proj.join("pnpm-lock.yaml"); + let pkg_path = proj.join("package.json"); + let lock_before = std::fs::read(&lock_path).expect("lock after pnpm install"); + let pkg_before = std::fs::read(&pkg_path).unwrap(); + let lock_before_str = String::from_utf8(lock_before.clone()).unwrap(); + assert!( + lock_before_str.starts_with(lock_head), + "fixture must be a {lock_head} lock:\n{lock_before_str}" + ); + + // 2. Vendor (offline). + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "vendor failed ({pm}).\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env = parse_envelope(&stdout); + assert_eq!(env["status"], "success", "envelope: {env}"); + assert_eq!(env["summary"]["applied"], 1, "{env}"); + let tgz_rel = format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz"); + assert!(proj.join(&tgz_rel).is_file()); + assert!( + !proj.join("pnpm-workspace.yaml").exists(), + "legacy wiring must not create pnpm-workspace.yaml ({pm})" + ); + let lock_after = std::fs::read_to_string(&lock_path).unwrap(); + let abs = socket_patch_core::vendor::pnpm_lock_legacy::normalize_canonical_root( + &std::fs::canonicalize(&proj).unwrap().display().to_string(), + ); + assert!( + lock_after.contains(&format!("{DEP}@{DEP_VERSION}: file:{tgz_rel}")), + "lock overrides must point at the vendored tarball ({pm}):\n{lock_after}" + ); + assert!( + lock_after.contains(&format!("file:{abs}/{tgz_rel}")), + "lock specifier must carry pnpm <= 8's absolutized spelling ({pm}):\n{lock_after}" + ); + eprintln!("VENDOR OK ({pm})"); + + // 3. SAME-PATH strict proof: wipe node_modules, EMPTY store, the + // spike-proven strictest invocation. + std::fs::remove_dir_all(proj.join("node_modules")).unwrap(); + let same_store = tmp.path().join("store-same"); + let ci = corepack( + &proj, + pm, + &[ + "install", + "--frozen-lockfile", + "--offline", + "--store-dir", + same_store.to_str().unwrap(), + ], + ); + assert!( + ci.status.success(), + "same-path `install --frozen-lockfile --offline` must succeed from the vendored \ + tarball ({pm}).\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr), + ); + let same_installed = std::fs::read(&installed_index).unwrap(); + assert_eq!( + same_installed, patched, + "same-path frozen+offline install must land the patched bytes ({pm})" + ); + eprintln!("SAME-PATH FROZEN INSTALL OK ({pm})"); + + // The lock must be byte-stable under pnpm's own re-serialization. + assert_eq!( + std::fs::read_to_string(&lock_path).unwrap(), + lock_after, + "pnpm's own install must leave the spliced lock byte-identical ({pm})" + ); + + // 4. MOVED-CHECKOUT proof (different absolute path, dead registry, + // EMPTY store): frozen fails — the documented pnpm <= 8 limitation — + // and plain --offline lands the marker. + let fresh = tmp.path().join("fresh"); + std::fs::create_dir_all(&fresh).unwrap(); + std::fs::copy(&pkg_path, fresh.join("package.json")).unwrap(); + std::fs::copy(&lock_path, fresh.join("pnpm-lock.yaml")).unwrap(); + copy_dir_recursive(&proj.join(".socket"), &fresh.join(".socket")); + std::fs::write(fresh.join(".npmrc"), "registry=http://127.0.0.1:1/\n").unwrap(); + + let fresh_store = tmp.path().join("store-fresh"); + let frozen = corepack( + &fresh, + pm, + &[ + "install", + "--frozen-lockfile", + "--offline", + "--store-dir", + fresh_store.to_str().unwrap(), + ], + ); + assert!( + !frozen.status.success(), + "pnpm <= 8's frozen check is path-bound (absolute specifier): a moved checkout \ + passing --frozen-lockfile would invalidate the documented caveat ({pm})" + ); + + // `--no-frozen-lockfile` is load-bearing, not belt-and-braces: pnpm + // defaults --frozen-lockfile ON when CI=true, and the moved-checkout + // recovery WORKS by re-resolving the path-bound absolute specifier — + // frozen semantics skip that re-resolution and fail (observed on CI: + // ERR_PNPM_OUTDATED_LOCKFILE on pnpm 8, stale-path install on pnpm 7). + let plain = corepack( + &fresh, + pm, + &[ + "install", + "--offline", + "--no-frozen-lockfile", + "--store-dir", + fresh_store.to_str().unwrap(), + ], + ); + assert!( + plain.status.success(), + "moved-checkout `install --offline --no-frozen-lockfile` must succeed from the \ + vendored tarball ({pm}).\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&plain.stdout), + String::from_utf8_lossy(&plain.stderr), + ); + let fresh_installed = + std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(); + assert_eq!( + fresh_installed, patched, + "moved-checkout install must land the patched bytes ({pm})" + ); + eprintln!("MOVED-CHECKOUT OFFLINE INSTALL OK ({pm})"); + + // 5. Idempotency in the original project. + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "re-vendor failed ({pm}).\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env2 = parse_envelope(&stdout); + assert!( + env2["events"] + .as_array() + .unwrap() + .iter() + .any(|e| e["action"] == "skipped" && e["errorCode"] == "already_vendored"), + "in-sync rerun must report already_vendored ({pm}): {env2}" + ); + assert_eq!( + std::fs::read_to_string(&lock_path).unwrap(), + lock_after, + "re-vendor must be byte-stable ({pm})" + ); + + // 6. Revert restores the pair byte-identical. + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--revert", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "revert failed ({pm}).\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let renv = parse_envelope(&stdout); + assert_eq!(renv["summary"]["removed"], 1, "{renv}"); + assert_eq!( + std::fs::read(&lock_path).unwrap(), + lock_before, + "revert must restore pnpm-lock.yaml byte-identical ({pm})" + ); + assert_eq!( + std::fs::read(&pkg_path).unwrap(), + pkg_before, + "revert must restore package.json byte-identical ({pm})" + ); + assert!(!proj.join(".socket/vendor").exists()); + eprintln!("REVERT OK ({pm})"); +} diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index e2564d15..a0ba4570 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -1564,6 +1564,14 @@ async fn scan_redirect_rewrites_rush_common_and_subspace_locks() { !tmp.path().join("pnpm-lock.yaml").exists(), "the rewrite must edit nested locks in place, not create a root lock" ); + // And no root pnpm-workspace.yaml either: rush runs pnpm in common/temp, + // which never reads the repo root, so the trustLockfile auto-config + // (root-lock-gated) must stay hands-off here — writing it would be + // config theater. + assert!( + !tmp.path().join("pnpm-workspace.yaml").exists(), + "rush nested-lock redirects must not create a root pnpm-workspace.yaml" + ); // repo-state.json present → the stale-hash warning fires. let out = std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")) @@ -1580,6 +1588,13 @@ async fn scan_redirect_rewrites_rush_common_and_subspace_locks() { /// hosting test can't read back. No package-manager binary is needed: the /// rewrite is pure text over the fixture locks. fn run_redirect_subprocess(cwd: &Path, api_url: &str) -> serde_json::Value { + run_redirect_subprocess_with(cwd, api_url, &[]) +} + +/// [`run_redirect_subprocess`] with extra CLI flags appended (e.g. the +/// `--no-trust-lockfile-config` opt-out), so flag-dependent envelope shapes +/// are exercised through the real clap parse. +fn run_redirect_subprocess_with(cwd: &Path, api_url: &str, extra: &[&str]) -> serde_json::Value { let out = scrubbed_cli() .args([ "scan", @@ -1595,6 +1610,7 @@ fn run_redirect_subprocess(cwd: &Path, api_url: &str) -> serde_json::Value { "--api-token", "fake", ]) + .args(extra) .output() .expect("run socket-patch"); assert_eq!( @@ -1704,6 +1720,96 @@ async fn redirect_inbundle_only_dep_is_skipped_not_confirmed() { ); } +/// A pnpm v6 lock resolving the patched dep through BOTH a plain key and a +/// nested-peer-paren key (`/pkg@1.0.0(react@18.2.0(scheduler@0.23.2)):` — +/// a spelling the splice grammar cannot parse) must be refused whole: +/// `redirected: 0`, the lock byte-untouched, the hosted URL nowhere (a +/// partial splice would have landed it in the lock, and the confirmation +/// probe would then confirm + VEX-attest the dep while dependents through +/// the nested-peer instance keep installing the unpatched upstream tarball), +/// a `redirect_pnpm_unsupported_lock_key` warning naming the residual key, +/// and no redirect-ledger record claiming the purl. Subprocess so the +/// `--json` envelope's `redirected` count and `warnings[]` can be read back. +#[tokio::test] +#[serial] +async fn redirect_pnpm_nested_peer_residual_refuses_dep_not_confirmed() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + + let tmp = tempfile::tempdir().expect("create the fixture tempdir"); + std::fs::write( + tmp.path().join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{NAME}": "{VERSION}" }} }}"# + ), + ) + .expect("write the consumer package.json"); + let pkg = tmp.path().join("node_modules").join(NAME); + std::fs::create_dir_all(&pkg).expect("create the installed package dir"); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{NAME}", "version": "{VERSION}" }}"#), + ) + .expect("write the installed package.json"); + let lock = format!( + "lockfileVersion: '6.0' + +dependencies: + {NAME}: + specifier: {VERSION} + version: {VERSION} + +packages: + + /{NAME}@{VERSION}: + resolution: {{integrity: sha512-UPSTREAMupstream==}} + dev: false + + /{NAME}@{VERSION}(react@18.2.0(scheduler@0.23.2)): + resolution: {{integrity: sha512-UPSTREAMupstream==}} + dev: false +" + ); + std::fs::write(tmp.path().join("pnpm-lock.yaml"), &lock) + .expect("write the mixed plain + nested-peer pnpm lock"); + + let env = run_redirect_subprocess(tmp.path(), &server.uri()); + assert_eq!( + env["redirect"]["redirected"], 0, + "a residual-instance dep must NOT be counted redirected: {env}" + ); + let codes = warning_codes(&env); + assert!( + codes.contains(&"redirect_pnpm_unsupported_lock_key".to_string()), + "the residual refusal warning must reach the envelope: {env}" + ); + let after = std::fs::read_to_string(tmp.path().join("pnpm-lock.yaml")) + .expect("read back the pnpm-lock.yaml the run must have left alone"); + assert_eq!(after, lock, "the lockfile must be byte-untouched"); + assert!( + !after.contains(HOSTED_URL), + "the hosted URL must never appear (it would confirm + attest): {after}" + ); + // No ledger half-claims the purl either: an unconfirmed dep must fetch + // no record and record no edits. + let ledger_path = tmp.path().join(".socket/vendor/redirect-state.json"); + if let Ok(text) = std::fs::read_to_string(&ledger_path) { + let ledger: serde_json::Value = + serde_json::from_str(&text).expect("the redirect ledger must be valid JSON"); + assert!( + ledger["records"].get(PURL).is_none(), + "an unconfirmed dep must not be recorded: {ledger}" + ); + let claimed = ledger["edits"] + .as_array() + .into_iter() + .flatten() + .any(|e| e["key"].as_str().is_some_and(|k| k.contains(NAME))); + assert!(!claimed, "no edit may claim the refused dep: {ledger}"); + } +} + /// The rewriters' own warnings must reach HUMAN mode too, not just the /// `--json` envelope: they carry the load-bearing "why nothing happened / /// what you must do" guidance (`redirect_npm_no_lockfile`, @@ -1985,17 +2091,26 @@ fn write_pnpm_project(root: &Path) { std::fs::write(root.join("pnpm-lock.yaml"), rush_pnpm_lock(NAME)).unwrap(); } -/// A hosted redirect that rewrites a `pnpm-lock.yaml` must warn that pnpm >=11's -/// lockfile supply-chain policy will REJECT the rewritten lock -/// (`ERR_PNPM_TARBALL_URL_MISMATCH` — the repointed tarball URL no longer -/// matches the registry's published metadata) and name the documented -/// `pnpm install --trust-lockfile` opt-out — the same way the Rush repo-state -/// case surfaces its own post-rewrite install caveat. The npm twin +/// A hosted redirect that rewrites the root v9 `pnpm-lock.yaml` AUTO-CONFIGURES +/// `trustLockfile: true` in pnpm-workspace.yaml (zero-touch: pnpm >=11's +/// lockfile supply-chain policy rejects the rewritten lock otherwise, and the +/// committable workspace key is the verified recovery both majors honor while +/// pnpm 9/10 silently ignore it), and the `redirect_pnpm_trust_lockfile` +/// warning must say so: trust is configured, commit the file alongside the +/// lock, installs need no flags — naming BOTH failure spellings (pnpm 11: +/// `ERR_PNPM_TARBALL_URL_MISMATCH`, pnpm 12: +/// `ERR_PNPM_LOCKFILE_RESOLUTION_VERIFICATION`), disclosing the whole-lock +/// tradeoff (trustLockfile skips pnpm's re-verification for ALL entries), and +/// pre-empting pnpm 12's own rebuild-the-lock advice, which silently +/// reinstates the vulnerable upstream. The named host must be the SPLICED +/// artifact host (here the fixture's `patch.test`), never a hardcoded +/// `patch.socket.dev` — the hosted host follows `--api-url`. The npm twin /// (package-lock.json, no pnpm lock) rewrites identically but emits no such -/// warning. Subprocess so the `--json` `warnings[]` array can be read back. +/// warning and no workspace file. Subprocess so the `--json` `warnings[]` +/// array can be read back. #[tokio::test] #[serial] -async fn pnpm_lock_redirect_warns_to_trust_lockfile() { +async fn pnpm_lock_redirect_autoconfigures_trust_lockfile_and_says_so() { let server = MockServer::start().await; mock_discovery(&server).await; mock_reference(&server).await; @@ -2010,12 +2125,28 @@ async fn pnpm_lock_redirect_warns_to_trust_lockfile() { env["redirect"]["redirected"], 1, "anchor: the pnpm lock must have been redirected: {env}" ); + // The zero-touch write itself: a fresh pnpm-workspace.yaml with the + // root-only scaffold + the trust key, and it counts as a rewritten file + // (a CI consumer committing rewrittenFiles must not miss it). + let ws = std::fs::read_to_string(pnpm.path().join("pnpm-workspace.yaml")) + .expect("the run must create pnpm-workspace.yaml"); + assert_eq!( + ws, "packages:\n - '.'\ntrustLockfile: true\n", + "created workspace file must be the scaffold + trust key" + ); + assert!( + env["redirect"]["rewrittenFiles"] + .as_array() + .unwrap() + .iter() + .any(|f| f == "pnpm-workspace.yaml"), + "pnpm-workspace.yaml must be listed in rewrittenFiles: {env}" + ); assert!( warning_codes(&env).contains(&"redirect_pnpm_trust_lockfile".to_string()), "a rewritten pnpm-lock.yaml must warn about the pnpm >=11 policy; got warnings {:?}", warning_codes(&env) ); - // The warning must NAME the documented opt-out flag. let detail = env["redirect"]["warnings"] .as_array() .unwrap() @@ -2023,12 +2154,68 @@ async fn pnpm_lock_redirect_warns_to_trust_lockfile() { .find(|w| w["code"] == "redirect_pnpm_trust_lockfile") .and_then(|w| w["detail"].as_str()) .unwrap_or_default(); + // The new reality: trust is configured; commit it; no flags needed. assert!( - detail.contains("--trust-lockfile"), - "the warning must name `pnpm install --trust-lockfile`; got: {detail}" + detail.contains("trustLockfile: true") && detail.contains("pnpm-workspace.yaml"), + "the warning must name the configured pnpm-workspace.yaml \ + `trustLockfile: true` key; got: {detail}" + ); + assert!( + detail.contains("commit it alongside the lock") && detail.contains("no extra flags"), + "the warning must say trust is configured and installs need no flags; got: {detail}" + ); + // The security tradeoff, stated honestly: the skip covers the WHOLE lock. + assert!( + detail.contains("ALL lockfile entries") && detail.contains("minimumReleaseAge"), + "the warning must disclose the whole-lock re-verification skip; got: {detail}" + ); + // The `.npmrc` `trust-lockfile=true` spelling is IGNORED by pnpm and + // must never be recommended. + assert!( + !detail.contains(".npmrc"), + "the warning must not point at .npmrc (pnpm ignores that spelling); got: {detail}" + ); + // Both major-specific failure spellings. + assert!( + detail.contains("ERR_PNPM_TARBALL_URL_MISMATCH") + && detail.contains("ERR_PNPM_LOCKFILE_RESOLUTION_VERIFICATION"), + "the warning must name the pnpm 11 AND pnpm 12 error codes; got: {detail}" + ); + // pnpm 12's error text steers users to rebuild the lock, which discards + // the redirect — the warning must pre-empt it and scope the blast radius. + assert!( + detail.contains("pnpm clean --lockfile"), + "the warning must pre-empt pnpm's rebuild-the-lock advice; got: {detail}" + ); + assert!( + detail.contains("pnpm <=10"), + "the warning must scope the failure to pnpm >=11; got: {detail}" + ); + // The host is derived from the spliced tarball URL (the fixture's + // HOSTED_URL host), never hardcoded. + assert!( + detail.contains("patch.test") && !detail.contains("patch.socket.dev"), + "the warning must name the actual spliced host, not patch.socket.dev; \ + got: {detail}" + ); + + // The ledger records the workspace-trust edit (created ⇒ revert deletes). + let ledger: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(pnpm.path().join(".socket/vendor/redirect-state.json")).unwrap(), + ) + .unwrap(); + assert!( + ledger["edits"].as_array().unwrap().iter().any(|e| { + e["kind"] == "redirect_pnpm_workspace_trust" + && e["action"] == "created" + && e["path"] == "pnpm-workspace.yaml" + && e["key"] == "trustLockfile" + }), + "the ledger must record the created workspace-trust edit: {ledger}" ); - // npm twin: only a package-lock.json is rewritten → no pnpm warning. + // npm twin: only a package-lock.json is rewritten → no pnpm warning and + // no workspace file materializes. let npm = tempfile::tempdir().unwrap(); write_project(npm.path()); let env = run_redirect_subprocess(npm.path(), &server.uri()); @@ -2038,6 +2225,348 @@ async fn pnpm_lock_redirect_warns_to_trust_lockfile() { "an npm-only redirect must not emit the pnpm trust-lockfile warning; got warnings {:?}", warning_codes(&env) ); + assert!( + !npm.path().join("pnpm-workspace.yaml").exists(), + "an npm-only redirect must not create pnpm-workspace.yaml" + ); +} + +/// `--no-trust-lockfile-config` (the opt-out for users who refuse the +/// whole-lock trust tradeoff): the redirect still lands, but nothing touches +/// pnpm-workspace.yaml, the ledger records no workspace-trust edit, and the +/// warning falls back to the OLD two-recovery guidance (per-run +/// `pnpm install --trust-lockfile`; committable `trustLockfile: true`). +#[tokio::test] +#[serial] +async fn pnpm_trust_opt_out_writes_nothing_and_keeps_manual_guidance() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_pnpm_project(tmp.path()); + let env = + run_redirect_subprocess_with(tmp.path(), &server.uri(), &["--no-trust-lockfile-config"]); + assert_eq!(env["status"], "success", "envelope: {env}"); + assert_eq!( + env["redirect"]["redirected"], 1, + "the opt-out must not stop the redirect itself: {env}" + ); + assert!( + !tmp.path().join("pnpm-workspace.yaml").exists(), + "the opt-out must not create pnpm-workspace.yaml" + ); + assert_eq!( + env["redirect"]["rewrittenFiles"], + serde_json::json!(["pnpm-lock.yaml"]), + "only the lock may be rewritten under the opt-out: {env}" + ); + let ledger: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(), + ) + .unwrap(); + assert!( + !ledger.to_string().contains("redirect_pnpm_workspace_trust"), + "the opt-out must record no workspace-trust edit: {ledger}" + ); + let detail = env["redirect"]["warnings"] + .as_array() + .unwrap() + .iter() + .find(|w| w["code"] == "redirect_pnpm_trust_lockfile") + .and_then(|w| w["detail"].as_str()) + .unwrap_or_default(); + assert!( + detail.contains("--trust-lockfile") + && detail.contains("trustLockfile: true") + && detail.contains("pnpm-workspace.yaml"), + "the opt-out warning must keep BOTH manual recoveries; got: {detail}" + ); + assert!( + detail.contains("pnpm clean --lockfile") && detail.contains("pnpm <=10"), + "the opt-out warning keeps the rebuild caution and version scoping; got: {detail}" + ); + assert!( + !detail.contains("no extra flags"), + "the opt-out warning must not claim trust was configured; got: {detail}" + ); +} + +/// An EXPLICIT user `trustLockfile: ` in pnpm-workspace.yaml is a +/// security decision the auto-config must never flip: the file stays +/// byte-identical, no workspace-trust edit is recorded, and the warning says +/// the setting was respected while spelling out the manual recoveries. +#[tokio::test] +#[serial] +async fn pnpm_trust_respects_an_explicit_user_false() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_pnpm_project(tmp.path()); + let user_ws = "packages:\n - '.'\ntrustLockfile: false\n"; + std::fs::write(tmp.path().join("pnpm-workspace.yaml"), user_ws).unwrap(); + + let env = run_redirect_subprocess(tmp.path(), &server.uri()); + assert_eq!(env["status"], "success", "envelope: {env}"); + assert_eq!( + env["redirect"]["redirected"], 1, + "the redirect itself must still land: {env}" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join("pnpm-workspace.yaml")).unwrap(), + user_ws, + "an explicit trustLockfile: false must be left byte-identical" + ); + let detail = env["redirect"]["warnings"] + .as_array() + .unwrap() + .iter() + .find(|w| w["code"] == "redirect_pnpm_trust_lockfile") + .and_then(|w| w["detail"].as_str()) + .unwrap_or_default(); + assert!( + detail.contains("respected") && detail.contains("trustLockfile: false"), + "the warning must say the explicit user setting was respected; got: {detail}" + ); + assert!( + detail.contains("--trust-lockfile"), + "the warning must fall back to the per-run flag recovery; got: {detail}" + ); + let ledger: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(), + ) + .unwrap(); + assert!( + !ledger.to_string().contains("redirect_pnpm_workspace_trust"), + "no workspace-trust edit may be recorded for a respected user setting: {ledger}" + ); +} + +/// Two hardening pins on the trust-lockfile warning's host list, which lands +/// in CI logs via both stderr and the persisted `--json` envelope: +/// +/// 1. USERINFO NEVER LEAKS. A credentialed artifact URL +/// (`http://alice:s3cret@patch.test/…`) is spliced into the lock verbatim, +/// but the warning must name `host[:port]` only — printing the URL +/// authority wholesale leaked `alice:s3cret` into CI logs. +/// 2. ONLY SPLICED HOSTS ARE NAMED. The host list must come from the URLs +/// actually present in a REWRITTEN pnpm-lock.yaml's final text, not from +/// every npm override: a sibling override that landed solely in +/// package-lock.json (here `other-host.test`) must not be named, or the +/// warning points users at a server the pnpm lock never references. +/// +/// Fixture: two granted npm patches — the credentialed one resolved ONLY by +/// the root pnpm-lock.yaml, the sibling resolved ONLY by package-lock.json. +#[tokio::test] +#[serial] +async fn pnpm_warning_strips_userinfo_and_names_only_spliced_hosts() { + const SIBLING_NAME: &str = "sibling-npm-only"; + const SIBLING_VERSION: &str = "2.0.0"; + const SIBLING_PURL: &str = "pkg:npm/sibling-npm-only@2.0.0"; + const SIBLING_UUID: &str = "33333333-3333-4333-8333-333333333333"; + const SIBLING_URL: &str = "http://other-host.test/patch/npm/sibling-npm-only/2.0.0/44444444-4444-4444-8444-444444444444/33333333-3333-4333-8333-333333333333/sibling-npm-only-2.0.0.tgz"; + const CRED_URL: &str = "http://alice:s3cret@patch.test/patch/npm/in-proc-redirect/1.0.0/22222222-2222-4222-8222-222222222222/11111111-1111-4111-8111-111111111111/in-proc-redirect-1.0.0.tgz"; + + let server = MockServer::start().await; + // Discovery: BOTH installed packages have a granted patch. + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [ + { + "purl": PURL, + "patches": [{ + "uuid": UUID, "purl": PURL, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", + "title": "credentialed redirect fixture" + }] + }, + { + "purl": SIBLING_PURL, + "patches": [{ + "uuid": SIBLING_UUID, "purl": SIBLING_PURL, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", + "title": "sibling redirect fixture" + }] + } + ], + "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; + // Per-package search, one mock per package name (the names share no + // substring, so the regexes cannot cross-match). + for (pkg_name, uuid, purl) in [ + (NAME, UUID, PURL), + (SIBLING_NAME, SIBLING_UUID, SIBLING_PURL), + ] { + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.*{pkg_name}.*$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": uuid, "purl": purl, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; + } + // Grants: the pnpm-locked package's tarball URL carries userinfo; the + // sibling's points at a DIFFERENT host that must never reach the warning. + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "results": { + UUID: { + "status": "granted", + "url": CRED_URL, + "purl": PURL, + "artifacts": [{ + "kind": "tarball", + "url": CRED_URL, + "integrity": { "sha512": PATCHED_SHA512 } + }], + "registryOverride": null + }, + SIBLING_UUID: { + "status": "granted", + "url": SIBLING_URL, + "purl": SIBLING_PURL, + "artifacts": [{ + "kind": "tarball", + "url": SIBLING_URL, + "integrity": { "sha512": PATCHED_SHA512 } + }], + "registryOverride": null + } + } + }))) + .mount(&server) + .await; + // Patch views for BOTH confirmed redirects (ledger records for VEX). + for (uuid, purl) in [(UUID, PURL), (SIBLING_UUID, SIBLING_PURL)] { + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{uuid}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": uuid, + "purl": purl, + "publishedAt": "2024-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": "a".repeat(64), + "afterHash": "b".repeat(64), + } + }, + "vulnerabilities": { + GHSA: { + "cves": ["CVE-2024-9"], + "summary": "redirect vex fixture", + "severity": "high", + "description": "d" + } + }, + "description": "x", "license": "MIT", "tier": "free" + }))) + .mount(&server) + .await; + } + + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{NAME}": "{VERSION}", "{SIBLING_NAME}": "{SIBLING_VERSION}" }} }}"# + ), + ) + .unwrap(); + write_installed(tmp.path(), NAME, VERSION, b"unpatched installed bytes\n"); + write_installed( + tmp.path(), + SIBLING_NAME, + SIBLING_VERSION, + b"unpatched sibling bytes\n", + ); + // pnpm-lock.yaml resolves ONLY the credentialed package… + std::fs::write(tmp.path().join("pnpm-lock.yaml"), rush_pnpm_lock(NAME)).unwrap(); + // …while package-lock.json resolves ONLY the sibling. + std::fs::write( + tmp.path().join("package-lock.json"), + format!( + r#"{{ + "name": "consumer", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": {{ + "": {{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{SIBLING_NAME}": "{SIBLING_VERSION}" }} }}, + "node_modules/{SIBLING_NAME}": {{ + "version": "{SIBLING_VERSION}", + "resolved": "https://registry.npmjs.org/{SIBLING_NAME}/-/{SIBLING_NAME}-{SIBLING_VERSION}.tgz", + "integrity": "sha512-UPSTREAMupstream==" + }} + }} +}} +"# + ), + ) + .unwrap(); + + let env = run_redirect_subprocess(tmp.path(), &server.uri()); + assert_eq!(env["status"], "success", "envelope: {env}"); + assert_eq!( + env["redirect"]["redirected"], 2, + "anchor: both locks must have been redirected: {env}" + ); + // Anchors: the credentialed URL really was spliced into the pnpm lock + // (so the no-leak assertion below is exercising a real splice), and the + // sibling's URL landed only in package-lock.json. + let pnpm_lock = std::fs::read_to_string(tmp.path().join("pnpm-lock.yaml")).unwrap(); + assert!( + pnpm_lock.contains(CRED_URL) && !pnpm_lock.contains("other-host.test"), + "pnpm-lock.yaml must carry the credentialed URL and nothing from the \ + sibling host; got:\n{pnpm_lock}" + ); + let npm_lock = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); + assert!( + npm_lock.contains(SIBLING_URL), + "package-lock.json must carry the sibling URL; got:\n{npm_lock}" + ); + + let detail = env["redirect"]["warnings"] + .as_array() + .unwrap() + .iter() + .find(|w| w["code"] == "redirect_pnpm_trust_lockfile") + .and_then(|w| w["detail"].as_str()) + .unwrap_or_default() + .to_string(); + // (1) Host only, never userinfo. The `@` check pins the whole authority + // form: no spelling of `user:pass@host` can survive it. + assert!( + detail.contains("patch.test"), + "the warning must name the spliced host; got: {detail}" + ); + assert!( + !detail.contains("alice") && !detail.contains("s3cret") && !detail.contains('@'), + "the warning must never leak URL userinfo (credentials) into CI logs; \ + got: {detail}" + ); + // (2) Only hosts spliced into a rewritten pnpm lock — the sibling landed + // solely in package-lock.json, so its host must not be named. + assert!( + !detail.contains("other-host.test"), + "the warning must name only hosts the pnpm lock actually points at; \ + got: {detail}" + ); } /// A clean, fully-successful hosted run must carry an EXACT warning set — not @@ -2435,7 +2964,10 @@ async fn redirect_ledger_write_failure_leaves_project_files_untouched() { perms.set_readonly(true); std::fs::set_permissions(&vendor_dir, perms.clone()).unwrap(); let out = run_hosted_json_scan(tmp.path(), &server).await; - // Restore writability so the tempdir can be cleaned up. + // Restore writability so the tempdir can be cleaned up. The blanket + // group-write concern behind the lint doesn't apply: this un-readonlies a + // private tempdir moments before its deletion. + #[allow(clippy::permissions_set_readonly_false)] perms.set_readonly(false); std::fs::set_permissions(&vendor_dir, perms).unwrap(); assert_write_failure_envelope(&out, "ledger-write failure"); @@ -2897,7 +3429,9 @@ async fn composer_redirect_is_confirmed_and_recorded() { } /// Mount the full cargo hosted-mock set (discovery + reference + view) for -/// one patch over `purl`. +/// one patch over `purl`. Eight positional fixture knobs beat a one-off +/// params struct for a test-local mock helper. +#[allow(clippy::too_many_arguments)] async fn mock_cargo_patch( server: &MockServer, purl: &str, diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs index 8229322d..73b9a375 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -238,8 +238,30 @@ async fn hosted_rewrites_pnpm_root_lock_resolution() { "the ledger must preserve the original upstream integrity for revert: {first}" ); + // Zero-touch trust config: a rewritten root v9 lock auto-creates + // pnpm-workspace.yaml with the root-only scaffold + `trustLockfile: true` + // (pnpm >=11 rejects the redirected lock without it; 9/10 ignore the + // key), and the ledger records the created-file edit for revert. + let ws_path = tmp.path().join("pnpm-workspace.yaml"); + let ws = std::fs::read_to_string(&ws_path) + .expect("the redirect must auto-create pnpm-workspace.yaml"); + assert_eq!( + ws, "packages:\n - '.'\ntrustLockfile: true\n", + "created workspace file must be the scaffold + trust key" + ); + assert!( + edits.iter().any(|e| { + e["kind"] == "redirect_pnpm_workspace_trust" + && e["action"] == "created" + && e["path"] == "pnpm-workspace.yaml" + && e["key"] == "trustLockfile" + }), + "the ledger must record the workspace-trust creation: {first}" + ); + // Idempotency: a second run rewrites nothing new — an already-redirected - // resolution must not append duplicate edits (which would poison a revert). + // resolution must not append duplicate edits (which would poison a revert), + // and the auto-created workspace file must stay byte-stable. let code = run(hosted_args(tmp.path(), server.uri())).await; assert_eq!(code, 0, "second scan --mode hosted should succeed"); let second: serde_json::Value = @@ -254,6 +276,162 @@ async fn hosted_rewrites_pnpm_root_lock_resolution() { lock, lock_after_rerun, "the re-run must leave the lock byte-stable" ); + assert_eq!( + std::fs::read_to_string(&ws_path).unwrap(), + ws, + "the re-run must leave pnpm-workspace.yaml byte-stable" + ); +} + +/// MERGE case: a pre-existing pnpm-workspace.yaml (comments, multi-glob +/// packages, catalog — none of it ours) gains EXACTLY one appended +/// `trustLockfile: true` line after its last non-empty line; every other +/// byte survives verbatim, and the ledger records the `added` (not +/// `created`) action so a revert removes just that line. +#[tokio::test] +#[serial] +async fn hosted_merges_trust_key_into_existing_workspace_yaml_byte_exactly() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_pnpm_project(tmp.path()); + let user_ws = + "# team workspace\npackages:\n - '.'\n - 'tools/*'\n\ncatalog:\n react: ^18.0.0\n"; + std::fs::write(tmp.path().join("pnpm-workspace.yaml"), user_ws).unwrap(); + + let code = run(hosted_args(tmp.path(), server.uri())).await; + assert_eq!(code, 0, "scan --mode hosted should succeed"); + + let ws = std::fs::read_to_string(tmp.path().join("pnpm-workspace.yaml")).unwrap(); + assert_eq!( + ws, + "# team workspace\npackages:\n - '.'\n - 'tools/*'\n\ncatalog:\n react: ^18.0.0\ntrustLockfile: true\n", + "the merge must preserve every user byte and append exactly one line" + ); + let ledger: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(), + ) + .unwrap(); + assert!( + ledger["edits"].as_array().unwrap().iter().any(|e| { + e["kind"] == "redirect_pnpm_workspace_trust" + && e["action"] == "added" + && e["path"] == "pnpm-workspace.yaml" + }), + "the ledger must record the merged (added) trust edit: {ledger}" + ); +} + +/// `--dry-run` previews: NOTHING lands on disk — no lock rewrite, no +/// pnpm-workspace.yaml, no ledger — while the envelope still reports both +/// files as would-be-rewritten (`dryRun: true`). +#[tokio::test] +#[serial] +async fn hosted_dry_run_writes_neither_lock_nor_workspace_trust() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_pnpm_project(tmp.path()); + let lock_before = std::fs::read(tmp.path().join("pnpm-lock.yaml")).unwrap(); + + let mut args = hosted_args(tmp.path(), server.uri()); + args.common.dry_run = true; + let code = run(args).await; + assert_eq!(code, 0, "dry-run scan --mode hosted should succeed"); + + assert_eq!( + std::fs::read(tmp.path().join("pnpm-lock.yaml")).unwrap(), + lock_before, + "dry-run must leave the lock byte-identical" + ); + assert!( + !tmp.path().join("pnpm-workspace.yaml").exists(), + "dry-run must not create pnpm-workspace.yaml" + ); + assert!( + !tmp.path() + .join(".socket/vendor/redirect-state.json") + .exists(), + "dry-run must not write the redirect ledger" + ); +} + +/// LEGACY lock (pnpm 8's lockfileVersion '6.0', byte-real matrix shape): the +/// plain `/name@version:` key stays redirectable, but the trustLockfile +/// auto-config must NOT fire — pnpm 7/8 have neither the >=11 policy nor the +/// flag, so writing trust config for them would be pure noise. No +/// pnpm-workspace.yaml appears and the ledger carries no workspace-trust +/// edit. +#[tokio::test] +#[serial] +async fn hosted_legacy_v6_lock_gets_no_workspace_trust_config() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::write( + root.join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{NAME}": "{VERSION}" }} }}"# + ), + ) + .unwrap(); + let pkg = root.join("node_modules").join(NAME); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{NAME}", "version": "{VERSION}" }}"#), + ) + .unwrap(); + // The v6 shape pnpm 8 emits (matrix hosted-pnpm8): quoted '6.0', + // `/name@version:` package key. + std::fs::write( + root.join("pnpm-lock.yaml"), + format!( + "lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +dependencies: + {NAME}: + specifier: {VERSION} + version: {VERSION} + +packages: + + /{NAME}@{VERSION}: + resolution: {{integrity: {UPSTREAM_SHA512}}} + dev: false +" + ), + ) + .unwrap(); + + let code = run(hosted_args(root, server.uri())).await; + assert_eq!(code, 0, "scan --mode hosted should succeed on a v6 lock"); + + let lock = std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(); + assert!( + lock.contains(&format!("tarball: {HOSTED_URL}")), + "anchor: the v6 lock must still be redirected; got:\n{lock}" + ); + assert!( + !root.join("pnpm-workspace.yaml").exists(), + "a legacy v6 lock must not trigger the trustLockfile auto-config" + ); + let ledger = std::fs::read_to_string(root.join(".socket/vendor/redirect-state.json")).unwrap(); + assert!( + !ledger.contains("redirect_pnpm_workspace_trust"), + "no workspace-trust edit may be recorded for a legacy lock: {ledger}" + ); } /// (a2) SCOPED package: pnpm lockfileVersion 9 single-quotes `packages:` keys @@ -445,3 +623,251 @@ async fn hosted_pnpm_vex_emits_redirected_attestation() { "the attestation must carry the (redirected) marker: {impact}" ); } + +/// The CLI binary with ambient SOCKET_* env scrubbed (same hermeticity rule +/// as `in_process_redirect.rs`'s `scrubbed_cli`; duplicated because test +/// binaries cannot share helpers). Subprocess (not in-process `run`) so the +/// `--json` stdout envelope can be read back — the diagnostics under test +/// ARE the envelope's `redirect.warnings`. +fn scrubbed_cli() -> std::process::Command { + let cmd = std::process::Command::new(env!("CARGO_BIN_EXE_socket-patch")); + let mut cmd = cmd; + for (key, _) in std::env::vars_os() { + let name = key.to_string_lossy(); + if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") && name != "SOCKET_NO_CONFIG" + { + cmd.env_remove(&key); + } + } + cmd +} + +/// Run `scan --mode hosted --json` as a subprocess against `cwd` and parse +/// the stdout envelope (exit code, parsed JSON). +fn run_hosted_json(cwd: &Path, api_url: &str) -> (Option, serde_json::Value) { + let out = scrubbed_cli() + .args([ + "scan", + "--mode", + "hosted", + "--json", + "--yes", + "--cwd", + cwd.to_str().unwrap(), + "--api-url", + api_url, + "--org", + ORG, + "--api-token", + "fake", + ]) + .output() + .expect("run socket-patch"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + let doc = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!("stdout must be the JSON envelope ({e});\nstdout=\n{stdout}\nstderr=\n{stderr}") + }); + (out.status.code(), doc) +} + +/// (c) pnpm 1/2 legacy project (the 2026-08-18 legacy-matrix layout: +/// `shrinkwrap.yaml` + `node_modules/.modules.yaml`, no pnpm-lock.yaml and +/// no package-lock.json): the no-lockfile diagnostic must be PNPM-flavored +/// — `redirect_pnpm_legacy_lockfile` naming shrinkwrap.yaml and the pnpm +/// upgrade path — never the npm `redirect_npm_no_lockfile` wording that +/// dead-ends on a pnpm project. The legacy lock also feeds the +/// lockfile-only supplement (shrinkwrap.yaml IS the v5 grammar under the +/// pre-rename filename), and the run stays fail-closed: shrinkwrap.yaml is +/// byte-untouched with zero redirects. +#[tokio::test] +#[serial] +async fn hosted_legacy_shrinkwrap_project_diagnoses_pnpm_not_npm() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::write( + root.join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{NAME}": "{VERSION}" }} }}"# + ), + ) + .unwrap(); + let pkg = root.join("node_modules").join(NAME); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{NAME}", "version": "{VERSION}" }}"#), + ) + .unwrap(); + // pnpm layout marker (pnpm 1/2 writes it, modern pnpm still does). + std::fs::write( + root.join("node_modules/.modules.yaml"), + "packageManager: pnpm@2.17.0\n", + ) + .unwrap(); + // shrinkwrapVersion-3 grammar (real shape from the legacy matrix): + // v5-style `/name/version` keys, BLOCK-mapped resolution. One entry is + // installed (NAME), one is lock-only — the supplement must see it. + let shrinkwrap = format!( + "dependencies: + {NAME}: {VERSION} +packages: + /{NAME}/{VERSION}: + dev: false + resolution: + integrity: {UPSTREAM_SHA512} + /legacy-only-dep/2.0.0: + dev: false + resolution: + integrity: sha512-LEGACYONLYlegacyonly== +registry: 'https://registry.npmjs.org/' +shrinkwrapMinorVersion: 9 +shrinkwrapVersion: 3 +specifiers: + {NAME}: {VERSION} +" + ); + std::fs::write(root.join("shrinkwrap.yaml"), &shrinkwrap).unwrap(); + + let (code, doc) = run_hosted_json(root, &server.uri()); + assert_eq!(code, Some(0), "fail-closed diagnostics still exit 0: {doc}"); + + let warnings = doc["redirect"]["warnings"].as_array().unwrap(); + assert_eq!( + warnings.len(), + 1, + "exactly the pnpm-legacy diagnostic, no npm noise: {doc}" + ); + assert_eq!( + warnings[0]["code"], "redirect_pnpm_legacy_lockfile", + "the family selection must be marker-aware: {doc}" + ); + let detail = warnings[0]["detail"].as_str().unwrap(); + assert!( + detail.contains("shrinkwrap.yaml") && detail.contains("pnpm"), + "detail must name the legacy lock and pnpm: {detail}" + ); + assert!( + !doc.to_string().contains("redirect_npm_no_lockfile"), + "the npm wording must be gone: {doc}" + ); + assert_eq!(doc["redirect"]["redirected"], 0, "nothing redirects: {doc}"); + + // The lockfile-only supplement reads shrinkwrap.yaml: the uninstalled + // `/legacy-only-dep/2.0.0` entry surfaces (it was 0 before the fix). + assert_eq!( + doc["lockfileOnlyPackages"], 1, + "shrinkwrap.yaml must feed the lockfile-only supplement: {doc}" + ); + + assert_eq!( + std::fs::read_to_string(root.join("shrinkwrap.yaml")).unwrap(), + shrinkwrap, + "shrinkwrap.yaml must be byte-untouched (fail-closed)" + ); +} + +/// (d) hosted over a VENDORED pnpm lock (the mode-conversion matrix's projB +/// shape: overrides + `@file:.socket/vendor/…` packages/snapshots +/// keys): the per-dep warning must be `redirect_pnpm_entry_vendored` +/// pointing at `vendor --revert` — not the `redirect_pnpm_entry_not_found` +/// wording that reads as "not locked" and invites a `pnpm install` +/// wild-goose chase. Fail-closed unchanged: zero redirects, lock untouched, +/// no redirect ledger. +#[tokio::test] +#[serial] +async fn hosted_over_vendored_pnpm_lock_diagnoses_vendored_not_missing() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::write( + root.join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{NAME}": "{VERSION}" }} }}"# + ), + ) + .unwrap(); + let pkg = root.join("node_modules").join(NAME); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{NAME}", "version": "{VERSION}" }}"#), + ) + .unwrap(); + // Byte-real vendored v9 shape (mode-conversion snap-B, renamed to the + // fixture package). + let vendored_spec = format!( + "file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/{NAME}-{VERSION}.tgz" + ); + let lock = format!( + "lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + {NAME}@{VERSION}: {vendored_spec} + +importers: + + .: + dependencies: + {NAME}: + specifier: {vendored_spec} + version: {vendored_spec} + +packages: + + {NAME}@{vendored_spec}: + resolution: {{integrity: {UPSTREAM_SHA512}, tarball: {vendored_spec}}} + version: {VERSION} + +snapshots: + + {NAME}@{vendored_spec}: {{}} +" + ); + std::fs::write(root.join("pnpm-lock.yaml"), &lock).unwrap(); + + let (code, doc) = run_hosted_json(root, &server.uri()); + assert_eq!(code, Some(0), "fail-closed diagnostics still exit 0: {doc}"); + + let warnings = doc["redirect"]["warnings"].as_array().unwrap(); + assert!( + warnings + .iter() + .any(|w| w["code"] == "redirect_pnpm_entry_vendored"), + "the vendored state must be named: {doc}" + ); + let detail = warnings + .iter() + .find(|w| w["code"] == "redirect_pnpm_entry_vendored") + .and_then(|w| w["detail"].as_str()) + .unwrap(); + assert!( + detail.contains("vendor --revert"), + "detail must give the mode-switch path: {detail}" + ); + assert!( + !doc.to_string().contains("redirect_pnpm_entry_not_found"), + "the not-locked wording must be gone for a vendored dep: {doc}" + ); + assert_eq!(doc["redirect"]["redirected"], 0, "nothing redirects: {doc}"); + assert_eq!( + std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(), + lock, + "the vendored lock must be byte-untouched (fail-closed)" + ); + assert!( + !root.join(".socket/vendor/redirect-state.json").exists(), + "a zero-redirect run must not write a redirect ledger" + ); +} diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 226bbaee..6dda0a88 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -1473,6 +1473,17 @@ fn vendor_missing_file_fails_closed_without_force() { let (code, env) = vendor_cli(fx.root(), &[]); assert_ne!(code, 0, "missing patch target must fail: {env:#}"); + // CONTRACT: even a run where EVERY outcome failed reports + // "partialFailure". The envelope has no "completed with zero successes" + // status, and status=error is reserved for pre-event failures (it implies + // a top-level error payload and empty events[] — json_envelope.rs), so + // escalating this run to "error" would violate the contract and diverge + // from scan --vendor and vendor --revert, which report the same outcome + // as partialFailure. Exit code 1 carries the failure signal. + assert_eq!( + env["status"], "partialFailure", + "all-failed vendor runs report partialFailure per the envelope contract: {env:#}" + ); let failed = find_event(&env, "failed", None); assert!( failed["error"] @@ -1956,3 +1967,340 @@ async fn scan_vendor_gem_detached_writes_no_manifest_and_reverts() { ); assert!(!fx.root().join(".socket/vendor").exists()); } + +// ───────────────────────────────────────────────────────────────────── +// hosted → vendored mode conversion (takeover reconciliation, pnpm v9) +// ───────────────────────────────────────────────────────────────────── +// +// The full migration a real project performs: `scan --mode hosted` first +// (wiremock API, v9 pnpm root lock — the shapes of +// `in_process_redirect_pnpm.rs`), then `vendor` over the hosted-redirected +// lock. Pins the npm-family takeover reconciliation: +// +// * the redirect ledger loses the converted purl's `records` entry AND its +// `redirect_pnpm_resolution` edits (stale halves fed VEX/updates and +// re-fired the takeover warning forever pre-fix), +// * the `vendor_supersedes_redirect` warning fires exactly ONCE — on the +// run that reconciles — and a re-vendor (`already_vendored`) is silent, +// * `vendor --revert` still restores the HOSTED-spliced lock byte-exactly +// (the hosted fragment is embedded as the vendor wiring `original`, so +// dropping the redirect ledger halves loses no revert data). +mod hosted_to_vendor_conversion { + use super::*; + use serial_test::serial; + use socket_patch_cli::commands::scan::{run as scan_run, ScanArgs, ScanMode}; + use wiremock::matchers::{method, path, path_regex}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + const ORG: &str = "test-org"; + const CONV_NAME: &str = "conv-pnpm-takeover"; + const CONV_VERSION: &str = "1.0.0"; + const CONV_PURL: &str = "pkg:npm/conv-pnpm-takeover@1.0.0"; + const CONV_UUID: &str = "44444444-4444-4444-8444-444444444444"; + const HOSTED_URL: &str = "http://patch.test/patch/npm/conv-pnpm-takeover/1.0.0/55555555-5555-4555-8555-555555555555/44444444-4444-4444-8444-444444444444/conv-pnpm-takeover-1.0.0.tgz"; + const PATCHED_SHA512: &str = "sha512-PATCHEDpatchedPATCHEDpatched0123456789=="; + const UPSTREAM_SHA512: &str = "sha512-UPSTREAMupstream=="; + + async fn mock_hosted_api(server: &MockServer) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "packages": [{ + "purl": CONV_PURL, + "patches": [{ + "uuid": CONV_UUID, "purl": CONV_PURL, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", + "title": "conversion fixture" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "patches": [{ + "uuid": CONV_UUID, "purl": CONV_PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": { + CONV_UUID: { + "status": "granted", + "url": HOSTED_URL, + "purl": CONV_PURL, + "artifacts": [{ + "kind": "tarball", + "url": HOSTED_URL, + "integrity": { "sha512": PATCHED_SHA512 } + }], + "registryOverride": null + } + } + }))) + .mount(server) + .await; + // `view/{uuid}` — the record the redirect ledger persists (real file + // hashes, so the ledger record mirrors the manifest the vendor run + // uses later). + let before_hash = compute_git_sha256_from_bytes(ORIG_INDEX); + let after_hash = compute_git_sha256_from_bytes(PATCHED_INDEX); + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{CONV_UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "uuid": CONV_UUID, + "purl": CONV_PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": before_hash, + "afterHash": after_hash, + } + }, + "vulnerabilities": {}, + "description": "x", "license": "MIT", "tier": "free" + }))) + .mount(server) + .await; + } + + /// The `in_process_redirect_pnpm.rs` project shape: v9 root pnpm lock + /// resolving the package under `packages:`, plus the installed + /// node_modules copy (with the real patch-target file, so the later + /// vendor run can pack the artifact). + fn write_pnpm_project(root: &Path) { + std::fs::write( + root.join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{CONV_NAME}": "{CONV_VERSION}" }} }}"# + ), + ) + .unwrap(); + let pkg = root.join("node_modules").join(CONV_NAME); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{CONV_NAME}", "version": "{CONV_VERSION}" }}"#), + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), ORIG_INDEX).unwrap(); + std::fs::write( + root.join("pnpm-lock.yaml"), + format!( + "lockfileVersion: '9.0' + +importers: + .: + dependencies: + {CONV_NAME}: + specifier: {CONV_VERSION} + version: {CONV_VERSION} + +packages: + {CONV_NAME}@{CONV_VERSION}: + resolution: {{integrity: {UPSTREAM_SHA512}}} + +snapshots: + {CONV_NAME}@{CONV_VERSION}: {{}} +" + ), + ) + .unwrap(); + } + + /// `--mode hosted` args against the wiremock API (the + /// `in_process_redirect_pnpm.rs` shape). + fn hosted_args(cwd: &Path, api_url: String) -> ScanArgs { + ScanArgs { + common: GlobalArgs { + cwd: cwd.to_path_buf(), + org: Some(ORG.to_string()), + api_token: Some("fake".to_string()), + api_url: Some(api_url), + json: true, + yes: true, + ..GlobalArgs::default() + }, + batch_size: 100, + apply: false, + prune: false, + sync: false, + vendor: false, + detached: false, + redirect: false, + mode: Some(ScanMode::Hosted), + all_releases: false, + vex: Default::default(), + } + } + + /// The manifest + staged blob the vendor run needs (vendor is driven + /// offline; hosted mode wrote no manifest — the ledger is its store). + fn seed_manifest_and_blob(root: &Path) { + let before_hash = compute_git_sha256_from_bytes(ORIG_INDEX); + let after_hash = compute_git_sha256_from_bytes(PATCHED_INDEX); + let manifest = json!({ + "patches": { + CONV_PURL: { + "uuid": CONV_UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": before_hash, + "afterHash": after_hash + } + }, + "vulnerabilities": {}, + "description": "conversion fixture", + "license": "MIT", + "tier": "free" + } + } + }); + let socket = root.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let mut bytes = serde_json::to_vec_pretty(&manifest).unwrap(); + bytes.push(b'\n'); + std::fs::write(socket.join("manifest.json"), &bytes).unwrap(); + std::fs::write(socket.join("blobs").join(after_hash), PATCHED_INDEX).unwrap(); + } + + fn takeover_warnings(envelope: &Value) -> Vec<&str> { + envelope["warnings"] + .as_array() + .map(|w| { + w.iter() + .filter(|e| e["code"] == "vendor_supersedes_redirect") + .map(|e| e["detail"].as_str().unwrap_or("")) + .collect() + }) + .unwrap_or_default() + } + + #[tokio::test] + #[serial] + async fn hosted_then_vendor_reconciles_ledger_warns_once_and_reverts_bytes() { + let server = MockServer::start().await; + mock_hosted_api(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_pnpm_project(root); + + // 1. Hosted redirect: the lock's resolution is spliced to the hosted + // tarball and the redirect ledger claims the purl. + let code = scan_run(hosted_args(root, server.uri())).await; + assert_eq!(code, 0, "scan --mode hosted must succeed"); + let hosted_lock = std::fs::read(root.join("pnpm-lock.yaml")).unwrap(); + let hosted_lock_text = String::from_utf8(hosted_lock.clone()).unwrap(); + assert!( + hosted_lock_text.contains(&format!("tarball: {HOSTED_URL}")), + "hosted splice missing:\n{hosted_lock_text}" + ); + let ledger_path = root.join(".socket/vendor/redirect-state.json"); + let ledger: Value = + serde_json::from_str(&std::fs::read_to_string(&ledger_path).unwrap()).unwrap(); + assert!( + ledger["records"].get(CONV_PURL).is_some(), + "hosted run must record the purl: {ledger}" + ); + + // 2. Vendor over the hosted-redirected lock (offline, staged blob). + seed_manifest_and_blob(root); + let (code, env1) = vendor_cli(root, &[]); + assert_eq!( + code, 0, + "vendor over the hosted lock must succeed: {env1:#}" + ); + find_event(&env1, "applied", None); + + // The takeover warning fired exactly once — on the reconciling run — + // and reports the reconciliation as DONE, not as advice to re-run. + let warns = takeover_warnings(&env1); + assert_eq!( + warns.len(), + 1, + "vendor_supersedes_redirect must fire exactly once: {env1:#}" + ); + assert!( + warns[0].contains("reconciled automatically"), + "the warning must state the reconciliation happened: {}", + warns[0] + ); + + // The redirect ledger no longer carries the purl's halves: its + // `records` entry and its `redirect_pnpm_resolution` edits are gone + // (a residual non-package edit like the workspace-trust one may + // remain — it is the hosted flow's own config surface). + match std::fs::read_to_string(&ledger_path) { + Ok(text) => { + let after: Value = serde_json::from_str(&text).unwrap(); + assert!( + after["records"].get(CONV_PURL).is_none(), + "the superseded record must be dropped: {after}" + ); + let leftover: Vec<&Value> = after["edits"] + .as_array() + .map(|edits| { + edits + .iter() + .filter(|e| e["key"].as_str().is_some_and(|k| k.contains(CONV_NAME))) + .collect() + }) + .unwrap_or_default(); + assert!( + leftover.is_empty(), + "the superseded package edits must be dropped: {after}" + ); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // Fully emptied ledgers are deleted — also a valid outcome. + } + Err(e) => panic!("unreadable redirect ledger: {e}"), + } + + // The vendor ledger's wiring `original` embeds the HOSTED-spliced + // fragment — the revert data the dropped ledger halves would + // otherwise have been the last copy of. + let state: Value = serde_json::from_str( + &std::fs::read_to_string(root.join(".socket/vendor/state.json")).unwrap(), + ) + .unwrap(); + assert!( + state["entries"][CONV_PURL]["wiring"] + .to_string() + .contains(HOSTED_URL), + "vendor wiring must embed the hosted-spliced original: {state:#}" + ); + + // 3. Re-vendor: an `already_vendored` no-op with NO takeover warning + // (pre-fix the stale ledger re-fired it on every run). + let (code, env2) = vendor_cli(root, &[]); + assert_eq!(code, 0, "re-vendor must succeed: {env2:#}"); + find_event(&env2, "skipped", Some("already_vendored")); + assert!( + takeover_warnings(&env2).is_empty(), + "a reconciled ledger must not re-fire the warning: {env2:#}" + ); + + // 4. `vendor --revert` restores the HOSTED-spliced lock byte-exactly. + let (code, renv) = vendor_cli(root, &["--revert"]); + assert_eq!(code, 0, "revert must succeed: {renv:#}"); + assert_eq!( + std::fs::read(root.join("pnpm-lock.yaml")).unwrap(), + hosted_lock, + "revert must byte-restore the hosted-spliced lock" + ); + } +} diff --git a/crates/socket-patch-cli/tests/remove_rollback_api_overrides.rs b/crates/socket-patch-cli/tests/remove_rollback_api_overrides.rs index b1090242..7433b474 100644 --- a/crates/socket-patch-cli/tests/remove_rollback_api_overrides.rs +++ b/crates/socket-patch-cli/tests/remove_rollback_api_overrides.rs @@ -57,6 +57,7 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_TELEMETRY_DISABLED", "SOCKET_ONE_OFF", "SOCKET_SKIP_ROLLBACK", + "SOCKET_NO_TRUST_LOCKFILE_CONFIG", ]; /// Drift guard: the scrub must cover every env var `GlobalArgs` binds — the @@ -148,6 +149,8 @@ fn dead_port() -> u16 { fn remove_rollback_downloads_missing_blob_via_flag_overrides() { let before = b"original-content\n"; let before_hash = git_sha256(before); + let after = b"patched-content\n"; + let after_hash = git_sha256(after); let (port, seen_paths) = spawn_blob_server(before_hash.clone(), before.to_vec()); let dead = dead_port(); @@ -155,6 +158,25 @@ fn remove_rollback_downloads_missing_blob_via_flag_overrides() { let tmp = tempfile::tempdir().expect("tempdir"); let socket = tmp.path().join(".socket"); std::fs::create_dir_all(socket.join("blobs")).unwrap(); + // The package must be INSTALLED, at its patched (afterHash) state: + // since the before-blob gate reorder, only installed packages whose + // files genuinely need their original bytes back enter the blob plan — + // a manifest-only entry is a benign `package_not_installed` skip that + // never downloads anything, and the restore below must succeed for + // `remove` to exit 0. + std::fs::write( + tmp.path().join("package.json"), + r#"{ "name": "ovr-root", "version": "0.0.0" }"#, + ) + .unwrap(); + let pkg_dir = tmp.path().join("node_modules/__ovr_test__"); + std::fs::create_dir_all(&pkg_dir).unwrap(); + std::fs::write( + pkg_dir.join("package.json"), + r#"{ "name": "__ovr_test__", "version": "1.0.0" }"#, + ) + .unwrap(); + std::fs::write(pkg_dir.join("index.js"), after).unwrap(); // The before-blob is deliberately ABSENT from .socket/blobs: the // rollback gate must download it through the flag-configured client. let manifest = format!( @@ -166,7 +188,7 @@ fn remove_rollback_downloads_missing_blob_via_flag_overrides() { "files": {{ "package/index.js": {{ "beforeHash": "{before_hash}", - "afterHash": "1111111111111111111111111111111111111111111111111111111111111111" + "afterHash": "{after_hash}" }} }}, "vulnerabilities": {{}}, diff --git a/crates/socket-patch-cli/tests/repair_vendor_e2e.rs b/crates/socket-patch-cli/tests/repair_vendor_e2e.rs index dee42141..41a5d869 100644 --- a/crates/socket-patch-cli/tests/repair_vendor_e2e.rs +++ b/crates/socket-patch-cli/tests/repair_vendor_e2e.rs @@ -241,6 +241,11 @@ fn events_of(v: &serde_json::Value) -> Vec { v["events"].as_array().cloned().unwrap_or_default() } +/// Run-level `warnings[]` (`{code, detail}`); empty when omitted. +fn warnings_of(v: &serde_json::Value) -> Vec { + v["warnings"].as_array().cloned().unwrap_or_default() +} + /// 1. Deleted tarball → `repair` rebuilds it byte-identically (installed /// copy + view-fetched patch content), lockfile and ledger untouched. #[tokio::test] @@ -357,6 +362,92 @@ async fn repair_rebuilds_corrupt_vendored_tarball() { ); } +/// 3b. Corrupt tarball with NO rebuild source: the per-entry failure must +/// PRESERVE the corrupt-but-diagnosable bytes. Deleting them (as the +/// old delete-corrupt-first pass did) converts an integrity-mismatch +/// state into a bare ENOENT on the next install — the lock still +/// points at the tarball — and destroys the forensic evidence of the +/// tamper. Both no-source rungs are pinned: patch content missing +/// (staging unavailable) and patch content present but the pristine +/// package unreachable (--offline, node_modules gone). RED before the +/// rebuild-source-first ordering: the uuid dir was emptied up front +/// and both arms left it bare. +#[tokio::test] +async fn repair_keeps_corrupt_artifact_when_no_rebuild_source_exists() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_fixture( + tmp.path(), + "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "sha512-orig==", + ); + let tgz = vendor_project(tmp.path(), &mock.uri(), &[]); + let tgz_bytes = std::fs::read(&tgz).unwrap(); + + const GARBAGE: &[u8] = b"\x1f\x8bgarbage"; + std::fs::write(&tgz, GARBAGE).unwrap(); + std::fs::remove_dir_all(tmp.path().join("node_modules")).unwrap(); + + // Arm 1: no local patch sources either — the staging step itself has + // nothing to rebuild from. + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["repair", "--offline"]); + assert_eq!(code, 1, "stdout={stdout} stderr={stderr}"); + let v = parse_env(&stdout); + assert!( + events_of(&v).iter().any(|e| e["action"] == "failed" + && e["purl"] == PURL + && e["error"].as_str().unwrap_or("").contains("--offline")), + "the failure names the purl and the offline cause: {v}" + ); + assert_eq!( + std::fs::read(&tgz).unwrap(), + GARBAGE, + "arm 1: an unrebuildable corrupt artifact must not be destroyed" + ); + + // Arm 2: patch content IS local (seeded after-blob), but the pristine + // package ladder still has no source — the corrupt copy must survive + // the deeper rung too. + let blobs = tmp.path().join(".socket/blobs"); + std::fs::create_dir_all(&blobs).unwrap(); + std::fs::write(blobs.join(git_sha256(AFTER)), AFTER).unwrap(); + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["repair", "--offline"]); + assert_eq!(code, 1, "stdout={stdout} stderr={stderr}"); + let v = parse_env(&stdout); + assert!( + events_of(&v).iter().any(|e| e["action"] == "failed" + && e["purl"] == PURL + && e["error"].as_str().unwrap_or("").contains("--offline")), + "the failure names the purl and the offline cause: {v}" + ); + assert_eq!( + std::fs::read(&tgz).unwrap(), + GARBAGE, + "arm 2: an unrebuildable corrupt artifact must not be destroyed" + ); + + // Heal: with the installed copy restored, the same repair rebuilds the + // recorded bytes — retention never wedges the corrupt→rebuild path. + let pkg = tmp.path().join("node_modules/left-pad"); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + br#"{"name":"left-pad","version":"1.3.0"}"#, + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), BEFORE).unwrap(); + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["repair", "--offline"]); + assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); + let v = parse_env(&stdout); + assert_eq!(v["summary"]["rebuilt"], 1, "envelope={v}"); + assert_eq!( + std::fs::read(&tgz).unwrap(), + tgz_bytes, + "rebuild restores the recorded bytes once a source exists" + ); +} + /// 4. A tampered ledger sha can never be satisfied: the rebuild is removed /// and the run fails loudly rather than leaving unverifiable bytes. #[tokio::test] @@ -499,6 +590,24 @@ async fn repair_reconstructs_ledger_from_lockfile_references() { lock1, "lockfile untouched" ); + // npm wiring originals are registry integrity material repair cannot + // reconstruct offline: the gap is a run-level ADVISORY (the entry + // itself repaired fine), so it rides `warnings[]` naming the purl — + // never `events[]` as a `skipped` a consumer would count as work not + // done. + assert!( + warnings_of(&v) + .iter() + .any(|w| w["code"] == "vendor_wiring_unknown" + && w["detail"].as_str().unwrap_or("").contains(PURL)), + "the wiring gap rides run-level warnings[] with the purl: {v}" + ); + assert!( + !events_of(&v) + .iter() + .any(|e| e["errorCode"] == "vendor_wiring_unknown"), + "no skipped event for the run-level advisory: {v}" + ); // The re-synthesized ledger entry: same uuid, fingerprint of the // rebuilt bytes, NOT detached (the manifest still has the record). @@ -515,11 +624,23 @@ async fn repair_reconstructs_ledger_from_lockfile_references() { "recomputed fingerprint matches the rebuilt artifact: {state}" ); - // Revert degrades gracefully (no recorded originals): exit 0, artifact - // removed, the drifted-entry guidance surfaced. + // Revert fails CLOSED: there are no recorded originals to replay and + // the rewired lock still resolves through the artifact — removing it + // would brick every later `npm ci` (see test 12 for the full recovery + // arc). let (code, stdout, _) = run_cli(tmp.path(), &mock.uri(), &["vendor", "--revert"]); - assert_eq!(code, 0, "revert of a reconstructed entry: {stdout}"); - assert!(!tgz.exists(), "revert removed the artifact"); + assert_ne!( + code, 0, + "revert of a still-wired reconstructed entry must refuse: {stdout}" + ); + let v = parse_env(&stdout); + assert!( + events_of(&v) + .iter() + .any(|e| e["errorCode"] == "vendor_wiring_unknown_revert_blocked"), + "envelope={v}" + ); + assert!(tgz.exists(), "the artifact the lock references survives"); } /// 7b. Only `state.json` was lost; the committed artifact survived INTACT. @@ -906,6 +1027,122 @@ async fn repair_dry_run_previews_rebuild() { assert!(!tgz.exists(), "dry run writes nothing"); } +/// 12. The flavor-None brick, exactly as observed empirically (real project, +/// 2026-08-18): only `state.json` is lost, the artifact and the rewired +/// `package-lock.json` survive. `repair` reconstructs the entry with +/// EMPTY wiring (npm pre-vendor lock fragments are not +/// offline-recoverable) — and `vendor --revert` of that entry used to +/// exit 0 while DELETING the tarball the lock still resolves through, +/// silently bricking every later `npm ci` (ENOENT on the file: spec). +/// Now: the revert fails closed (`vendor_wiring_unknown_revert_blocked`), +/// the artifact and lock survive, `repair` stays idempotent, and once +/// the pre-vendor lock is restored a normal revert removes the orphaned +/// artifact cleanly. The reconstruction also stamps the flavor it found +/// the reference in (`package-lock`), so revert routes to the backend +/// whose guard probes the RIGHT lockfile. +#[tokio::test] +async fn revert_of_reconstructed_package_lock_entry_fails_closed_then_recovers() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_fixture( + tmp.path(), + "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "sha512-orig==", + ); + let lock_pre = std::fs::read(tmp.path().join("package-lock.json")).unwrap(); + let tgz = vendor_project(tmp.path(), &mock.uri(), &[]); + let lock_vendored = std::fs::read(tmp.path().join("package-lock.json")).unwrap(); + + // Ledger gone; artifact + rewired lock intact (the empirical shape). + // The anchored reconstruction restores the entry with wiring: []. + std::fs::remove_file(tmp.path().join(".socket/vendor/state.json")).unwrap(); + mount_blob(&mock).await; + let (code, stdout, stderr) = run_cli( + tmp.path(), + &mock.uri(), + &["repair", "--download-mode", "file"], + ); + assert_eq!(code, 0, "reconstruction: stdout={stdout} stderr={stderr}"); + let state_path = tmp.path().join(".socket/vendor/state.json"); + let state: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap(); + assert_eq!( + state["entries"][PURL]["wiring"].as_array().map(Vec::len), + Some(0), + "npm wiring is not offline-recoverable: {state}" + ); + let stamped_flavor = state["entries"][PURL]["flavor"].clone(); + + // Nothing to replay + the lock still resolves through the artifact: + // revert must refuse loudly instead of silently removing the tarball. + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["vendor", "--revert"]); + assert_ne!( + code, 0, + "revert of an empty-wiring entry the lock still references must fail closed: \ + stdout={stdout} stderr={stderr}" + ); + let v = parse_env(&stdout); + assert!( + events_of(&v) + .iter() + .any(|e| e["errorCode"] == "vendor_wiring_unknown_revert_blocked"), + "envelope={v}" + ); + assert!( + tgz.is_file(), + "the artifact the lock still references must survive the refusal" + ); + assert_eq!( + std::fs::read(tmp.path().join("package-lock.json")).unwrap(), + lock_vendored, + "the lock stays untouched by the refusal" + ); + + // The reconstruction identified the referencing lockfile, so the entry + // carries the detected flavor — not None (which would depend on the + // flavor-None fallback route for its guard). + assert_eq!( + stamped_flavor, + serde_json::json!("package-lock"), + "reconstruction stamps the detected flavor" + ); + + // Recovery, exactly as the refusal advises: `repair` keeps the vendored + // artifact healthy (idempotent — the entry and tarball survive)... + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["repair"]); + assert_eq!( + code, 0, + "repair after refusal: stdout={stdout} stderr={stderr}" + ); + assert!(tgz.is_file(), "repair keeps the artifact"); + + // ...and once the pre-vendor lock is restored (the manual-restore arm — + // the wiring originals are unrecoverable by design), a normal revert + // removes the now-orphaned artifact cleanly. + std::fs::write(tmp.path().join("package-lock.json"), &lock_pre).unwrap(); + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["vendor", "--revert"]); + assert_eq!(code, 0, "final revert: stdout={stdout} stderr={stderr}"); + let v = parse_env(&stdout); + assert!( + events_of(&v) + .iter() + .any(|e| e["action"] == "removed" && e["purl"] == PURL), + "envelope={v}" + ); + assert!( + !tmp.path() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "the orphaned artifact dir is removed" + ); + assert_eq!( + std::fs::read(tmp.path().join("package-lock.json")).unwrap(), + lock_pre, + "clean end state: the restored pre-vendor lock is untouched" + ); +} + // ────────────────────────────── gem rows ────────────────────────────── const GEM_UUID: &str = "22222222-2222-4222-8222-222222222222"; @@ -1129,7 +1366,10 @@ async fn repair_reconstructs_gem_wiring_and_revert_byte_restores() { assert!( !events_of(&v) .iter() - .any(|e| e["errorCode"] == "vendor_wiring_unknown"), + .any(|e| e["errorCode"] == "vendor_wiring_unknown") + && !warnings_of(&v) + .iter() + .any(|w| w["code"] == "vendor_wiring_unknown"), "gem wiring IS reconstructable — no unknown-wiring warning: {v}" ); // THE oracle: the reconstructed ledger equals the original, wiring, diff --git a/crates/socket-patch-cli/tests/repair_vendor_flavors_e2e.rs b/crates/socket-patch-cli/tests/repair_vendor_flavors_e2e.rs index d7eae377..a14c1b59 100644 --- a/crates/socket-patch-cli/tests/repair_vendor_flavors_e2e.rs +++ b/crates/socket-patch-cli/tests/repair_vendor_flavors_e2e.rs @@ -587,6 +587,136 @@ async fn repair_reconstructs_pnpm_ledger_from_lockfile() { ledger_gone_reconstructs_from_lock(Flavor::Pnpm).await; } +// ── (f) reconstructed empty-wiring entry: revert must not brick installs ──── +// +// Empirically confirmed brick (real pnpm@10.34.5 project, 2026-08-18): after +// `repair` reconstructs a ledger-gone vendored entry from the lockfile, the +// entry carries EMPTY wiring (npm-family pre-vendor lock fragments are not +// offline-recoverable). A subsequent `vendor --revert` used to exit 0 with a +// bare {"action":"removed"} event — zero warnings — while DELETING the +// vendored tarball pnpm-lock.yaml still resolves through in several places; +// every later `pnpm install` then fails ENOENT on the missing file: tarball. +// The npm-family backends now fail closed +// (`vendor_wiring_unknown_revert_blocked`) when there is nothing to replay +// and the lock still references the artifact, and still remove genuinely +// orphaned artifacts once the lock no longer does. `repair`'s reconstruction +// also stamps the flavor it found the reference in (asserted below), so the +// revert routes to the backend whose guard probes the RIGHT lockfile; a +// flavor-None entry falls back to the package-lock backend, which is +// guarded too (repair_vendor_e2e.rs test 12). + +#[tokio::test] +async fn revert_of_reconstructed_pnpm_entry_fails_closed_then_recovers() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_fixture(tmp.path(), Flavor::Pnpm); + let lock_pre = std::fs::read(tmp.path().join("pnpm-lock.yaml")).unwrap(); + let pkg_pre = std::fs::read(tmp.path().join("package.json")).unwrap(); + let tgz = vendor_project(tmp.path(), &mock.uri()); + let lock_vendored = std::fs::read(tmp.path().join("pnpm-lock.yaml")).unwrap(); + + // Ledger gone; artifact + rewired lock intact (the empirical shape). + // The anchored reconstruction restores the entry with wiring: []. + std::fs::remove_file(tmp.path().join(".socket/vendor/state.json")).unwrap(); + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["repair"]); + assert_eq!(code, 0, "reconstruction: stdout={stdout} stderr={stderr}"); + let state_path = tmp.path().join(".socket/vendor/state.json"); + let state: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap(); + assert_eq!( + state["entries"][PURL]["wiring"].as_array().map(Vec::len), + Some(0), + "npm wiring is not offline-recoverable: {state}" + ); + // The reconstruction found the reference in pnpm-lock.yaml (v9), so the + // entry is stamped with the pnpm flavor — revert routes to the pnpm + // backend and its guard probes pnpm-lock.yaml, not package-lock.json. + assert_eq!( + state["entries"][PURL]["flavor"], + serde_json::json!("pnpm"), + "reconstruction stamps the detected flavor: {state}" + ); + + // Nothing to replay + the lock still resolves through the artifact: + // revert must refuse loudly instead of silently removing the tarball. + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["vendor", "--revert"]); + assert_ne!( + code, 0, + "revert of an empty-wiring entry the lock still references must fail closed: \ + stdout={stdout} stderr={stderr}" + ); + let v = parse_env(&stdout); + assert!( + events_of(&v) + .iter() + .any(|e| e["errorCode"] == "vendor_wiring_unknown_revert_blocked"), + "envelope={v}" + ); + assert!( + tgz.is_file(), + "the artifact the lock still references must survive the refusal" + ); + assert_eq!( + std::fs::read(tmp.path().join("pnpm-lock.yaml")).unwrap(), + lock_vendored, + "the lock stays untouched by the refusal" + ); + + // Recovery, exactly as the refusal advises: `repair` keeps the vendored + // artifact healthy (idempotent — the entry and tarball survive)... + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["repair"]); + assert_eq!( + code, 0, + "repair after refusal: stdout={stdout} stderr={stderr}" + ); + assert!(tgz.is_file(), "repair keeps the artifact"); + + // ...and once the pre-vendor surfaces are restored (the manual-restore + // arm — the wiring originals are unrecoverable by design), a normal + // revert removes the now-orphaned artifact cleanly. + std::fs::write(tmp.path().join("pnpm-lock.yaml"), &lock_pre).unwrap(); + std::fs::write(tmp.path().join("package.json"), &pkg_pre).unwrap(); + let ws = tmp.path().join("pnpm-workspace.yaml"); + if ws.exists() { + std::fs::remove_file(&ws).unwrap(); + } + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["vendor", "--revert"]); + assert_eq!(code, 0, "final revert: stdout={stdout} stderr={stderr}"); + let v = parse_env(&stdout); + assert!( + events_of(&v) + .iter() + .any(|e| e["action"] == "removed" && e["purl"] == PURL), + "envelope={v}" + ); + assert!( + !tmp.path() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "the orphaned artifact dir is removed" + ); + assert_eq!( + std::fs::read(tmp.path().join("pnpm-lock.yaml")).unwrap(), + lock_pre, + "clean end state: the restored pre-vendor lock is untouched" + ); + // The ledger entry is gone — either the state file was removed with its + // last entry, or it persists with an empty entries map. + match std::fs::read_to_string(&state_path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Ok(text) => { + let state: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!( + state["entries"].as_object().map(serde_json::Map::len), + Some(0), + "ledger entry gone: {state}" + ); + } + Err(e) => panic!("unreadable state.json: {e}"), + } +} + #[tokio::test] async fn repair_reconstructs_yarn_berry_ledger_from_lockfile() { ledger_gone_reconstructs_from_lock(Flavor::YarnBerry).await; diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 4ebb1150..7b5dd555 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -28,8 +28,8 @@ pub mod golang_local; mod state; mod takeover; pub use state::{ - load_redirect_state, persist_redirect_state, save_redirect_state, CorruptRedirectState, - RedirectState, REDIRECT_STATE_REL, + drop_superseded_purl, load_redirect_state, persist_redirect_state, save_redirect_state, + CorruptRedirectState, RedirectState, REDIRECT_STATE_REL, }; pub use takeover::{revert_cargo_redirect_purl, CargoRedirectRevert}; @@ -235,10 +235,40 @@ fn rewrite_npm_lock( || k.ends_with("/pnpm-lock.yaml") }); if !sibling_lock_present { - result.warnings.push(RewriteWarning { - code: "redirect_npm_no_lockfile".into(), - detail: "no package-lock.json / npm-shrinkwrap.json present".into(), - }); + // Family selection is marker-aware: `shrinkwrap.yaml` is the + // pnpm <=2-era lock (pnpm 3 renamed it to pnpm-lock.yaml; npm + // never emits that filename), and `node_modules/.modules.yaml` + // is pnpm's installer state file — either one proves the + // project is pnpm, where the npm "no package-lock.json" + // wording sends users to the wrong package manager. Both are + // read-only markers handed in via the CLI's candidate list; no + // rewriter edits them. The shrinkwrap check runs first so a + // fresh clone (shrinkwrap.yaml committed, node_modules absent) + // still names the legacy lock. + let warning = if files.contains_key("shrinkwrap.yaml") { + RewriteWarning { + code: "redirect_pnpm_legacy_lockfile".into(), + detail: "shrinkwrap.yaml is the pnpm <=2-era lockfile; the \ + redirect only rewrites pnpm-lock.yaml — upgrade pnpm \ + (>=3) and reinstall so it emits pnpm-lock.yaml, then \ + re-run" + .into(), + } + } else if files.contains_key("node_modules/.modules.yaml") { + RewriteWarning { + code: "redirect_pnpm_no_lockfile".into(), + detail: "pnpm project (node_modules/.modules.yaml present) but \ + no pnpm-lock.yaml; run `pnpm install` to generate one, \ + then re-run" + .into(), + } + } else { + RewriteWarning { + code: "redirect_npm_no_lockfile".into(), + detail: "no package-lock.json / npm-shrinkwrap.json present".into(), + } + }; + result.warnings.push(warning); } return; }; @@ -1467,6 +1497,82 @@ fn plan_cargo_config( } // ── pnpm-lock.yaml ─────────────────────────────────────────────────────────── + +/// LOOSE post-splice residual probe for ONE dep over one pnpm lock text: the +/// lock instance keys of `@` — in ANY grammar pnpm has +/// shipped (v9 `name@version`, quoted scoped spellings, v6 +/// `/name@version(peers…)` including NESTED peer parens the splice regex +/// provably cannot match, v5 `/name/version` with `_` suffixes) and with ANY +/// suffix spelling, anticipated or not — whose own `resolution:` block does +/// NOT reference `artifact_url`. +/// +/// The splice regex is the WRITER and must stay strict (it rebuilds the +/// resolution byte-surgically). This probe is the AUDITOR: it only answers +/// "does an instance of this exact name@version remain pointed somewhere +/// else?", so it is deliberately looser than the writer — an instance key +/// the writer's grammar cannot even parse still shows up here, and the +/// caller then refuses the dep instead of shipping a partial rewrite (the +/// fail-open the original pre-splice `redirect_pnpm_unsupported_lock_key` +/// refusal guarded against). +/// +/// Keys are recognized version-exactly: `@` / v5 +/// `/` followed by nothing or by a character that cannot +/// extend a version (so `left-pad@1.3.0` never claims `left-pad@1.3.01`). +/// v9 `snapshots:` instance keys carry no `resolution:` line and pin +/// nothing, so a key with no resolution in its block does not count. +fn pnpm_unrewritten_instances( + content: &str, + fname: &str, + version: &str, + artifact_url: &str, +) -> Vec { + let at_form = format!("{fname}@{version}"); + let slash_form = format!("{fname}/{version}"); + // A character that could extend `version` into a LONGER version string + // (semver body chars) — anything else marks a suffix boundary. + let extends_version = |c: char| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'); + let mut residual: Vec = Vec::new(); + let lines: Vec<&str> = content.lines().collect(); + for (i, line) in lines.iter().enumerate() { + // Lock instance keys are 2-space-indented mapping keys: ` :`. + let Some(rest) = line.strip_prefix(" ") else { + continue; + }; + if rest.starts_with(' ') { + continue; + } + let Some(raw_key) = rest.strip_suffix(':') else { + continue; + }; + let unquoted = raw_key.trim_matches(|c| c == '\'' || c == '"'); + let key = unquoted.strip_prefix('/').unwrap_or(unquoted); + let Some(suffix) = key + .strip_prefix(&at_form) + .or_else(|| key.strip_prefix(&slash_form)) + else { + continue; + }; + if suffix.chars().next().is_some_and(extends_version) { + continue; + } + // The entry's block: the following deeper-indented lines. A + // `resolution:` pointing anywhere but the hosted artifact is a + // residual; no resolution at all (v9 `snapshots:` keys) pins nothing. + for entry_line in &lines[i + 1..] { + if !entry_line.trim().is_empty() && !entry_line.starts_with(" ") { + break; + } + if entry_line.trim_start().starts_with("resolution:") + && !entry_line.contains(artifact_url) + { + residual.push(raw_key.to_string()); + break; + } + } + } + residual +} + fn rewrite_pnpm_lock( files: &BTreeMap, overrides: &[DepOverride], @@ -1518,115 +1624,230 @@ fn rewrite_pnpm_lock( }); continue; }; - // `(^ {2}(?:''|/?):\n(?: {4,}.*\n)*? {4,}resolution: )\{([^}\n]*)\}` - // where `` is `@`. lockfileVersion 9 single-quotes keys - // that begin with `@` (`'@scope/name@1.0.0':` — YAML forbids a plain - // scalar starting with `@`); v6 keys start with `/` and are unquoted. + // One `packages:`-section entry per INSTANCE of the dep, across every + // lock grammar pnpm has shipped (each carries its own `resolution:` + // block — verified against locks emitted by corepack pnpm@7.33.5 and + // pnpm@8.15.9, 2026-08-18): + // v9: `@:` — single-quoted when the name starts with `@` + // (YAML forbids a plain scalar starting with `@`); resolved + // peers live in `snapshots:` keys, which carry no resolution. + // v6: `/@:` plus one `/@(peerA@x)(peerB@y):` + // per resolved-peer combination. + // v5.x: `//:` plus `//_:` per + // combination (`_react@18.2.0`, or a hash for long sets). + // EVERY matching instance is spliced. Rewriting only the first would + // fail open: a v6 lock holding both `/pkg@1.0.0:` and + // `/pkg@1.0.0(peer@2.0.0):` would confirm and attest the dep while + // every dependent resolving through the peered entry still installs + // the unpatched upstream tarball. let key = regex::escape(&fname) + "@" + ®ex::escape(&dep.version); - // Legacy pnpm lock grammars this rewriter cannot repoint: lockfile- - // Version 6 embeds resolved peers in the `packages:` key itself - // (`/name@1.0.0(peer@2.0.0):`) and v5.x separates the version with a - // slash (`/name/1.0.0:`, peers suffixed `_peer@2.0.0`). Both carry - // their own `resolution:` block the pattern below never matches. - // Rewriting AROUND them is fail-open: a v6 lock holding both - // `/pkg@1.0.0:` and `/pkg@1.0.0(peer@2.0.0):` would get the plain - // entry rewritten — confirming and attesting the dep — while every - // dependent resolving through the peered entry still installs the - // unpatched upstream tarball. So when ANY such key exists for this - // dep in ANY lock, refuse the dep outright (no rewrite anywhere), - // naming the unmatched keys. v9 is unaffected: its peer-suffixed - // `snapshots:` keys never start with `/` (and carry no resolution). - let legacy_pat = String::from(r"(?m)^ {2}(/") + let pat = String::from(r"(?m)(^ {2}('") + + &key + + r"'|/?" + &key - + r"\([^:\n]*|/" + + r"(?:\([^)\n]*\))*|/" + ®ex::escape(&fname) + "/" + ®ex::escape(&dep.version) - + r"(?:[_(][^:\n]*)?):"; - let legacy_re = Regex::new(&legacy_pat) - .expect("legacy-key regex from the escaped name and version is valid"); - let mut legacy_keys: Vec = Vec::new(); - for (lock_key, content, _) in &contents { - for caps in legacy_re.captures_iter(content) { - legacy_keys.push(format!("{} in {lock_key}", &caps[1])); - } - } - if !legacy_keys.is_empty() { - result.warnings.push(RewriteWarning { - code: "redirect_pnpm_unsupported_lock_key".into(), - detail: format!( - "{fname}@{} resolves through pnpm v5/v6 lock key(s) the \ - redirect grammar cannot repoint: {}; left unredirected — \ - regenerate the lock with pnpm >=9 (lockfileVersion 9) \ - and re-run", - dep.version, - legacy_keys.join(", ") - ), - }); - continue; - } - let pat = String::from(r"(?m)(^ {2}(?:'") - + &key - + r"'|/?" - + &key - + r"):\n(?: {4,}.*\n)*? {4,}resolution: )\{([^}\n]*)\}"; + + r"(?:_[^:\n]*)?):\n(?: {4,}.*\n)*? {4,}resolution: )\{([^}\n]*)\}"; let re = Regex::new(&pat).expect("resolution regex from the escaped name@version key is valid"); let mut matched_any = false; - for (key, content, changed) in &mut contents { - let Some(caps) = re.captures(content) else { - continue; - }; - matched_any = true; - let whole = caps - .get(0) - .expect("group 0 is the whole match") - .as_str() - .to_string(); - let prefix = caps - .get(1) - .expect("resolution regex always captures group 1 (prefix)") - .as_str() - .to_string(); - let inner = caps - .get(2) - .expect("resolution regex always captures group 2 (inner)") - .as_str() - .to_string(); - let original = format!("{{{inner}}}"); - let mut fields: Vec = vec![ - format!("integrity: {sha512}"), - format!("tarball: {}", dep.artifact_url), - ]; - for f in inner.split(',') { - let t = f.trim(); - if !t.is_empty() && !t.starts_with("integrity:") && !t.starts_with("tarball:") { - fields.push(t.to_string()); + // Per-lock rewrites are PLANNED first and committed only after the + // residual gate below proves no instance of this dep escaped the + // splice grammar in ANY lock — committing lock-by-lock as we go + // would ship exactly the partial rewrite the gate exists to refuse. + let mut planned: Vec<(usize, String, Vec)> = Vec::new(); + let mut residuals: Vec<(&str, Vec)> = Vec::new(); + for (idx, (lock_key, content, _)) in contents.iter().enumerate() { + // (byte range to replace, replacement text) per instance, plus + // one FileEdit per instance keyed by the canonical instance key — + // per-instance edits keep the revert ledger lossless when several + // instances of one dep live in the same lock. + let mut splices: Vec<(std::ops::Range, String)> = Vec::new(); + let mut instance_edits: Vec = Vec::new(); + for caps in re.captures_iter(content) { + matched_any = true; + let whole = caps.get(0).expect("group 0 is the whole match"); + let prefix = caps + .get(1) + .expect("resolution regex always captures group 1 (prefix)") + .as_str(); + let key_text = caps + .get(2) + .expect("resolution regex always captures group 2 (the lock key)") + .as_str(); + let inner = caps + .get(3) + .expect("resolution regex always captures group 3 (inner)") + .as_str(); + let original = format!("{{{inner}}}"); + let mut fields: Vec = vec![ + format!("integrity: {sha512}"), + format!("tarball: {}", dep.artifact_url), + ]; + for f in inner.split(',') { + let t = f.trim(); + if !t.is_empty() && !t.starts_with("integrity:") && !t.starts_with("tarball:") { + fields.push(t.to_string()); + } } + let rebuilt = format!("{{{}}}", fields.join(", ")); + // Already redirected (re-run): no edit, no ledger growth. + if rebuilt == original { + continue; + } + // Canonical instance key: quotes and the leading `/` are lock + // spelling, not identity, and v5's `//` is + // respelled `@` — so a plain instance's key + // is `@` in every grammar (the shape the golden + // fixtures pin) and peered instances stay distinct. + let instance_key = if let Some(quoted) = key_text + .strip_prefix('\'') + .and_then(|k| k.strip_suffix('\'')) + { + quoted.to_string() + } else if let Some(slashed) = key_text.strip_prefix('/') { + match slashed.strip_prefix(&format!("{fname}/")) { + Some(rest) => format!("{fname}@{rest}"), + None => slashed.to_string(), + } + } else { + key_text.to_string() + }; + splices.push((whole.range(), format!("{prefix}{rebuilt}"))); + instance_edits.push(FileEdit { + path: (*lock_key).clone(), + kind: "redirect_pnpm_resolution".into(), + action: "rewritten".into(), + key: Some(instance_key), + original: Some(Value::String(original)), + new: Some(Value::String(rebuilt)), + }); } - let rebuilt = format!("{{{}}}", fields.join(", ")); - // Already redirected (re-run): no edit, no ledger growth. - if rebuilt == original { + // Splice by byte range (captures_iter yields non-overlapping + // matches in order) — a string replace could hit the wrong + // instance when two entries share identical surrounding bytes. + let candidate: Option = if splices.is_empty() { + None + } else { + let mut out = String::with_capacity(content.len()); + let mut cursor = 0usize; + for (range, replacement) in splices { + out.push_str(&content[cursor..range.start]); + out.push_str(&replacement); + cursor = range.end; + } + out.push_str(&content[cursor..]); + Some(out) + }; + // Residual gate, run over the POST-splice text: any instance of + // this exact name@version still resolving somewhere other than + // the hosted artifact — in a spelling the splice grammar cannot + // parse (e.g. v6 NESTED peer parens) — makes this a partial + // rewrite. Shipping it would confirm and VEX-attest the dep while + // dependents through the unmatched instance keep installing the + // unpatched upstream tarball, so the dep is refused instead. + let leftover = pnpm_unrewritten_instances( + candidate.as_deref().unwrap_or(content), + &fname, + &dep.version, + &dep.artifact_url, + ); + if !leftover.is_empty() { + residuals.push(((*lock_key).as_str(), leftover)); continue; } - *content = content.replacen(&whole, &format!("{prefix}{rebuilt}"), 1); + if let Some(out) = candidate { + planned.push((idx, out, instance_edits)); + } + } + // ANY residual anywhere refuses the dep across the WHOLE lock set — + // nothing rewritten, nothing recorded, nothing confirmed (the same + // fail-closed contract the pre-splice v5/v6 refusal had): a rewrite + // committed in one lock while another still resolves the dep + // upstream would confirm the dep set-wide. + if !residuals.is_empty() { + for (lock_key, keys) in &residuals { + result.warnings.push(RewriteWarning { + code: "redirect_pnpm_unsupported_lock_key".into(), + detail: format!( + "{fname}@{} still resolves through pnpm lock key(s) whose \ + resolution the redirect grammar cannot repoint: {} in \ + {lock_key}; the dep is left unredirected in EVERY lock \ + (nothing rewritten, nothing confirmed) — regenerate the \ + lock with a current pnpm (lockfileVersion 9) and re-run", + dep.version, + keys.join(", ") + ), + }); + } + continue; + } + for (idx, out, mut instance_edits) in planned { + let (_, content, changed) = &mut contents[idx]; + *content = out; *changed = true; - result.edits.push(FileEdit { - path: (*key).clone(), - kind: "redirect_pnpm_resolution".into(), - action: "rewritten".into(), - key: Some(format!("{fname}@{}", dep.version)), - original: Some(Value::String(original)), - new: Some(Value::String(rebuilt)), - }); + result.edits.append(&mut instance_edits); } // The entry-not-found warning fires only when the dep matched in NO - // pnpm lock across the whole set, not once per lock. + // pnpm lock across the whole set, not once per lock. A VENDORED dep + // is named as such: `socket-patch vendor` removes the registry + // resolution this grammar looks for (v9 respells the packages key + // `@file:.socket/vendor/…`; v5/v6 rekey it to a bare `file:` + // key but keep the `@: file:…` overrides line), so + // the generic not-locked wording would send users on a wild-goose + // `pnpm install` when the real path is a mode switch. Fail-closed + // either way: nothing is rewritten for the dep. if !matched_any { - result.warnings.push(RewriteWarning { - code: "redirect_pnpm_entry_not_found".into(), - detail: format!("no inline resolution for {fname}@{}", dep.version), + let v9_vendored_key = format!("{fname}@file:"); + let override_key = format!("{fname}@{}", dep.version); + let vendored = contents.iter().any(|(_, content, _)| { + content.lines().any(|line| { + let t = line.trim_start(); + let t = t.strip_prefix('\'').unwrap_or(t); + // v9 packages/snapshots key (leading `/` in v6 spelling). + // The vendor backend always writes the RELATIVE + // `file:.socket/vendor/…` spelling here, so anchoring on + // it keeps a user's own `file:` dep of the same name + // from being misreported as vendored. + let key = t.strip_prefix('/').unwrap_or(t); + if key + .strip_prefix(&v9_vendored_key) + .is_some_and(|rest| rest.starts_with(".socket/vendor/")) + { + return true; + } + // overrides / root-dep line: `@: file:…` + // (pnpm <=8 absolutizes the value, so only the + // `.socket/vendor/` tail is stable enough to match). + t.strip_prefix(&override_key) + .map(|rest| rest.strip_prefix('\'').unwrap_or(rest)) + .and_then(|rest| rest.strip_prefix(':')) + .is_some_and(|rest| { + rest.contains("file:") && rest.contains(".socket/vendor/") + }) + }) }); + if vendored { + result.warnings.push(RewriteWarning { + code: "redirect_pnpm_entry_vendored".into(), + detail: format!( + "{fname}@{} has no registry resolution because it is \ + VENDORED (the lock resolves it to a \ + file:.socket/vendor/… tarball); the hosted redirect \ + does not apply — run `socket-patch vendor --revert` to \ + restore the registry resolution, then re-run `scan \ + --mode hosted`", + dep.version + ), + }); + } else { + result.warnings.push(RewriteWarning { + code: "redirect_pnpm_entry_not_found".into(), + detail: format!("no inline resolution for {fname}@{}", dep.version), + }); + } } } for (key, content, changed) in contents { @@ -7951,13 +8172,14 @@ snapshots: /// pnpm lockfileVersion 6 embeds resolved peers in the `packages:` key /// itself, so one name@version can appear as BOTH `/pkg@1.0.0:` and - /// `/pkg@1.0.0(peer@2.0.0):`. Rewriting only the plain entry is silent - /// fail-open: the dep is confirmed and attested while every dependent - /// resolving through the peered entry still installs the unpatched - /// upstream tarball. The whole dep must be refused with a warning naming - /// the unmatched key — nothing rewritten, nothing confirmed. + /// `/pkg@1.0.0(peer@2.0.0):`. Rewriting only the plain entry would be + /// silent fail-open — every dependent resolving through the peered entry + /// would keep installing the unpatched upstream tarball — so EVERY + /// instance is spliced, each under its own per-instance ledger key + /// (lossless revert), and a re-run over the result is a byte-stable + /// no-op with zero new edits. #[test] - fn pnpm_v6_mixed_plain_and_peered_is_refused() { + fn pnpm_v6_mixed_plain_and_peered_rewrites_every_instance() { let lock = "lockfileVersion: '6.0' dependencies: @@ -7976,6 +8198,102 @@ packages: peerDependencies: react: '*' dev: false +"; + let mut files = BTreeMap::new(); + files.insert("pnpm-lock.yaml".to_string(), lock.to_string()); + let url = "http://patch.test/left-pad-1.3.0.tgz"; + let overrides = vec![npm_override("left-pad", "1.3.0", url, "sha512-PATCHED==")]; + let r = rewrite_registry_redirect(&files, &overrides); + let out = r + .files + .get("pnpm-lock.yaml") + .unwrap_or_else(|| panic!("v6 mixed lock must be rewritten: {:?}", r.warnings)); + let spliced = format!("resolution: {{integrity: sha512-PATCHED==, tarball: {url}}}"); + assert!( + out.contains(&format!( + " /left-pad@1.3.0:\n {spliced}\n dev: false\n" + )) && out.contains(&format!( + " /left-pad@1.3.0(react@18.2.0):\n {spliced}\n peerDependencies:" + )), + "BOTH the plain and the peered instance must be spliced: {out}" + ); + assert!( + !out.contains("sha512-UPSTREAM=="), + "no instance may keep the upstream integrity: {out}" + ); + // Per-instance ledger edits, keyed by the canonical instance key. + let keys: Vec<&str> = r.edits.iter().filter_map(|e| e.key.as_deref()).collect(); + assert_eq!( + keys, + vec!["left-pad@1.3.0", "left-pad@1.3.0(react@18.2.0)"], + "one lossless ledger edit per instance: {:?}", + r.edits + ); + assert!( + r.edits.iter().all(|e| { + e.kind == "redirect_pnpm_resolution" + && e.original == Some(Value::String("{integrity: sha512-UPSTREAM==}".into())) + }), + "every edit must preserve its instance's original resolution for revert: {:?}", + r.edits + ); + assert!( + !r.warnings + .iter() + .any(|w| w.code.starts_with("redirect_pnpm_")), + "a fully-rewritten v6 lock emits no pnpm warnings: {:?}", + r.warnings + ); + + // Idempotency: a re-run over the rewritten lock changes nothing. + let mut files2 = BTreeMap::new(); + files2.insert("pnpm-lock.yaml".to_string(), out.clone()); + let r2 = rewrite_registry_redirect(&files2, &overrides); + assert!( + r2.files.is_empty() && r2.edits.is_empty(), + "re-run must be byte-stable with zero new edits: files={:?} edits={:?}", + r2.files.keys(), + r2.edits + ); + assert!( + !r2.warnings + .iter() + .any(|w| w.code == "redirect_pnpm_entry_not_found"), + "an already-redirected instance still counts as matched: {:?}", + r2.warnings + ); + } + + /// pnpm v6 peers-of-peers NEST the parens in the `packages:` key + /// (`/pkg@1.0.0(react@18.2.0(scheduler@0.23.2)):`) — a spelling the + /// splice regex's `\([^)\n]*\)` groups provably cannot match. Splicing + /// AROUND it would be the exact fail-open the old pre-splice refusal + /// guarded: the plain instance rewritten, the dep confirmed and + /// VEX-attested, while every dependent resolving through the nested-peer + /// instance keeps installing the unpatched upstream tarball. The + /// post-splice residual gate must refuse the dep — nothing rewritten, + /// no edits, a `redirect_pnpm_unsupported_lock_key` warning naming the + /// residual key. + #[test] + fn pnpm_v6_nested_paren_peer_key_refuses_the_dep_fail_closed() { + let lock = "lockfileVersion: '6.0' + +dependencies: + left-pad: + specifier: 1.3.0 + version: 1.3.0 + +packages: + + /left-pad@1.3.0: + resolution: {integrity: sha512-UPSTREAM==} + dev: false + + /left-pad@1.3.0(react@18.2.0(scheduler@0.23.2)): + resolution: {integrity: sha512-UPSTREAM==} + peerDependencies: + react: '*' + dev: false "; let mut files = BTreeMap::new(); files.insert("pnpm-lock.yaml".to_string(), lock.to_string()); @@ -7984,7 +8302,7 @@ packages: let r = rewrite_registry_redirect(&files, &overrides); assert!( r.files.is_empty() && r.edits.is_empty(), - "a partially-matchable v6 lock must not be rewritten at all: files={:?} edits={:?}", + "a partial rewrite must not ship: files={:?} edits={:?}", r.files.keys(), r.edits ); @@ -7992,21 +8310,142 @@ packages: .warnings .iter() .find(|w| w.code == "redirect_pnpm_unsupported_lock_key") - .unwrap_or_else(|| panic!("refusal warning expected: {:?}", r.warnings)); + .unwrap_or_else(|| panic!("the residual must be warned about: {:?}", r.warnings)); assert!( - warning.detail.contains("/left-pad@1.3.0(react@18.2.0)"), - "warning must name the unmatched peered key: {}", + warning + .detail + .contains("/left-pad@1.3.0(react@18.2.0(scheduler@0.23.2))"), + "the warning must name the residual key: {}", warning.detail ); assert!( !r.warnings .iter() .any(|w| w.code == "redirect_pnpm_entry_not_found"), - "the refusal replaces entry-not-found, not stacks on it: {:?}", + "the residual refusal must not double-report as not-found: {:?}", r.warnings ); } + /// The residual gate is SET-WIDE: a Rush-style repo whose root v9 lock + /// splices fully while a nested lock resolves the same dep only through + /// a nested-peer key must refuse the dep in EVERY lock. Committing the + /// root rewrite alone would land the artifact URL in the project — the + /// CLI's substring confirmation probe would then confirm and attest the + /// dep while the nested lock's dependents stay on the upstream tarball. + #[test] + fn pnpm_residual_in_one_lock_refuses_the_dep_in_every_lock() { + let v9_root = "lockfileVersion: '9.0' + +importers: + .: + dependencies: + left-pad: + specifier: 1.3.0 + version: 1.3.0 + +packages: + left-pad@1.3.0: + resolution: {integrity: sha512-UPSTREAM==} + +snapshots: + left-pad@1.3.0: {} +"; + let v6_nested = "lockfileVersion: '6.0' + +packages: + + /left-pad@1.3.0(react@18.2.0(scheduler@0.23.2)): + resolution: {integrity: sha512-UPSTREAM==} + dev: false +"; + let mut files = BTreeMap::new(); + files.insert("pnpm-lock.yaml".to_string(), v9_root.to_string()); + files.insert( + "common/config/rush/pnpm-lock.yaml".to_string(), + v6_nested.to_string(), + ); + let url = "http://patch.test/left-pad-1.3.0.tgz"; + let overrides = vec![npm_override("left-pad", "1.3.0", url, "sha512-PATCHED==")]; + let r = rewrite_registry_redirect(&files, &overrides); + assert!( + r.files.is_empty() && r.edits.is_empty(), + "the dep must be refused in every lock, the fully-spliceable root \ + included: files={:?} edits={:?}", + r.files.keys(), + r.edits + ); + let warning = r + .warnings + .iter() + .find(|w| w.code == "redirect_pnpm_unsupported_lock_key") + .unwrap_or_else(|| panic!("the residual must be warned about: {:?}", r.warnings)); + assert!( + warning.detail.contains("common/config/rush/pnpm-lock.yaml"), + "the warning must name the lock holding the residual: {}", + warning.detail + ); + } + + /// Boundary contract of the loose residual probe: it flags exactly the + /// unrewritten registry-resolved instances of THIS name@version — never + /// v9 `snapshots:` keys (no resolution, nothing to repoint), never a + /// longer version sharing the prefix, never an instance already pointing + /// at the hosted artifact — while catching every suffix grammar (v6 + /// nested parens, v5 `_`) and quoted scoped spellings. + #[test] + fn pnpm_residual_probe_respects_version_and_section_boundaries() { + let url = "http://patch.test/left-pad-1.3.0.tgz"; + let content = format!( + "lockfileVersion: '9.0' + +packages: + left-pad@1.3.0: + resolution: {{integrity: sha512-PATCHED==, tarball: {url}}} + left-pad@1.3.01: + resolution: {{integrity: sha512-OTHERVERSION==}} + '@scope/left-pad@1.3.0': + resolution: {{integrity: sha512-OTHERPACKAGE==}} + +snapshots: + left-pad@1.3.0(react@18.2.0): + dependencies: + react: 18.2.0 +" + ); + assert!( + pnpm_unrewritten_instances(&content, "left-pad", "1.3.0", url).is_empty(), + "rewritten instances, other versions/packages, and resolution-less \ + snapshots keys must not count" + ); + let v6 = "lockfileVersion: '6.0' + +packages: + + /left-pad@1.3.0(react@18.2.0(scheduler@0.23.2)): + resolution: {integrity: sha512-UPSTREAM==} + dev: false +"; + assert_eq!( + pnpm_unrewritten_instances(v6, "left-pad", "1.3.0", url), + vec!["/left-pad@1.3.0(react@18.2.0(scheduler@0.23.2))"], + "a nested-paren v6 instance still on the registry is a residual" + ); + let v5 = "lockfileVersion: 5.4 + +packages: + + /left-pad/1.3.0_react@18.2.0: + resolution: {integrity: sha512-UPSTREAM==} + dev: false +"; + assert_eq!( + pnpm_unrewritten_instances(v5, "left-pad", "1.3.0", url), + vec!["/left-pad/1.3.0_react@18.2.0"], + "a v5 `_`-suffixed instance still on the registry is a residual" + ); + } + /// A dist block with no `url` has nothing to redirect: pinning a shasum /// onto it would claim a redirect that cannot happen. #[test] @@ -8027,12 +8466,13 @@ packages: assert_eq!(warning_codes(&r), vec!["redirect_composer_no_dist_url"]); } - /// A v6 dep resolved ONLY through peer-suffixed keys previously degraded - /// to a bare `entry_not_found`; the refusal must instead name the exact - /// key the grammar cannot repoint so the operator knows the lock (not the - /// dep) is the problem. + /// A v6 dep resolved ONLY through a peer-suffixed key (pnpm 8 dedupes a + /// workspace onto the peered instantiation — captured live from corepack + /// pnpm@8.15.9, 2026-08-18) is spliced in place like any other instance, + /// scoped names included, with the peered key preserved verbatim in the + /// ledger edit. #[test] - fn pnpm_v6_pure_peered_key_is_refused_by_name() { + fn pnpm_v6_pure_peered_key_is_rewritten_in_place() { let lock = "lockfileVersion: '6.0' packages: @@ -8043,41 +8483,54 @@ packages: "; let mut files = BTreeMap::new(); files.insert("pnpm-lock.yaml".to_string(), lock.to_string()); + let url = "http://patch.test/socktest-pkg-1.0.0.tgz"; let overrides = vec![npm_override( "@socktest/pkg", "1.0.0", - "http://patch.test/socktest-pkg-1.0.0.tgz", + url, "sha512-PATCHED==", )]; let r = rewrite_registry_redirect(&files, &overrides); - assert!(r.files.is_empty() && r.edits.is_empty()); - let warning = r - .warnings - .iter() - .find(|w| w.code == "redirect_pnpm_unsupported_lock_key") - .unwrap_or_else(|| panic!("refusal warning expected: {:?}", r.warnings)); + let out = r + .files + .get("pnpm-lock.yaml") + .unwrap_or_else(|| panic!("pure-peered v6 key must be rewritten: {:?}", r.warnings)); assert!( - warning - .detail - .contains("/@socktest/pkg@1.0.0(react@18.2.0)"), - "warning must name the unmatched key: {}", - warning.detail + out.contains(&format!( + " /@socktest/pkg@1.0.0(react@18.2.0):\n resolution: \ + {{integrity: sha512-PATCHED==, tarball: {url}}}\n dev: false\n" + )), + "the peered entry must be spliced with its key untouched: {out}" + ); + assert_eq!( + r.edits.len(), + 1, + "exactly one instance, one edit: {:?}", + r.edits + ); + assert_eq!( + r.edits[0].key.as_deref(), + Some("@socktest/pkg@1.0.0(react@18.2.0)"), + "the ledger edit is keyed by the canonical peered instance key: {:?}", + r.edits[0] ); assert!( !r.warnings .iter() - .any(|w| w.code == "redirect_pnpm_entry_not_found"), + .any(|w| w.code.starts_with("redirect_pnpm_")), "{:?}", r.warnings ); } /// pnpm lockfileVersion 5.x keys are path-style (`/name/version:`, peers - /// suffixed `_peer@ver`) — the rewrite grammar never matches them, so the - /// dep must be refused with the keys named rather than silently reported - /// as a missing entry. + /// suffixed `_peer@ver`): BOTH instances are spliced (rewriting only one + /// would leave the other installing upstream) and each edit is keyed by + /// the canonical `name@version` respelling — plain instances thus + /// share the `name@version` key shape with every other lock grammar. + /// Idempotency: a re-run over the result is a no-op. #[test] - fn pnpm_v5_path_style_keys_are_refused_by_name() { + fn pnpm_v5_path_style_keys_rewrite_every_instance() { let lock = "lockfileVersion: 5.4 specifiers: @@ -8098,40 +8551,57 @@ packages: "; let mut files = BTreeMap::new(); files.insert("pnpm-lock.yaml".to_string(), lock.to_string()); - let overrides = vec![npm_override( - "left-pad", - "1.3.0", - "http://patch.test/left-pad-1.3.0.tgz", - "sha512-PATCHED==", - )]; + let url = "http://patch.test/left-pad-1.3.0.tgz"; + let overrides = vec![npm_override("left-pad", "1.3.0", url, "sha512-PATCHED==")]; let r = rewrite_registry_redirect(&files, &overrides); - assert!(r.files.is_empty() && r.edits.is_empty()); - let warning = r - .warnings - .iter() - .find(|w| w.code == "redirect_pnpm_unsupported_lock_key") - .unwrap_or_else(|| panic!("refusal warning expected: {:?}", r.warnings)); + let out = r + .files + .get("pnpm-lock.yaml") + .unwrap_or_else(|| panic!("v5 lock must be rewritten: {:?}", r.warnings)); + let spliced = format!("resolution: {{integrity: sha512-PATCHED==, tarball: {url}}}"); assert!( - warning.detail.contains("/left-pad/1.3.0") - && warning.detail.contains("/left-pad/1.3.0_react@18.2.0"), - "warning must name both v5 keys: {}", - warning.detail + out.contains(&format!(" /left-pad/1.3.0:\n {spliced}\n")) + && out.contains(&format!(" /left-pad/1.3.0_react@18.2.0:\n {spliced}\n")), + "BOTH v5 instances must be spliced with their path-style keys untouched: {out}" + ); + assert!( + !out.contains("sha512-UPSTREAM=="), + "no instance may keep the upstream integrity: {out}" + ); + let keys: Vec<&str> = r.edits.iter().filter_map(|e| e.key.as_deref()).collect(); + assert_eq!( + keys, + vec!["left-pad@1.3.0", "left-pad@1.3.0_react@18.2.0"], + "per-instance ledger keys use the canonical respelling: {:?}", + r.edits ); assert!( !r.warnings .iter() - .any(|w| w.code == "redirect_pnpm_entry_not_found"), + .any(|w| w.code.starts_with("redirect_pnpm_")), "{:?}", r.warnings ); + + // Idempotency over the rewritten bytes. + let mut files2 = BTreeMap::new(); + files2.insert("pnpm-lock.yaml".to_string(), out.clone()); + let r2 = rewrite_registry_redirect(&files2, &overrides); + assert!( + r2.files.is_empty() && r2.edits.is_empty(), + "re-run must be a no-op: files={:?} edits={:?}", + r2.files.keys(), + r2.edits + ); } /// When a dep lives in a rewritable v9 lock AND a legacy lock in the same - /// set (e.g. a Rush nested lock still on pnpm 7), rewriting just the v9 - /// lock would confirm the dep while the legacy lock keeps installing - /// upstream. The refusal must cover the WHOLE set: no lock rewritten. + /// set (e.g. a Rush nested lock still on pnpm 7), BOTH locks are + /// rewritten — rewriting just the v9 lock would confirm the dep while the + /// legacy lock kept installing upstream. Each lock's edit rides its own + /// `path` so the ledger stays lossless per file. #[test] - fn pnpm_legacy_lock_in_set_refuses_the_dep_everywhere() { + fn pnpm_legacy_lock_in_set_is_rewritten_alongside_v9() { let v9_lock = "lockfileVersion: '9.0' packages: @@ -8152,29 +8622,220 @@ packages: "common/config/rush/pnpm-lock.yaml".to_string(), v5_lock.to_string(), ); + let url = "http://patch.test/left-pad-1.3.0.tgz"; + let overrides = vec![npm_override("left-pad", "1.3.0", url, "sha512-PATCHED==")]; + let r = rewrite_registry_redirect(&files, &overrides); + let spliced = format!("resolution: {{integrity: sha512-PATCHED==, tarball: {url}}}"); + let v9_out = r + .files + .get("pnpm-lock.yaml") + .unwrap_or_else(|| panic!("the v9 lock must be rewritten: {:?}", r.warnings)); + assert!( + v9_out.contains(&format!(" left-pad@1.3.0:\n {spliced}\n")), + "{v9_out}" + ); + let v5_out = r + .files + .get("common/config/rush/pnpm-lock.yaml") + .unwrap_or_else(|| panic!("the nested v5 lock must be rewritten: {:?}", r.warnings)); + assert!( + v5_out.contains(&format!(" /left-pad/1.3.0:\n {spliced}\n")), + "{v5_out}" + ); + // One edit per lock, both under the canonical plain-instance key, each + // carrying its own path for a lossless per-file revert. + let mut paths: Vec<&str> = r + .edits + .iter() + .filter(|e| e.key.as_deref() == Some("left-pad@1.3.0")) + .map(|e| e.path.as_str()) + .collect(); + paths.sort_unstable(); + assert_eq!( + paths, + vec!["common/config/rush/pnpm-lock.yaml", "pnpm-lock.yaml"], + "both locks' edits must be recorded: {:?}", + r.edits + ); + assert!( + !r.warnings + .iter() + .any(|w| w.code.starts_with("redirect_pnpm_")), + "{:?}", + r.warnings + ); + } + + /// Byte-accurate pnpm 7 (lockfileVersion 5.4) grammar, captured live from + /// `corepack pnpm@7.33.5 install` of a workspace where pkg-a consumes + /// use-sync-external-store@1.2.0 bare and pkg-b consumes it beside + /// react@18.2.0 (2026-08-18): the plain and `_react@18.2.0`-suffixed + /// instances each carry their own resolution and BOTH get spliced, with + /// every sibling line (`peerDependencies:`, `dependencies:`, `dev:`) + /// byte-preserved. The spliced shape is exactly what pnpm@7.33.5 then + /// frozen-installed from an empty store in the capture session. + #[test] + fn pnpm_v5_real_captured_peered_grammar_rewrites_both_instances() { + let lock = "lockfileVersion: 5.4 + +importers: + + .: + specifiers: {} + + pkg-a: + specifiers: + use-sync-external-store: 1.2.0 + dependencies: + use-sync-external-store: 1.2.0 + + pkg-b: + specifiers: + react: 18.2.0 + use-sync-external-store: 1.2.0 + dependencies: + react: 18.2.0 + use-sync-external-store: 1.2.0_react@18.2.0 + +packages: + + /react/18.2.0: + resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} + engines: {node: '>=0.10.0'} + dependencies: + loose-envify: 1.4.0 + dev: false + + /use-sync-external-store/1.2.0: + resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dev: false + + /use-sync-external-store/1.2.0_react@18.2.0: + resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + dev: false +"; + let mut files = BTreeMap::new(); + files.insert("pnpm-lock.yaml".to_string(), lock.to_string()); + let url = "http://patch.test/use-sync-external-store-1.2.0.tgz"; let overrides = vec![npm_override( - "left-pad", - "1.3.0", - "http://patch.test/left-pad-1.3.0.tgz", + "use-sync-external-store", + "1.2.0", + url, "sha512-PATCHED==", )]; let r = rewrite_registry_redirect(&files, &overrides); + let out = r + .files + .get("pnpm-lock.yaml") + .unwrap_or_else(|| panic!("captured v5 lock must be rewritten: {:?}", r.warnings)); + let spliced = format!("resolution: {{integrity: sha512-PATCHED==, tarball: {url}}}"); assert!( - r.files.is_empty() && r.edits.is_empty(), - "no lock in the set may be rewritten while a legacy key survives: files={:?}", - r.files.keys() + out.contains(&format!( + " /use-sync-external-store/1.2.0:\n {spliced}\n peerDependencies:\n \ + react: ^16.8.0 || ^17.0.0 || ^18.0.0\n dev: false\n" + )), + "plain instance spliced, siblings byte-preserved: {out}" ); - let warning = r - .warnings - .iter() - .find(|w| w.code == "redirect_pnpm_unsupported_lock_key") - .unwrap_or_else(|| panic!("refusal warning expected: {:?}", r.warnings)); assert!( - warning - .detail - .contains("/left-pad/1.3.0 in common/config/rush/pnpm-lock.yaml"), - "warning must name the key AND the lock it lives in: {}", - warning.detail + out.contains(&format!( + " /use-sync-external-store/1.2.0_react@18.2.0:\n {spliced}\n \ + peerDependencies:\n react: ^16.8.0 || ^17.0.0 || ^18.0.0\n \ + dependencies:\n react: 18.2.0\n dev: false\n" + )), + "peered instance spliced, siblings byte-preserved: {out}" + ); + // react's entry (whose resolution the lazy scan must not leak into) + // stays byte-untouched. + assert!( + out.contains(" /react/18.2.0:\n resolution: {integrity: sha512-/3IjMdb2L9"), + "unrelated entries stay untouched: {out}" + ); + let keys: Vec<&str> = r.edits.iter().filter_map(|e| e.key.as_deref()).collect(); + assert_eq!( + keys, + vec![ + "use-sync-external-store@1.2.0", + "use-sync-external-store@1.2.0_react@18.2.0" + ], + "{:?}", + r.edits + ); + } + + /// Byte-accurate pnpm 8 (lockfileVersion '6.0') grammar from the same + /// live capture (`corepack pnpm@8.15.9`, 2026-08-18): pnpm 8 deduped both + /// importers onto the single peer-suffixed instance + /// `/use-sync-external-store@1.2.0(react@18.2.0):` — the real-world v6 + /// peered shape — and the spliced lock frozen-installed from an empty + /// store in the capture session. + #[test] + fn pnpm_v6_real_captured_peered_grammar_rewrites_in_place() { + let lock = "lockfileVersion: '6.0' + +settings: + autoInstallPeers: false + excludeLinksFromLockfile: false + +importers: + + .: {} + + pkg-a: + dependencies: + use-sync-external-store: + specifier: 1.2.0 + version: 1.2.0(react@18.2.0) + +packages: + + /use-sync-external-store@1.2.0(react@18.2.0): + resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + dev: false +"; + let mut files = BTreeMap::new(); + files.insert("pnpm-lock.yaml".to_string(), lock.to_string()); + let url = "http://patch.test/use-sync-external-store-1.2.0.tgz"; + let overrides = vec![npm_override( + "use-sync-external-store", + "1.2.0", + url, + "sha512-PATCHED==", + )]; + let r = rewrite_registry_redirect(&files, &overrides); + let out = r + .files + .get("pnpm-lock.yaml") + .unwrap_or_else(|| panic!("captured v6 lock must be rewritten: {:?}", r.warnings)); + assert!( + out.contains(&format!( + " /use-sync-external-store@1.2.0(react@18.2.0):\n resolution: \ + {{integrity: sha512-PATCHED==, tarball: {url}}}\n peerDependencies:" + )), + "the peered v6 instance must be spliced in place: {out}" + ); + assert_eq!(r.edits.len(), 1, "{:?}", r.edits); + assert_eq!( + r.edits[0].key.as_deref(), + Some("use-sync-external-store@1.2.0(react@18.2.0)"), + "{:?}", + r.edits[0] + ); + assert!( + !r.warnings + .iter() + .any(|w| w.code.starts_with("redirect_pnpm_")), + "{:?}", + r.warnings ); } @@ -8365,6 +9026,196 @@ snapshots: ); } + /// pnpm 1/2 projects lock with `shrinkwrap.yaml` (the pre-rename v5 + /// grammar — real layout captured in the 2026-08-18 legacy matrix: + /// shrinkwrap.yaml + node_modules/.modules.yaml, no pnpm-lock.yaml and + /// no package-lock.json). The no-lockfile diagnostic must be + /// pnpm-flavored there — the npm "no package-lock.json" wording + /// dead-ends (running `npm i --package-lock-only` would fork the + /// project onto npm). Marker-aware family selection, fail-closed: + /// nothing is rewritten either way. + #[test] + fn no_lockfile_warning_is_pnpm_flavored_when_pnpm_markers_present() { + let ovr = npm_override( + "left-pad", + "1.3.0", + "http://patch.test/left-pad-1.3.0.tgz", + "sha512-PATCHED==", + ); + + // shrinkwrap.yaml present (with or without node_modules — a fresh + // clone has only the committed lock): name the legacy lock. + for with_marker in [true, false] { + let mut files = BTreeMap::new(); + files.insert( + "shrinkwrap.yaml".to_string(), + "dependencies:\n left-pad: 1.3.0\npackages:\n /left-pad/1.3.0:\n \ + dev: false\n resolution:\n integrity: sha512-UPSTREAM==\n\ + shrinkwrapVersion: 3\n" + .to_string(), + ); + if with_marker { + files.insert( + "node_modules/.modules.yaml".to_string(), + "packageManager: pnpm@2.17.0\n".to_string(), + ); + } + let r = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert!( + r.files.is_empty(), + "nothing may be rewritten (markers are read-only): {:?}", + r.files.keys() + ); + assert_eq!( + warning_codes(&r), + vec!["redirect_pnpm_legacy_lockfile"], + "shrinkwrap.yaml (with_marker={with_marker}) must select the \ + pnpm-legacy wording, never redirect_npm_no_lockfile: {:?}", + r.warnings + ); + assert!( + r.warnings[0].detail.contains("shrinkwrap.yaml") + && r.warnings[0].detail.contains("pnpm-lock.yaml"), + "detail must name the legacy lock and the upgrade target: {}", + r.warnings[0].detail + ); + } + + // pnpm marker only (lock deleted / never committed): pnpm-flavored + // "no lockfile", pointing at pnpm install — not npm. + let mut files = BTreeMap::new(); + files.insert( + "node_modules/.modules.yaml".to_string(), + "packageManager: pnpm@10.0.0\n".to_string(), + ); + let r = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert_eq!( + warning_codes(&r), + vec!["redirect_pnpm_no_lockfile"], + "a pnpm layout without any lock must warn pnpm-flavored: {:?}", + r.warnings + ); + assert!( + r.warnings[0].detail.contains("pnpm install"), + "detail must point at pnpm, not npm: {}", + r.warnings[0].detail + ); + } + + /// A VENDORED pnpm dep has no registry resolution by design — the lock + /// key is `@file:.socket/vendor/…` (v9) and the generic + /// entry-not-found wording invites a wild-goose `pnpm install`. The + /// warning must name the vendored state and the `vendor --revert` path + /// instead, while a genuinely unlocked dep keeps the old code, and a + /// same-name USER `file:` dep (not under .socket/vendor/) is never + /// misreported as vendored. Fail-closed in all three: zero rewrites. + #[test] + fn pnpm_vendored_entry_is_named_vendored_not_entry_not_found() { + let ovr = npm_override( + "left-pad", + "1.3.0", + "http://patch.test/left-pad-1.3.0.tgz", + "sha512-PATCHED==", + ); + + // Byte-real v9 vendored lock shape (2026-08-18 mode-conversion + // matrix, projB snap): overrides + file:-keyed packages/snapshots. + let vendored_lock = "lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + left-pad@1.3.0: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + +importers: + + .: + dependencies: + left-pad: + specifier: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + version: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + +packages: + + left-pad@file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz: + resolution: {integrity: sha512-VENDORED==, tarball: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz} + version: 1.3.0 + +snapshots: + + left-pad@file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz: {} +"; + let mut files = BTreeMap::new(); + files.insert("pnpm-lock.yaml".to_string(), vendored_lock.to_string()); + let r = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert!( + r.files.is_empty() && r.edits.is_empty(), + "a vendored lock must not be rewritten (fail-closed unchanged): {:?}", + r.files.keys() + ); + assert_eq!( + warning_codes(&r), + vec!["redirect_pnpm_entry_vendored"], + "the vendored dep must be named vendored: {:?}", + r.warnings + ); + assert!( + r.warnings[0].detail.contains("vendor --revert") + && r.warnings[0].detail.contains("left-pad@1.3.0"), + "detail must name the dep and the mode-switch path: {}", + r.warnings[0].detail + ); + + // Legacy vendored spelling (pnpm 7/8): packages rekeyed to a BARE + // `file:` key; the `@: file:…` overrides line (pnpm + // <=8 absolutizes the value) is what still carries name+version. + let legacy_vendored = "lockfileVersion: '6.0' + +overrides: + left-pad@1.3.0: file:/abs/project/.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + +packages: + + file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz: + resolution: {integrity: sha512-VENDORED==, tarball: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz} + name: left-pad + version: 1.3.0 + dev: false +"; + let mut files = BTreeMap::new(); + files.insert("pnpm-lock.yaml".to_string(), legacy_vendored.to_string()); + let r = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert_eq!( + warning_codes(&r), + vec!["redirect_pnpm_entry_vendored"], + "the legacy vendored spelling must also be recognized: {:?}", + r.warnings + ); + + // A user's own file: dep of the same name (NOT under .socket/vendor/) + // stays the generic entry-not-found — telling them to run `vendor + // --revert` would be wrong. + let user_file_lock = "lockfileVersion: '9.0' + +packages: + + left-pad@file:vendor/local/left-pad-1.3.0.tgz: + resolution: {integrity: sha512-LOCAL==, tarball: file:vendor/local/left-pad-1.3.0.tgz} + version: 1.3.0 +"; + let mut files = BTreeMap::new(); + files.insert("pnpm-lock.yaml".to_string(), user_file_lock.to_string()); + let r = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert_eq!( + warning_codes(&r), + vec!["redirect_pnpm_entry_not_found"], + "a non-socket file: dep keeps the generic warning: {:?}", + r.warnings + ); + } + /// A CRLF berry lock must be diagnosed as a line-ending problem, not as /// `cacheKey is \`(missing)\`` — the lock's cacheKey IS 10c0; only the /// `\n\n` block grammar fails on `\r\n\r\n`. Fail-closed either way. diff --git a/crates/socket-patch-core/src/patch/redirect/state.rs b/crates/socket-patch-core/src/patch/redirect/state.rs index e019d1ca..054903c4 100644 --- a/crates/socket-patch-core/src/patch/redirect/state.rs +++ b/crates/socket-patch-core/src/patch/redirect/state.rs @@ -30,6 +30,13 @@ pub struct RedirectState { /// (the final mode name); the loader is tolerant of any string, so /// ledgers written before the rename (`"redirect"`) still load. pub mode: String, + /// Recorded [`FileEdit`]s, appended in write order (a revert walks them + /// in reverse). `kind` is an open vocabulary — additive kinds (e.g. the + /// hosted pnpm flow's `redirect_pnpm_workspace_trust`, recording the + /// auto-configured pnpm-workspace.yaml `trustLockfile: true` with + /// `action` `"created"` for a new file or `"added"` for a spliced-in + /// line) must round-trip through ledgers written before they existed, + /// so no field here may ever tighten into an enum. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub edits: Vec, /// PURL -> manifest patch record. Present so VEX can attest redirected @@ -171,6 +178,127 @@ pub async fn save_redirect_state( atomic_write_bytes(&path, format!("{json}\n").as_bytes()).await } +/// `pkg:/@` → `(, )`; the name keeps any +/// namespace slashes (`@scope/pkg`). `None` when either part is missing. +/// Input must already be canonicalized (qualifiers stripped, percent-decoded). +fn purl_name_version(purl: &str) -> Option<(&str, &str)> { + let rest = purl.strip_prefix("pkg:")?; + let (_, coord) = rest.split_once('/')?; + let at = coord.rfind('@').filter(|&i| i > 0)?; + Some((&coord[..at], &coord[at + 1..])) +} + +/// Drop one PURL's superseded takeover leftovers from the ledger: its +/// `records` entry (canonical-purl match, qualifiers stripped and +/// percent-decoded) and every recorded edit keyed to that package. This is +/// the npm-family half of the hosted→vendored takeover reconciliation: the +/// vendored flows call it ONLY after the LIVE lockfile provably wires the +/// package to the committed `.socket/vendor/` artifact and no longer +/// resolves the hosted URL — at that point the vendor ledger's wiring +/// `original` embeds the hosted-spliced lock fragment, so `vendor --revert` +/// stays lossless without these ledger edits, and keeping them would feed +/// VEX/updates stale records and re-fire the takeover warning on every +/// later run. CARGO purls are refused (returns `false`, drops nothing): a +/// cargo takeover must revert the hosted edits ON DISK first — that path is +/// [`revert_cargo_redirect_purl`](super::revert_cargo_redirect_purl), which +/// does its own ledger drop. +/// +/// The edit matcher is ARTIFACT-ANCHORED, never name-anchored. An edit is +/// claimed when either: +/// +/// * its `new` content references THIS purl's hosted artifact — every hosted +/// artifact URL embeds the patch uuid (on ANY patch-server host; the same +/// invariant the takeover classifier's `hosted_wiring_live` proof rests +/// on), and a uuid is hex-and-dashes so it spells identically raw, +/// `\/`-escaped (old composer) and percent-encoded (yarn-berry +/// `::__archiveUrl=`). The uuid(s) come from this purl's own `records` +/// entry, captured before it is removed. This is what claims the +/// version-blind key shapes: npm `node_modules/…` path keys, legacy +/// `dependencies` bare-name keys, bun `/` keys. +/// * (secondary guard, for when the record — and with it the artifact URL — +/// is unavailable) its key is a VERSION-EXACT instance key: +/// `"name@version"`, pnpm v6 peer-suffixed `"name@version(peer…)"`, or the +/// pnpm-v5 respelling `"name@version_peer…"`. +/// +/// The old matcher claimed by NAME alone (`key == name`, key ends with +/// `"/name"`): with two versions of one package hosted, vendoring one +/// deleted BOTH versions' path-keyed edits, destroying the other version's +/// revert originals. Name-only matching is gone; version-blind keys with no +/// artifact anchor are KEPT (fail-closed — they may be the other version's +/// only revert data). Consequence for the CLI's takeover-overlap fallback +/// matcher (which still matches edit keys by bare name, but ONLY when +/// `records` is empty — the degraded record-fetch-failed ledger): a normal +/// record-carrying ledger reconciles fully here (the record removal alone +/// ends the overlap), while a degraded ledger's unattributable path-keyed +/// edits stay and its takeover warning keeps advising the manual per-package +/// cleanup — the correct outcome when the ledger lacks the records needed to +/// attribute edits to a version safely. +/// +/// Edits that are not package-keyed (e.g. the pnpm workspace-trust edit, +/// keyed `"trustLockfile"`) stay: they belong to the hosted flow's own +/// config surface and other still-redirected package(s) may ride on them. +/// +/// Returns whether anything was removed. The caller persists the mutated +/// ledger via [`persist_redirect_state`] (atomic; an emptied ledger is +/// deleted). +pub fn drop_superseded_purl(state: &mut RedirectState, purl: &str) -> bool { + use crate::utils::purl::{normalize_purl, strip_purl_qualifiers}; + let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); + let target = canon(purl); + if target.starts_with("pkg:cargo/") { + return false; + } + let Some((name, version)) = purl_name_version(&target) else { + return false; + }; + let (name, version) = (name.to_string(), version.to_string()); + + let record_keys: Vec = state + .records + .keys() + .filter(|k| canon(k) == target) + .cloned() + .collect(); + // THIS purl's patch uuid(s), captured before the records are removed — + // the artifact anchor (see the doc comment). Distinct purls (including + // two versions of one package) carry distinct patch uuids, so a uuid + // match is version-exact by construction. + let uuids: Vec = record_keys + .iter() + .filter_map(|k| state.records.get(k)) + .map(|r| r.uuid.clone()) + .collect(); + for key in &record_keys { + state.records.remove(key); + } + + let name_at_version = format!("{name}@{version}"); + let edits_before = state.edits.len(); + state.edits.retain(|e| { + let Some(key) = e.key.as_deref() else { + // No key ⇒ not attributable to any package; keep. + return true; + }; + // Version-exact instance keys: `name@version`, pnpm v6 peer-suffixed + // `name@version(peer…)`, pnpm v5 respelled `name@version_peer…`. + let version_exact = key == name_at_version + || key + .strip_prefix(name_at_version.as_str()) + .is_some_and(|rest| rest.starts_with('(') || rest.starts_with('_')); + // Artifact anchor: the edit's rewritten (`new`) content references + // this purl's hosted artifact (its patch uuid — spelling-invariant + // across raw / `\/`-escaped / percent-encoded URL forms). + let anchored = !uuids.is_empty() + && e.new.as_ref().is_some_and(|new| { + let text = new.to_string(); + uuids.iter().any(|uuid| text.contains(uuid.as_str())) + }); + !(version_exact || anchored) + }); + + !record_keys.is_empty() || state.edits.len() != edits_before +} + /// Persist the redirect ledger via [`save_redirect_state`]'s atomic writer. /// An EMPTY ledger (no edits, no records) is DELETED instead: a residual /// empty file would keep takeover-overlap detection and VEX reading a ledger @@ -197,7 +325,15 @@ mod tests { use crate::manifest::schema::{PatchFileInfo, PatchRecord, VulnerabilityInfo}; use std::collections::HashMap; + /// The sample record's patch uuid — hosted artifact URLs embed it (the + /// artifact anchor `drop_superseded_purl` claims edits by). + const SAMPLE_UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + fn sample_record() -> PatchRecord { + record_with_uuid(SAMPLE_UUID) + } + + fn record_with_uuid(uuid: &str) -> PatchRecord { let mut files = HashMap::new(); files.insert( "package/index.js".to_string(), @@ -217,7 +353,7 @@ mod tests { }, ); PatchRecord { - uuid: "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f".to_string(), + uuid: uuid.to_string(), exported_at: "2024-01-01T00:00:00Z".to_string(), files, vulnerabilities: vulns, @@ -227,6 +363,12 @@ mod tests { } } + /// The hosted artifact URL shape the patch server serves: the patch uuid + /// is a path segment, exactly the anchor `drop_superseded_purl` matches. + fn hosted_url(name: &str, version: &str, uuid: &str) -> String { + format!("https://patch.test/patch/npm/{name}/{version}/{uuid}/{name}-{version}.tgz") + } + #[test] fn round_trips_records_through_json() { let mut state = RedirectState::new(); @@ -242,6 +384,408 @@ mod tests { assert!(rec.vulnerabilities.contains_key("GHSA-xxxx-yyyy-zzzz")); } + /// The hosted pnpm flow's `redirect_pnpm_workspace_trust` edit (the + /// auto-configured pnpm-workspace.yaml `trustLockfile: true`) is plain + /// `FileEdit` vocabulary: it must round-trip byte-losslessly (camelCase + /// contract keys, revert-relevant fields intact) alongside the classic + /// lock edits — and, being additive, its ABSENCE must change nothing + /// (the legacy-ledger tests below stay green without it). + #[test] + fn workspace_trust_edit_round_trips_as_plain_file_edit_vocabulary() { + let mut state = RedirectState::new(); + state.edits.push(FileEdit { + path: "pnpm-lock.yaml".to_string(), + kind: "redirect_pnpm_resolution".to_string(), + action: "rewritten".to_string(), + key: Some("left-pad@1.3.0".to_string()), + original: Some(serde_json::json!("{integrity: sha512-UPSTREAM==}")), + new: Some(serde_json::json!( + "{integrity: sha512-PATCHED==, tarball: http://patch.test/x.tgz}" + )), + }); + state.edits.push(FileEdit { + path: "pnpm-workspace.yaml".to_string(), + kind: "redirect_pnpm_workspace_trust".to_string(), + action: "created".to_string(), + key: Some("trustLockfile".to_string()), + original: None, + new: Some(serde_json::json!("true")), + }); + let json = serde_json::to_string_pretty(&state).unwrap(); + let back: RedirectState = serde_json::from_str(&json).unwrap(); + assert_eq!(back.edits, state.edits, "edits must round-trip losslessly"); + // Edit order is the revert contract (walked in reverse): the trust + // edit stays AFTER the lock edit it accompanies. + assert_eq!(back.edits[1].kind, "redirect_pnpm_workspace_trust"); + assert_eq!(back.edits[1].action, "created"); + assert_eq!(back.edits[1].key.as_deref(), Some("trustLockfile")); + assert!( + back.edits[1].original.is_none(), + "a created file records no original" + ); + } + + /// A ledger written by a FUTURE (or concurrent) writer carrying an edit + /// kind/action this build has never heard of must still load — kind and + /// action are opaque strings, exactly like `mode`. Guards against + /// tightening the edit vocabulary into an enum, which would brick every + /// existing ledger the moment a new kind ships. + #[tokio::test] + async fn load_tolerates_unknown_edit_kinds_and_actions() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join(".socket/vendor"); + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write( + dir.join("redirect-state.json"), + br#"{ + "version": 1, + "mode": "hosted", + "edits": [ + { + "path": "pnpm-workspace.yaml", + "kind": "redirect_pnpm_workspace_trust", + "action": "added", + "key": "trustLockfile", + "new": "true" + }, + { + "path": "some-future-file", + "kind": "redirect_kind_from_the_future", + "action": "transmogrified" + } + ] +}"#, + ) + .await + .unwrap(); + let loaded = load_redirect_state(tmp.path()).await.unwrap().unwrap(); + assert_eq!(loaded.edits.len(), 2); + assert_eq!(loaded.edits[0].kind, "redirect_pnpm_workspace_trust"); + assert_eq!(loaded.edits[0].action, "added"); + assert_eq!(loaded.edits[0].original, None); + assert_eq!(loaded.edits[1].kind, "redirect_kind_from_the_future"); + } + + fn edit(path: &str, kind: &str, key: Option<&str>) -> FileEdit { + FileEdit { + path: path.to_string(), + kind: kind.to_string(), + action: "rewritten".to_string(), + key: key.map(str::to_string), + original: Some(serde_json::json!("orig")), + new: Some(serde_json::json!("new")), + } + } + + /// An edit whose rewritten content points at a hosted artifact URL — the + /// shape the npm rewriter records (`new` = the spliced resolved/integrity + /// pair), carrying the artifact anchor. + fn edit_resolved(path: &str, kind: &str, key: &str, url: &str) -> FileEdit { + FileEdit { + path: path.to_string(), + kind: kind.to_string(), + action: "rewritten".to_string(), + key: Some(key.to_string()), + original: Some(serde_json::json!({ + "resolved": "https://registry.npmjs.org/upstream.tgz", + "integrity": "sha512-UPSTREAM==" + })), + new: Some(serde_json::json!({ "resolved": url, "integrity": "sha512-P==" })), + } + } + + /// The takeover reconciliation drops exactly the superseded package's + /// halves — its `records` entry and every edit keyed to it (pnpm + /// `name@version`, pnpm v6 peer-suffixed and v5 `_`-suffixed instances, + /// npm `node_modules/…` paths whose rewritten content carries this purl's + /// hosted artifact) — while other packages' data and non-package-keyed + /// edits (the pnpm workspace-trust edit) survive verbatim. + #[test] + fn drop_superseded_purl_removes_both_halves_and_only_them() { + let mut state = RedirectState::new(); + state + .records + .insert("pkg:npm/left-pad@1.3.0".to_string(), sample_record()); + state + .records + .insert("pkg:npm/minimist@1.2.2".to_string(), sample_record()); + state.edits = vec![ + edit( + "pnpm-lock.yaml", + "redirect_pnpm_resolution", + Some("left-pad@1.3.0"), + ), + // pnpm v6 peer-suffixed instance key for the SAME package. + edit( + "pnpm-lock.yaml", + "redirect_pnpm_resolution", + Some("left-pad@1.3.0(react@18.2.0)"), + ), + // pnpm v5 `_`-suffixed instance key (the rewriter's own respelled + // `/left-pad/1.3.0_react@18.2.0` key) for the same package. + edit( + "pnpm-lock.yaml", + "redirect_pnpm_resolution", + Some("left-pad@1.3.0_react@18.2.0"), + ), + // npm nested node_modules path for the same package: the key is + // version-blind, so the claim rides the artifact anchor in `new`. + edit_resolved( + "package-lock.json", + "redirect_npm_lock_entry", + "node_modules/a/node_modules/left-pad", + &hosted_url("left-pad", "1.3.0", SAMPLE_UUID), + ), + // Another package's edit — must survive. + edit( + "pnpm-lock.yaml", + "redirect_pnpm_resolution", + Some("minimist@1.2.2"), + ), + // Non-package-keyed workspace-trust edit — must survive. + edit( + "pnpm-workspace.yaml", + "redirect_pnpm_workspace_trust", + Some("trustLockfile"), + ), + ]; + + assert!(drop_superseded_purl(&mut state, "pkg:npm/left-pad@1.3.0")); + + assert!( + !state.records.contains_key("pkg:npm/left-pad@1.3.0"), + "the superseded record must be dropped" + ); + assert!( + state.records.contains_key("pkg:npm/minimist@1.2.2"), + "other packages' records must survive" + ); + let keys: Vec<&str> = state + .edits + .iter() + .filter_map(|e| e.key.as_deref()) + .collect(); + assert_eq!( + keys, + vec!["minimist@1.2.2", "trustLockfile"], + "only the superseded package's edits may be dropped: {keys:?}" + ); + + // Idempotent: a second drop finds nothing and reports it. + assert!(!drop_superseded_purl(&mut state, "pkg:npm/left-pad@1.3.0")); + } + + /// TWO versions of one package hosted at once: dropping the vendored one + /// must not touch the other version's halves. The npm path keys + /// (`node_modules/…/left-pad`) and legacy `dependencies` bare-name keys + /// carry NO version, so the old name-anchored matcher claimed BOTH + /// versions' edits here — destroying left-pad@2.0.0's pre-redirect + /// originals (its only revert data) when left-pad@1.3.0 was vendored. + /// The matcher is artifact-anchored now: only edits whose rewritten + /// content references the dropped purl's own hosted artifact go. + #[test] + fn drop_superseded_purl_never_claims_the_other_hosted_versions_edits() { + const UUID_V2: &str = "1a2b3c4d-5e6f-4a1b-8c2d-0f9e8d7c6b5a"; + let url_v1 = hosted_url("left-pad", "1.3.0", SAMPLE_UUID); + let url_v2 = hosted_url("left-pad", "2.0.0", UUID_V2); + let mut state = RedirectState::new(); + state + .records + .insert("pkg:npm/left-pad@1.3.0".to_string(), sample_record()); + state.records.insert( + "pkg:npm/left-pad@2.0.0".to_string(), + record_with_uuid(UUID_V2), + ); + state.edits = vec![ + // v1's edits: a version-blind path key (anchored via `new`) and + // a version-exact pnpm key. + edit_resolved( + "package-lock.json", + "redirect_npm_lock_entry", + "node_modules/left-pad", + &url_v1, + ), + edit( + "pnpm-lock.yaml", + "redirect_pnpm_resolution", + Some("left-pad@1.3.0"), + ), + // v2's edits: a nested path key, a legacy bare-name key, and a + // version-exact pnpm key — ALL must survive dropping v1. + edit_resolved( + "package-lock.json", + "redirect_npm_lock_entry", + "node_modules/a/node_modules/left-pad", + &url_v2, + ), + edit_resolved( + "package-lock.json", + "redirect_npm_lock_dep", + "left-pad", + &url_v2, + ), + edit( + "pnpm-lock.yaml", + "redirect_pnpm_resolution", + Some("left-pad@2.0.0"), + ), + ]; + + assert!(drop_superseded_purl(&mut state, "pkg:npm/left-pad@1.3.0")); + + assert!( + !state.records.contains_key("pkg:npm/left-pad@1.3.0"), + "the vendored version's record must be dropped" + ); + assert!( + state.records.contains_key("pkg:npm/left-pad@2.0.0"), + "the still-hosted version's record must survive" + ); + let keys: Vec<&str> = state + .edits + .iter() + .filter_map(|e| e.key.as_deref()) + .collect(); + assert_eq!( + keys, + vec![ + "node_modules/a/node_modules/left-pad", + "left-pad", + "left-pad@2.0.0" + ], + "the other hosted version's edits are its only revert data and \ + must survive verbatim: {keys:?}" + ); + } + + /// A DEGRADED ledger (record fetch failed: `records` empty, edits only) + /// offers no artifact anchor. The secondary guard must stay version-exact + /// — `name@version` plus the `(`/`_` instance suffixes — and version-blind + /// path/bare-name keys must be KEPT (they cannot be attributed to a + /// version, and dropping them could destroy another version's revert + /// originals). Fail closed: leftover keys mean the takeover warning's + /// manual advisory keeps firing, which is the correct degraded outcome. + #[test] + fn drop_superseded_purl_without_a_record_claims_only_version_exact_keys() { + let mut state = RedirectState::new(); + state.edits = vec![ + edit( + "pnpm-lock.yaml", + "redirect_pnpm_resolution", + Some("left-pad@1.3.0"), + ), + edit( + "pnpm-lock.yaml", + "redirect_pnpm_resolution", + Some("left-pad@1.3.0_react@18.2.0"), + ), + edit( + "pnpm-lock.yaml", + "redirect_pnpm_resolution", + Some("left-pad@1.3.0(react@18.2.0)"), + ), + // A LONGER version sharing the prefix: `1.3.0` must not claim + // `1.3.01`'s instances (the `_`/`(` boundary is load-bearing). + edit( + "pnpm-lock.yaml", + "redirect_pnpm_resolution", + Some("left-pad@1.3.01_react@18.2.0"), + ), + // Version-blind keys: unattributable without the anchor — keep. + edit_resolved( + "package-lock.json", + "redirect_npm_lock_entry", + "node_modules/left-pad", + "https://patch.test/no-uuid-here/left-pad-1.3.0.tgz", + ), + edit_resolved( + "package-lock.json", + "redirect_npm_lock_dep", + "left-pad", + "https://patch.test/no-uuid-here/left-pad-1.3.0.tgz", + ), + ]; + + assert!(drop_superseded_purl(&mut state, "pkg:npm/left-pad@1.3.0")); + + let keys: Vec<&str> = state + .edits + .iter() + .filter_map(|e| e.key.as_deref()) + .collect(); + assert_eq!( + keys, + vec![ + "left-pad@1.3.01_react@18.2.0", + "node_modules/left-pad", + "left-pad" + ], + "without an artifact anchor only version-exact instance keys may \ + be claimed: {keys:?}" + ); + } + + /// A version-boundary key (`left-pad@1.3.10`) and a different package + /// whose name merely ends with the target's (`not-left-pad`) must never + /// be claimed — the `/`-boundary and `(`-boundary checks are load-bearing. + #[test] + fn drop_superseded_purl_respects_name_and_version_boundaries() { + let mut state = RedirectState::new(); + state.edits = vec![ + edit( + "pnpm-lock.yaml", + "redirect_pnpm_resolution", + Some("left-pad@1.3.10"), + ), + edit( + "package-lock.json", + "redirect_npm_lock_entry", + Some("node_modules/not-left-pad"), + ), + ]; + assert!(!drop_superseded_purl(&mut state, "pkg:npm/left-pad@1.3.1")); + assert_eq!(state.edits.len(), 2, "no foreign edit may be claimed"); + } + + /// Scoped names: the record key may carry the percent-encoded API form + /// while the caller passes the canonical decoded purl; both halves must + /// still be claimed (the path-keyed edit via the artifact anchor its + /// rewritten content carries). + #[test] + fn drop_superseded_purl_matches_percent_encoded_scoped_records() { + let mut state = RedirectState::new(); + state + .records + .insert("pkg:npm/%40scope%2Fpkg@1.0.0".to_string(), sample_record()); + state.edits = vec![edit_resolved( + "package-lock.json", + "redirect_npm_lock_entry", + "node_modules/@scope/pkg", + &hosted_url("%40scope%2Fpkg", "1.0.0", SAMPLE_UUID), + )]; + assert!(drop_superseded_purl(&mut state, "pkg:npm/@scope/pkg@1.0.0")); + assert!(state.records.is_empty() && state.edits.is_empty()); + } + + /// Cargo purls are refused: their takeover must revert the hosted edits + /// ON DISK first (`revert_cargo_redirect_purl`), so a bare ledger drop + /// would destroy the only revert data. Fail closed by dropping nothing. + #[test] + fn drop_superseded_purl_refuses_cargo() { + let mut state = RedirectState::new(); + state + .records + .insert("pkg:cargo/cfg-if@1.0.4".to_string(), sample_record()); + state.edits = vec![edit( + "Cargo.lock", + "redirect_cargo_lock_entry", + Some("cfg-if@1.0.4"), + )]; + assert!(!drop_superseded_purl(&mut state, "pkg:cargo/cfg-if@1.0.4")); + assert_eq!(state.records.len(), 1); + assert_eq!(state.edits.len(), 1); + } + #[tokio::test] async fn load_missing_ledger_is_none() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index 4434e2c9..a0989716 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -290,6 +290,23 @@ pub(crate) async fn revert_bun( Ok(d) => d, Err(outcome) => return outcome, }; + // Nothing to replay (a `repair`-reconstructed entry): the artifact may + // only be removed when bun.lock provably no longer resolves through it + // — otherwise refuse, fail-closed, instead of silently bricking + // installs. Runs before the dry-run return so a preview never + // advertises a revert the wet run refuses. + if entry.wiring.is_empty() { + if let Some(blocked) = super::npm_lock::guard_unwired_textual_revert( + project_root, + &entry.uuid, + &uuid_dir_rel, + &[BUN_LOCK], + ) + .await + { + return blocked; + } + } if dry_run { return RevertOutcome::ok(); } @@ -1356,6 +1373,77 @@ mod tests { ); } + // ── empty-wiring (reconstructed) revert guard ────────────────────────── + + /// Reshape a vendored entry into what `repair`'s no-ledger + /// reconstruction persists: same uuid/artifact, EMPTY wiring. With + /// nothing to replay, revert must refuse (fail-closed) while bun.lock + /// still resolves through the artifact — dry-run preview included — + /// still remove a genuinely orphaned artifact, fail closed on an + /// unreadable lock, and proceed when no lock exists at all. + #[tokio::test] + async fn empty_wiring_revert_guards_against_bricking_installs() { + let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + entry.wiring.clear(); + let tgz_path = fx.root().join(fx.rel_tgz()); + let lock_vendored = fx.read_lock().await; + + // Still referenced: refuse, artifact and lock untouched. + for dry_run in [true, false] { + let outcome = revert_bun(&entry, fx.root(), dry_run).await; + assert!(!outcome.success, "dry_run={dry_run}: must refuse"); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_wiring_unknown_revert_blocked"), + "{:?}", + outcome.warnings + ); + assert!(tgz_path.exists(), "artifact survives the refusal"); + assert_eq!(fx.read_lock().await, lock_vendored, "lock untouched"); + } + + // Unreadable lock (not UTF-8): undeterminable, fail closed. + tokio::fs::write(fx.root().join(BUN_LOCK), [0xff, 0xfe, b'x']) + .await + .unwrap(); + let outcome = revert_bun(&entry, fx.root(), false).await; + assert!(!outcome.success, "unreadable-lock revert must refuse"); + assert!(tgz_path.exists()); + + // Re-locked away from the artifact (provably orphaned): removal + // proceeds, replaying nothing. + tokio::fs::write(fx.root().join(BUN_LOCK), BN3_BEFORE_LOCK) + .await + .unwrap(); + let outcome = revert_bun(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(!tgz_path.exists(), "orphaned artifact removed"); + assert_eq!( + fx.read_lock().await, + BN3_BEFORE_LOCK, + "empty wiring replays nothing" + ); + + // No lock at all: nothing can reference the artifact — proceed. + let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + entry.wiring.clear(); + tokio::fs::remove_file(fx.root().join(BUN_LOCK)) + .await + .unwrap(); + let outcome = revert_bun(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + !fx.root().join(fx.rel_tgz()).exists(), + "no lock, no reference" + ); + } + #[tokio::test] async fn revert_refuses_tampered_uuid_fail_closed() { let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; diff --git a/crates/socket-patch-core/src/vendor/lock_inventory.rs b/crates/socket-patch-core/src/vendor/lock_inventory.rs index c2030829..0b9c187f 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory.rs @@ -99,10 +99,11 @@ impl LockfileEntry { /// Inventory the project's npm-family lockfile. Routes by /// [`detect_npm_lock_flavor`]; the two PNPM-SPECIFIC probe refusals /// (legacy lockfileVersion, pnpm node-linker=pnp) fall back to reading a -/// root `pnpm-lock.yaml` directly, and any probe failure falls back to -/// Rush's common lock when `rush.json` is present. All other refusals -/// (yarn-berry PnP markers, bun locks, unrecognizable yarn locks, a -/// missing lockfile) yield `None`. +/// root `pnpm-lock.yaml` directly, a `vendor_lockfile_missing` refusal +/// falls back to the pnpm <=2-era `shrinkwrap.yaml` (same v5 grammar, +/// older filename), and any probe failure falls back to Rush's common +/// lock when `rush.json` is present. All other refusals (yarn-berry PnP +/// markers, bun locks, unrecognizable yarn locks) yield `None`. pub(crate) async fn inventory_npm_lock( project_root: &Path, ) -> Option<(NpmLockFlavor, Vec)> { @@ -132,6 +133,26 @@ pub(crate) async fn inventory_npm_lock( return Some((NpmLockFlavor::Pnpm, finalize_npm(pnpm))); } } + // pnpm 1/2 wrote the v5-era lock grammar under the name + // `shrinkwrap.yaml` (shrinkwrapVersion 3) — pnpm 3 renamed the + // file to pnpm-lock.yaml. The flavor probe doesn't know that + // filename, so such a project refuses as + // `vendor_lockfile_missing`; the lock still names the full + // resolved set, so read it directly rather than leaving pnpm<=2 + // projects (and their fresh clones) lockfile-blind. Gated on + // that ONE code: any other refusal means a DIFFERENT lock + // family is present (yarn-berry/bun markers, an unsupported + // recognized lock), where a shrinkwrap.yaml is stale debris + // from a long-ago migration whose dead resolutions must not + // pose as the live dependency set. + if code == "vendor_lockfile_missing" { + let legacy = inventory_pnpm_lock_at(&project_root.join("shrinkwrap.yaml")) + .await + .unwrap_or_default(); + if !legacy.is_empty() { + return Some((NpmLockFlavor::PnpmLegacy, finalize_npm(legacy))); + } + } // Rush monorepos have no root package.json/lock pair; their // single pnpm source-of-truth lives under common/config/rush/. // The flavor probe (root-relative) can't see it, so fall back @@ -143,7 +164,10 @@ pub(crate) async fn inventory_npm_lock( }; let raw = match flavor { NpmLockFlavor::PackageLock => inventory_package_lock(project_root).await, - NpmLockFlavor::Pnpm => inventory_pnpm_lock(project_root).await, + // The pnpm reader is grammar-agnostic (it already served legacy + // 5.4/6.0 locks through the refusal fallback below before those + // grammars had a wiring backend), so both pnpm flavors share it. + NpmLockFlavor::Pnpm | NpmLockFlavor::PnpmLegacy => inventory_pnpm_lock(project_root).await, NpmLockFlavor::YarnClassic => inventory_yarn_classic(project_root).await, NpmLockFlavor::YarnBerry => inventory_yarn_berry(project_root).await, NpmLockFlavor::Bun => inventory_bun(project_root).await, @@ -461,15 +485,40 @@ async fn inventory_pnpm_lock_at(lock_path: &Path) -> Option> } let mut integrity = LockIntegrity::None; let mut tarball: Option = None; - for line in &lines[block.header + 1..block.end] { + let entry_lines = &lines[block.header + 1..block.end]; + for (j, line) in entry_lines.iter().enumerate() { let t = line.trim(); - if let Some(rest) = t.strip_prefix("resolution:") { + let Some(rest) = t.strip_prefix("resolution:") else { + continue; + }; + if rest.trim().is_empty() { + // shrinkwrap.yaml (pnpm <=2, shrinkwrapVersion 3) nests the + // resolution as a BLOCK mapping — + // resolution: + // integrity: sha512-… + // — where every pnpm-lock.yaml generation writes the inline + // `resolution: {…}` flow map. Its fields are exactly the + // following deeper-indented lines (a shallower or blank + // line ends the mapping). + let indent = pnpm_lock::indent_of(line); + for child in &entry_lines[j + 1..] { + if child.trim().is_empty() || pnpm_lock::indent_of(child) <= indent { + break; + } + if let Some(v) = inline_yaml_field(child, "integrity:") { + integrity = LockIntegrity::Sri(v); + } + if let Some(v) = inline_yaml_field(child, "tarball:") { + tarball = Some(v); + } + } + } else { if let Some(v) = inline_yaml_field(rest, "integrity:") { integrity = LockIntegrity::Sri(v); } tarball = inline_yaml_field(rest, "tarball:"); - break; } + break; } // Our own vendored spec: not a registry dependency. if tarball @@ -1811,7 +1860,10 @@ packages: write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V5).await; let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap(); - assert_eq!(flavor, NpmLockFlavor::Pnpm); + // The legacy grammars route to the PnpmLegacy wiring flavor now + // (they used to reach here through the version-refusal fallback); + // the inventory content is identical either way. + assert_eq!(flavor, NpmLockFlavor::PnpmLegacy); assert_eq!( sorted_pairs(&entries), vec![ @@ -1874,7 +1926,10 @@ packages: write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V6).await; let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap(); - assert_eq!(flavor, NpmLockFlavor::Pnpm); + // The legacy grammars route to the PnpmLegacy wiring flavor now + // (they used to reach here through the version-refusal fallback); + // the inventory content is identical either way. + assert_eq!(flavor, NpmLockFlavor::PnpmLegacy); assert_eq!( sorted_pairs(&entries), vec![ @@ -1925,6 +1980,111 @@ packages: ); } + // ── shrinkwrap.yaml (pnpm 1/2) ────────────────────────────────────────── + + /// The exact grammar the 2026-08-18 legacy matrix captured from a real + /// pnpm 2 install (shrinkwrapVersion 3): v5-style `/name/version` keys, + /// BLOCK-mapped `resolution:` (integrity nested on its own line — every + /// pnpm-lock.yaml generation writes the inline `{…}` flow map instead), + /// quoted top-level `registry:`, and a transitive dep (`minimist`) + /// listed only under `packages:`. + const SHRINKWRAP_YAML: &str = "dependencies: + left-pad: 1.3.0 + mkdirp: 0.5.5 +packages: + /left-pad/1.3.0: + deprecated: use String.prototype.padStart() + dev: false + resolution: + integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA== + /minimist/1.2.8: + dev: false + resolution: + integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + /mkdirp/0.5.5: + dependencies: + minimist: 1.2.8 + dev: false + hasBin: true + resolution: + integrity: sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== +registry: 'https://registry.npmjs.org/' +shrinkwrapMinorVersion: 9 +shrinkwrapVersion: 3 +specifiers: + left-pad: 1.3.0 + mkdirp: 0.5.5 +"; + + /// A pnpm <=2 project (shrinkwrap.yaml, no pnpm-lock.yaml, no other + /// lock) must be inventoried through the shrinkwrap fallback: same v5 + /// key grammar, integrity read from the BLOCK-mapped resolution — + /// without it such projects report lockfileOnlyPackages=0 despite the + /// lock listing everything. + #[tokio::test] + async fn shrinkwrap_yaml_inventories_pnpm_legacy_project() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "shrinkwrap.yaml", SHRINKWRAP_YAML).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()) + .await + .expect("shrinkwrap.yaml must be inventoried"); + assert_eq!(flavor, NpmLockFlavor::PnpmLegacy); + assert_eq!(entries.len(), 3, "all three packages entries: {entries:?}"); + + let lp = entry(&entries, "left-pad"); + assert_eq!(lp.version, "1.3.0"); + assert_eq!(lp.purl, "pkg:npm/left-pad@1.3.0"); + assert_eq!( + lp.integrity, + LockIntegrity::Sri( + "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/\ + aVx2HrNcqQGsdot8ghrjyrvMCoEA==" + .into() + ), + "block-mapped resolution integrity must be captured" + ); + assert_eq!(lp.resolved, None, "no tarball recorded → registry URL"); + + // The transitive dep (a dependencies: child inside mkdirp's entry + // must not shadow it) and the binary-carrying dep both inventory. + assert_eq!(entry(&entries, "minimist").version, "1.2.8"); + assert_eq!(entry(&entries, "mkdirp").version, "0.5.5"); + } + + /// A root pnpm-lock.yaml wins over shrinkwrap.yaml: the flavor probe + /// recognizes the modern lock, so the legacy fallback never runs — a + /// leftover shrinkwrap.yaml from a long-ago pnpm upgrade must not + /// inject dead resolutions. + #[tokio::test] + async fn pnpm_lock_wins_over_stale_shrinkwrap_yaml() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK).await; + write(tmp.path(), "shrinkwrap.yaml", SHRINKWRAP_YAML).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap(); + assert_eq!(flavor, NpmLockFlavor::Pnpm); + assert!( + !entries.iter().any(|e| e.name == "mkdirp"), + "shrinkwrap-only entries must not leak in: {entries:?}" + ); + } + + /// Same stale-lock hazard as the pnpm-lock fallbacks: a shrinkwrap.yaml + /// behind another family's marker (yarn-berry PnP here — the probe + /// refuses with a NON-missing code) is migration debris, not the live + /// dependency set. + #[tokio::test] + async fn stale_shrinkwrap_behind_yarn_berry_pnp_marker_is_not_inventoried() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "shrinkwrap.yaml", SHRINKWRAP_YAML).await; + write(tmp.path(), ".pnp.cjs", "/* yarn berry PnP loader */").await; + assert!( + inventory_npm_lock(tmp.path()).await.is_none(), + "a shrinkwrap.yaml behind a yarn-berry PnP marker must not be inventoried" + ); + } + /// pnpm's own `node-linker=pnp` layout (`.pnp.cjs` + pnpm store + lock, /// no yarn.lock) refuses with the pnpm-specific PnP code — the fallback /// exists for exactly this project shape and must still read the lock. diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index febc2bae..c702ae02 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -65,6 +65,7 @@ pub mod npm_lock; mod npm_pack; pub mod nuget_feed; pub mod pnpm_lock; +pub mod pnpm_lock_legacy; pub mod pypi; pub mod pypi_pdm; pub mod pypi_pipenv; diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index b91d97bb..120d15fb 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -24,10 +24,11 @@ use std::path::Path; use crate::manifest::schema::PatchRecord; use crate::patch::apply::PatchSources; +use super::pnpm_lock_legacy::PnpmLockGrammar; use super::state::VendorEntry; use super::{ - bun_lock, npm_lock, pnpm_lock, yarn_berry_lock, yarn_classic_lock, RevertOutcome, - VendorOutcome, VendorWarning, + bun_lock, npm_lock, pnpm_lock, pnpm_lock_legacy, yarn_berry_lock, yarn_classic_lock, + RevertOutcome, VendorOutcome, VendorWarning, }; /// Which lockfile flavor drives this project's npm installs. @@ -41,6 +42,9 @@ pub(crate) enum NpmLockFlavor { YarnBerry, /// `pnpm-lock.yaml`, lockfileVersion 9.0 (pnpm >= 9). Pnpm, + /// `pnpm-lock.yaml`, the legacy grammars — lockfileVersion 5.4 (pnpm 7) + /// or 6.0 (pnpm 8). + PnpmLegacy, /// `bun.lock` (bun's text lockfile). Bun, } @@ -53,6 +57,7 @@ impl NpmLockFlavor { NpmLockFlavor::YarnClassic => "yarn-classic", NpmLockFlavor::YarnBerry => "yarn-berry", NpmLockFlavor::Pnpm => "pnpm", + NpmLockFlavor::PnpmLegacy => pnpm_lock_legacy::FLAVOR, NpmLockFlavor::Bun => "bun", } } @@ -92,8 +97,9 @@ const LOCKFILE_FAMILIES: [(NpmLockFlavor, &[&str]); 4] = [ /// [`crate::crawlers::pkg_managers::pnpm_pnp_layout`]) → Err /// `vendor_pnpm_pnp_unsupported` with a pnpm remedy; /// 2. `bun.lock` → Bun; else `bun.lockb` → Err `vendor_bun_lockb_unsupported`; -/// 3. `pnpm-lock.yaml` → head-sniff `lockfileVersion` (only `'9.0'`) → Pnpm, -/// else Err `vendor_lockfile_version_unsupported`; +/// 3. `pnpm-lock.yaml` → head-sniff `lockfileVersion`: `'9.0'` → Pnpm; +/// `5.4`/`'6.0'` (pnpm 7/8) → PnpmLegacy; anything else → Err +/// `vendor_lockfile_version_unsupported` (version-aware remedy); /// 4. `yarn.lock` → head-sniff: column-0 `__metadata:` → Err /// `vendor_yarn_berry_unsupported`; `# yarn lockfile v1` → YarnClassic; /// neither → Err `vendor_lockfile_version_unsupported`; @@ -164,13 +170,20 @@ pub(crate) async fn detect_npm_lock_flavor( )); } - // 3. pnpm: only lockfileVersion 9.0 has a wiring backend (the sniff - // is the pnpm backend's own pre-flight check). + // 3. pnpm: lockfileVersion 9.0 routes to the v9 backend, the legacy + // grammars 5.4 (pnpm 7) / 6.0 (pnpm 8) to the legacy backend; + // anything else refuses with the sniff's version-aware remedy. if exists("pnpm-lock.yaml").await { let text = read_lock(project_root, "pnpm-lock.yaml").await?; - pnpm_lock::check_lock_version(&text) - .map_err(|detail| ("vendor_lockfile_version_unsupported", detail))?; - break 'flavor NpmLockFlavor::Pnpm; + match pnpm_lock_legacy::sniff_lock_grammar(&text) { + Ok(PnpmLockGrammar::V9) => break 'flavor NpmLockFlavor::Pnpm, + Ok(PnpmLockGrammar::V54 | PnpmLockGrammar::V60) => { + break 'flavor NpmLockFlavor::PnpmLegacy + } + Err(detail) => { + return Err(("vendor_lockfile_version_unsupported", detail)); + } + } } // 4. yarn: classic v1 vs berry (node-modules linker), decided by content. @@ -223,6 +236,9 @@ pub(crate) async fn detect_npm_lock_flavor( // berry detection claims it too (never self-warn about the wired file). let family_owner = match detected { NpmLockFlavor::YarnBerry => NpmLockFlavor::YarnClassic, + // Both pnpm backends wire the same pnpm-lock.yaml (the family table + // keys the family under Pnpm) — never self-warn about the wired file. + NpmLockFlavor::PnpmLegacy => NpmLockFlavor::Pnpm, other => other, }; let mut warnings = Vec::new(); @@ -330,6 +346,7 @@ pub async fn vendor_npm_any( NpmLockFlavor::YarnClassic => vend!(yarn_classic_lock::vendor_yarn_classic), NpmLockFlavor::YarnBerry => vend!(yarn_berry_lock::vendor_yarn_berry), NpmLockFlavor::Pnpm => vend!(pnpm_lock::vendor_pnpm), + NpmLockFlavor::PnpmLegacy => vend!(pnpm_lock_legacy::vendor_pnpm_legacy), NpmLockFlavor::Bun => vend!(bun_lock::vendor_bun), }; // Probe warnings (e.g. a sibling lockfile that will install UNPATCHED @@ -365,6 +382,9 @@ pub async fn vendor_npm_any( pub async fn vendored_entry_in_use(entry: &VendorEntry, project_root: &Path) -> Option { match entry.flavor.as_deref() { Some("pnpm") => pnpm_lock::pnpm_entry_in_use(entry, project_root).await, + Some("pnpm-legacy") => { + pnpm_lock_legacy::pnpm_legacy_entry_in_use(entry, project_root).await + } // The remaining flavors wire resolutions into the lock itself // (resolved URLs / file: ranges / package tuples), so a textual // probe for the uuid dir is exact: the path appears iff some @@ -387,7 +407,13 @@ pub async fn vendored_entry_in_use(entry: &VendorEntry, project_root: &Path) -> } /// First readable lockfile from `names`, probed for the uuid artifact dir. -async fn lock_text_mentions_uuid(project_root: &Path, names: &[&str], uuid: &str) -> Option { +/// Shared with the textual backends' unwired-revert guard +/// ([`super::npm_lock::guard_unwired_textual_revert`]). +pub(super) async fn lock_text_mentions_uuid( + project_root: &Path, + names: &[&str], + uuid: &str, +) -> Option { let needle = format!(".socket/vendor/npm/{uuid}/"); for name in names { if let Ok(text) = tokio::fs::read_to_string(project_root.join(name)).await { @@ -415,6 +441,9 @@ pub async fn revert_npm_any( yarn_berry_lock::revert_yarn_berry(entry, project_root, dry_run).await } Some("pnpm") => pnpm_lock::revert_pnpm(entry, project_root, dry_run).await, + Some("pnpm-legacy") => { + pnpm_lock_legacy::revert_pnpm_legacy(entry, project_root, dry_run).await + } Some("bun") => bun_lock::revert_bun(entry, project_root, dry_run).await, Some(other) => RevertOutcome::failed(format!( "this socket-patch build cannot revert npm vendor flavor `{other}` — upgrade \ @@ -467,6 +496,7 @@ mod tests { assert_eq!(NpmLockFlavor::PackageLock.as_str(), "package-lock"); assert_eq!(NpmLockFlavor::YarnClassic.as_str(), "yarn-classic"); assert_eq!(NpmLockFlavor::Pnpm.as_str(), "pnpm"); + assert_eq!(NpmLockFlavor::PnpmLegacy.as_str(), "pnpm-legacy"); assert_eq!(NpmLockFlavor::Bun.as_str(), "bun"); } @@ -578,12 +608,21 @@ mod tests { assert_eq!(flavor, NpmLockFlavor::Pnpm, "{head}"); } - // Older version: named in the error. + // Legacy grammars route to the legacy backend: pnpm 7's bare-float + // 5.4 and pnpm 8's quoted '6.0' (their own spellings, captured). + for head in ["lockfileVersion: 5.4", "lockfileVersion: '6.0'"] { + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "pnpm-lock.yaml", &format!("{head}\n")).await; + let (flavor, _) = detect_npm_lock_flavor(tmp.path()).await.unwrap(); + assert_eq!(flavor, NpmLockFlavor::PnpmLegacy, "{head}"); + } + + // Pre-allowlist version (pnpm 6's 5.3): named in the error. let tmp = tempfile::tempdir().unwrap(); - touch(tmp.path(), "pnpm-lock.yaml", "lockfileVersion: '6.0'\n").await; + touch(tmp.path(), "pnpm-lock.yaml", "lockfileVersion: 5.3\n").await; let (code, detail) = detect_npm_lock_flavor(tmp.path()).await.unwrap_err(); assert_eq!(code, "vendor_lockfile_version_unsupported"); - assert!(detail.contains("6.0"), "{detail}"); + assert!(detail.contains("5.3"), "{detail}"); assert!(detail.contains("pnpm >= 9"), "{detail}"); // No version line in the head at all. @@ -893,6 +932,7 @@ mod tests { Some("yarn-classic".to_string()), Some("yarn-berry".to_string()), Some("pnpm".to_string()), + Some("pnpm-legacy".to_string()), Some("bun".to_string()), ] { entry.flavor = flavor.clone(); diff --git a/crates/socket-patch-core/src/vendor/npm_lock.rs b/crates/socket-patch-core/src/vendor/npm_lock.rs index 25a60d31..b18f54f9 100644 --- a/crates/socket-patch-core/src/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/vendor/npm_lock.rs @@ -368,6 +368,74 @@ pub async fn vendor_npm( done(result, Some(entry), warnings) } +/// FAIL-CLOSED revert guard for a ledger entry with NO wiring records, +/// shared by the four TEXTUAL npm-family backends (npm / yarn classic / +/// yarn berry / bun) — the flavor-parameterized sibling of +/// [`super::pnpm_lock::guard_unwired_revert`]. +/// +/// Such entries come out of `repair`'s no-ledger reconstruction (the +/// npm-family pre-vendor lock fragments are not offline-recoverable, so the +/// restored entry carries empty wiring). Revert has nothing to replay for +/// them — it cannot un-wire the lock — so removing the artifact while the +/// lockfile still resolves through it bricks every subsequent install +/// (ENOENT on the missing `file:` tarball), and used to do so silently. +/// The in-use probe is textual and EXACT for these flavors (the uuid dir +/// path appears iff some resolution still points at the artifact — see +/// [`super::npm_flavor::vendored_entry_in_use`]), over `lock_names` in the +/// caller's own precedence order (npm: shrinkwrap wins). Mentioned ⇒ +/// refuse; readable and provably absent ⇒ `None`, the caller's removal +/// proceeds unchanged; no readable lock ⇒ refuse, fail-closed (it may still +/// resolve through the artifact) UNLESS no lock file exists at all — a +/// missing lock cannot reference the artifact, matching the wired revert's +/// own missing-lock tolerance. +pub(super) async fn guard_unwired_textual_revert( + project_root: &Path, + entry_uuid: &str, + uuid_dir_rel: &str, + lock_names: &[&str], +) -> Option { + let locks = lock_names.join("/"); + let mentioned = + super::npm_flavor::lock_text_mentions_uuid(project_root, lock_names, entry_uuid).await; + let clause = match mentioned { + Some(false) => return None, + Some(true) => format!("{locks} still resolves through it"), + None => { + let mut unreadable = None; + for name in lock_names { + match tokio::fs::try_exists(project_root.join(name)).await { + Ok(false) => {} + // Fail-closed: a lock we cannot read may still resolve + // through the artifact. + _ => { + unreadable = Some(*name); + break; + } + } + } + // No lock file at all: nothing can reference the artifact. + let name = unreadable?; + format!("{name} exists but could not be read to prove it no longer references it") + } + }; + let detail = format!( + "refusing to remove {uuid_dir_rel}: the ledger entry records no pre-vendor wiring to \ + replay (it was likely reconstructed by `socket-patch repair`; the npm-family \ + pre-vendor lock fragments are not offline-recoverable) and {clause} — deleting the \ + artifact would make every subsequent install fail; run `socket-patch repair` to keep \ + the vendored artifact healthy, and revert by restoring the pre-vendor {locks} (or by \ + removing the dependency and re-locking) before re-running `vendor --revert`" + ); + Some(RevertOutcome { + success: false, + warnings: vec![VendorWarning::new( + "vendor_wiring_unknown_revert_blocked", + detail.clone(), + )], + error: Some(detail), + }) +} + /// Undo one vendored npm package: restore the recorded lock fragments and /// remove the artifact dir. pub async fn revert_npm(entry: &VendorEntry, project_root: &Path, dry_run: bool) -> RevertOutcome { @@ -379,6 +447,23 @@ pub async fn revert_npm(entry: &VendorEntry, project_root: &Path, dry_run: bool) Ok(d) => d, Err(outcome) => return outcome, }; + // Nothing to replay (a `repair`-reconstructed entry): the artifact may + // only be removed when the lock provably no longer resolves through it — + // otherwise refuse, fail-closed, instead of silently bricking installs. + // Runs before the dry-run return so a preview never advertises a revert + // the wet run refuses (same precedent as the uuid guard above). + if entry.wiring.is_empty() { + if let Some(blocked) = guard_unwired_textual_revert( + project_root, + &entry.uuid, + &uuid_dir_rel, + &[SHRINKWRAP, PACKAGE_LOCK], + ) + .await + { + return blocked; + } + } if dry_run { return RevertOutcome::ok(); } @@ -2013,6 +2098,105 @@ mod tests { .exists()); } + // ── empty-wiring (reconstructed) revert guard ────────────────────────── + // Same brick as the pnpm backends' (empirically confirmed 2026-08-18): + // a `repair`-reconstructed entry carries no wiring records; revert used + // to remove the artifact dir unconditionally, leaving the lock resolving + // through a deleted tarball — every later `npm ci` failed ENOENT, and + // nothing said so. + + /// Reshape a vendored entry into what `repair`'s no-ledger + /// reconstruction persists: same uuid/artifact, EMPTY wiring. + async fn reconstructed_fixture() -> (Fixture, VendorEntry) { + let fx = fixture().await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + entry.wiring.clear(); + (fx, entry) + } + + /// With nothing to replay, revert must refuse (fail-closed) while the + /// lock still resolves through the artifact — in the dry-run preview + /// too (never advertise a revert the wet run refuses). + #[tokio::test] + async fn empty_wiring_revert_refuses_while_lock_resolves_through_artifact() { + let (fx, entry) = reconstructed_fixture().await; + let tgz_path = fx.root().join(fx.expected_rel_tgz()); + let lock_vendored = tokio::fs::read(fx.lock_path()).await.unwrap(); + + for dry_run in [true, false] { + let outcome = revert_npm(&entry, fx.root(), dry_run).await; + assert!(!outcome.success, "dry_run={dry_run}: must refuse"); + let err = outcome.error.as_deref().unwrap_or_default(); + assert!(err.contains("socket-patch repair"), "{err}"); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_wiring_unknown_revert_blocked"), + "{:?}", + outcome.warnings + ); + assert!(tgz_path.exists(), "the artifact must survive the refusal"); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + lock_vendored, + "lock untouched" + ); + } + } + + /// The flip side: when the lock provably no longer resolves through the + /// artifact (re-locked away from it), the empty-wiring revert keeps its + /// pre-guard behavior and removes the genuinely orphaned artifact — + /// replaying nothing. A shrinkwrap wins the probe like it wins installs: + /// an uuid mention left behind in package-lock.json does not block. + #[tokio::test] + async fn empty_wiring_revert_removes_a_genuinely_orphaned_artifact() { + let (fx, entry) = reconstructed_fixture().await; + // npm installs from the shrinkwrap when both exist; the pre-vendor + // one carries no uuid reference while package-lock.json still does. + tokio::fs::write(fx.root().join(SHRINKWRAP), &fx.lock_bytes) + .await + .unwrap(); + let lock_vendored = tokio::fs::read(fx.lock_path()).await.unwrap(); + + let outcome = revert_npm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + !fx.root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "orphaned artifact dir removed" + ); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + lock_vendored, + "empty wiring replays nothing" + ); + } + + /// Undeterminable lock (present but unreadable — not UTF-8): fail + /// closed — it may still resolve through the artifact. A lock that is + /// absent altogether cannot reference anything, so removal proceeds. + #[tokio::test] + async fn empty_wiring_revert_fails_closed_on_unreadable_lock() { + let (fx, entry) = reconstructed_fixture().await; + let tgz_path = fx.root().join(fx.expected_rel_tgz()); + + tokio::fs::write(fx.lock_path(), [0xff, 0xfe, b'x']) + .await + .unwrap(); + let outcome = revert_npm(&entry, fx.root(), false).await; + assert!(!outcome.success, "unreadable-lock revert must refuse"); + assert!(tgz_path.exists()); + + tokio::fs::remove_file(fx.lock_path()).await.unwrap(); + let outcome = revert_npm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(!tgz_path.exists(), "no lock, no reference: removal is safe"); + } + #[tokio::test] async fn traversal_uuid_is_refused_before_any_write() { let mut fx = fixture().await; diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock.rs b/crates/socket-patch-core/src/vendor/pnpm_lock.rs index db09dfb6..0a3bc758 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock.rs @@ -79,9 +79,9 @@ const WS_SCAFFOLD_PACKAGES: [&str; 2] = ["packages:", " - '.'"]; const SUPPORTED_LOCK_VERSION: &str = "9.0"; /// Wiring kinds (the `WiringRecord.kind` discriminators this backend owns). -const KIND_PKG_OVERRIDE: &str = "pnpm_pkg_override"; +pub(super) const KIND_PKG_OVERRIDE: &str = "pnpm_pkg_override"; const KIND_WS_OVERRIDE: &str = "pnpm_ws_override"; -const KIND_LOCK_OVERRIDES: &str = "pnpm_lock_overrides"; +pub(super) const KIND_LOCK_OVERRIDES: &str = "pnpm_lock_overrides"; const KIND_LOCK_IMPORTER_DEP: &str = "pnpm_lock_importer_dep"; const KIND_LOCK_PACKAGE: &str = "pnpm_lock_package"; const KIND_LOCK_SNAPSHOT: &str = "pnpm_lock_snapshot"; @@ -194,8 +194,7 @@ pub async fn vendor_pnpm( if let Err(detail) = check_lock_override(&lines, name, version, &effective_key) { return refused("vendor_override_conflict", detail); } - if let Err(detail) = - check_workspace_override(ws_text.as_deref(), name, version, &effective_key) + if let Err(detail) = check_workspace_override(ws_text.as_deref(), name, version, &effective_key) { return refused("vendor_override_conflict", detail); } @@ -282,11 +281,11 @@ pub async fn vendor_pnpm( // The pnpm >= 11 override surface. Mirrors the package.json override // key-for-key so whichever surface the installed pnpm reads matches the // lock's `overrides:` section. - let ws_edit = match apply_workspace_override(ws_text.as_deref(), &effective_key, &spec, &mut wiring) - { - Ok(edit) => edit, - Err(e) => return done_failure(purl, format!("{PNPM_WORKSPACE} surgery failed: {e}")), - }; + let ws_edit = + match apply_workspace_override(ws_text.as_deref(), &effective_key, &spec, &mut wiring) { + Ok(edit) => edit, + Err(e) => return done_failure(purl, format!("{PNPM_WORKSPACE} surgery failed: {e}")), + }; if !pkg_changed && !lock_changed && ws_edit.new_text.is_none() { // Everything already carries this uuid + the packed integrity: the @@ -402,6 +401,56 @@ pub async fn pnpm_entry_in_use(entry: &VendorEntry, project_root: &Path) -> Opti Some(false) } +/// FAIL-CLOSED revert guard for a ledger entry with NO wiring records, +/// shared by both pnpm backends. +/// +/// Such entries come out of `repair`'s no-ledger reconstruction (the +/// npm-family pre-vendor lock fragments are not offline-recoverable, so the +/// restored entry carries empty wiring). Revert has nothing to replay for +/// them — it cannot un-wire the lock — so removing the artifact while +/// `pnpm-lock.yaml` still resolves through it bricks every subsequent +/// install (ENOENT on the missing `file:` tarball), and used to do so +/// silently. `in_use` is the calling backend's own lock probe result +/// ([`pnpm_entry_in_use`] / its legacy twin): `Some(true)` refuses; +/// `Some(false)` (provably orphaned) returns `None` and the caller's +/// removal proceeds unchanged; undeterminable (`None`) refuses too UNLESS +/// the lock is absent altogether — a missing lock cannot reference the +/// artifact, matching the wired revert's own missing-lock tolerance. +pub(super) async fn guard_unwired_revert( + project_root: &Path, + in_use: Option, + uuid_dir_rel: &str, +) -> Option { + let clause = match in_use { + Some(false) => return None, + Some(true) => format!("{PNPM_LOCK} still resolves through it"), + None => match tokio::fs::try_exists(project_root.join(PNPM_LOCK)).await { + Ok(false) => return None, + // Fail-closed: a lock we cannot read or parse may still + // resolve through the artifact. + _ => format!( + "{PNPM_LOCK} exists but could not be parsed to prove it no longer references it" + ), + }, + }; + let detail = format!( + "refusing to remove {uuid_dir_rel}: the ledger entry records no pre-vendor wiring to \ + replay (it was likely reconstructed by `socket-patch repair`; pnpm's pre-vendor lock \ + fragments are not offline-recoverable) and {clause} — deleting the artifact would make \ + every subsequent install fail; run `socket-patch repair` to keep the vendored artifact \ + healthy, and revert by restoring the pre-vendor {PNPM_LOCK}/{PACKAGE_JSON} (or by \ + removing the override and re-locking) before re-running `vendor --revert`" + ); + Some(RevertOutcome { + success: false, + warnings: vec![VendorWarning::new( + "vendor_wiring_unknown_revert_blocked", + detail.clone(), + )], + error: Some(detail), + }) +} + /// Undo one pnpm-vendored package: restore the recorded pair fragments and /// remove the artifact dir. Reverse application order; per-record ownership /// is re-checked against the live fragment (drift ⇒ warning, left alone). @@ -413,6 +462,17 @@ pub async fn revert_pnpm(entry: &VendorEntry, project_root: &Path, dry_run: bool Ok(d) => d, Err(outcome) => return outcome, }; + // Nothing to replay (a `repair`-reconstructed entry): the artifact may + // only be removed when the lock provably no longer resolves through it — + // otherwise refuse, fail-closed, instead of silently bricking installs. + // Runs before the dry-run return so a preview never advertises a revert + // the wet run refuses (same precedent as the uuid guard above). + if entry.wiring.is_empty() { + let in_use = pnpm_entry_in_use(entry, project_root).await; + if let Some(blocked) = guard_unwired_revert(project_root, in_use, &uuid_dir_rel).await { + return blocked; + } + } if dry_run { return RevertOutcome::ok(); } @@ -573,7 +633,10 @@ pub async fn revert_pnpm(entry: &VendorEntry, project_root: &Path, dry_run: bool .find(|r| r.file == PNPM_WORKSPACE && r.kind == KIND_WS_OVERRIDE) { let (created_file, created_overrides) = match &entry.pnpm { - Some(meta) => (meta.created_workspace_file, meta.created_workspace_overrides), + Some(meta) => ( + meta.created_workspace_file, + meta.created_workspace_overrides, + ), None => (false, false), }; if let Err(e) = revert_workspace( @@ -688,7 +751,11 @@ fn revert_ws_record( } match rec.original.as_ref().and_then(Value::as_str) { Some(orig) => { - lines[i] = format!("{}{}: {orig}", " ".repeat(indent), yaml_key_like(key, &repr)); + lines[i] = format!( + "{}{}: {orig}", + " ".repeat(indent), + yaml_key_like(key, &repr) + ); } None => { lines.remove(i); @@ -780,7 +847,11 @@ impl EditCtx<'_> { // ─────────────────────────── pre-flight checks ─────────────────────────── /// `lockfileVersion: '9.0'` head check (accept pnpm's single quotes plus -/// double-quoted/bare spellings). Also serves as the flavor router's sniff. +/// double-quoted/bare spellings) — the v9 BACKEND's own guard. The flavor +/// router sniffs with [`super::pnpm_lock_legacy::sniff_lock_grammar`] +/// instead, whose allowlist also routes the legacy 5.4/6.0 grammars to +/// their backend; this check only fires if a non-9.0 lock reaches +/// `vendor_pnpm` directly. pub(super) fn check_lock_version(text: &str) -> Result<(), String> { let version = text .lines() @@ -789,10 +860,25 @@ pub(super) fn check_lock_version(text: &str) -> Result<(), String> { .map(|rest| rest.trim().trim_matches(['\'', '"']).to_string()); match version { Some(v) if v == SUPPORTED_LOCK_VERSION => Ok(()), - Some(v) => Err(format!( - "{PNPM_LOCK} has lockfileVersion {v}; only {SUPPORTED_LOCK_VERSION} is \ - supported — re-lock with pnpm >= 9" - )), + Some(v) => { + // The remedy must point the right way: 5.x (pnpm 7) / 6.x + // (pnpm 8) locks predate the v9 grammar and upgrading pnpm + // re-locks them, but a HIGHER version means the user's pnpm + // already outgrew this build — telling them "re-lock with + // pnpm >= 9" would loop them back to the lock they have. + let major = v.split('.').next().and_then(|m| m.parse::().ok()); + Err(match major { + Some(m) if m < 9 => format!( + "{PNPM_LOCK} has lockfileVersion {v}; only {SUPPORTED_LOCK_VERSION} is \ + supported — re-lock with pnpm >= 9" + ), + _ => format!( + "{PNPM_LOCK} has lockfileVersion {v}; this socket-patch build supports \ + lockfileVersion {SUPPORTED_LOCK_VERSION} — re-lock with a pnpm release \ + that emits it, or update socket-patch" + ), + }) + } None => Err(format!( "{PNPM_LOCK} has no lockfileVersion in its head; only \ {SUPPORTED_LOCK_VERSION} is supported — re-lock with pnpm >= 9" @@ -803,7 +889,7 @@ pub(super) fn check_lock_version(text: &str) -> Result<(), String> { /// The package-name component of a pnpm override key /// (`[@scope/]name[@range]`, possibly behind a `parent>child` selector /// chain — the override targets the LAST segment). -fn override_key_name(key: &str) -> &str { +pub(super) fn override_key_name(key: &str) -> &str { let last = key.rsplit('>').next().unwrap_or(key).trim(); if let Some(rest) = last.strip_prefix('@') { match rest.find('@') { @@ -819,7 +905,7 @@ fn override_key_name(key: &str) -> &str { } /// Does `value` point into `.socket/vendor/npm/` (ours — any uuid)? -fn is_vendor_value(value: &str) -> bool { +pub(super) fn is_vendor_value(value: &str) -> bool { parse_vendor_path(value).is_some_and(|p| p.eco == "npm") } @@ -827,7 +913,7 @@ fn is_vendor_value(value: &str) -> bool { /// The leaf binding matters: a project can vendor the same package at /// several versions, and edits must never treat a SIBLING version's /// override/entry as their own. -fn vendor_value_is_for(value: &str, name: &str, version: &str) -> bool { +pub(super) fn vendor_value_is_for(value: &str, name: &str, version: &str) -> bool { parse_vendor_path(value) .is_some_and(|p| p.eco == "npm" && p.leaf == tgz_rel_leaf(name, version)) } @@ -837,7 +923,7 @@ fn vendor_value_is_for(value: &str, name: &str, version: &str) -> bool { /// key-for-key (pnpm hard-checks the two and fails /// `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH` on any drift), so whichever key /// this classification yields is the one BOTH surfaces edit. -enum OverrideDisposition { +pub(super) enum OverrideDisposition { /// No same-name key: insert our canonical `name@version` key. Insert, /// A same-name key already points into `.socket/vendor/npm/` — ours @@ -858,7 +944,7 @@ enum OverrideDisposition { impl OverrideDisposition { /// The override key both surfaces edit: the matched existing key, or /// our canonical `name@version` on a fresh insert. - fn effective_key<'a>(&'a self, our_key: &'a str) -> &'a str { + pub(super) fn effective_key<'a>(&'a self, our_key: &'a str) -> &'a str { match self { OverrideDisposition::Insert => our_key, OverrideDisposition::Ours { key } | OverrideDisposition::Takeover { key } => key, @@ -871,7 +957,7 @@ impl OverrideDisposition { /// a range/different-version value, a `parent>child` selector chain /// (scoped to one dependent — our whole-graph rewrite has different /// semantics), a non-string value, or several same-name keys. -fn classify_pkg_override( +pub(super) fn classify_pkg_override( pkg: &Value, name: &str, version: &str, @@ -930,7 +1016,7 @@ fn classify_pkg_override( /// drift means the pair is already desynced) with a value the edit can /// own: ours, the exact pinned `version` (takeover), or already our spec. /// A missing section/key is fine — the edit inserts it, restoring parity. -fn check_lock_override( +pub(super) fn check_lock_override( lines: &[String], name: &str, version: &str, @@ -1131,7 +1217,7 @@ fn check_rewritable_refs(lines: &[String], name: &str, version: &str) -> Result< /// Add/refresh `pnpm.overrides[@] = file:` on the /// parsed (preserve_order) document. Returns /// `(changed, created_pnpm_table, created_overrides_table)`. -fn apply_pkg_override( +pub(super) fn apply_pkg_override( pkg: &mut Value, our_key: &str, spec: &str, @@ -1326,7 +1412,10 @@ fn apply_workspace_override( lines[i] = format!("{pad}{}: {spec}", yaml_key_like(our_key, &repr)); wiring.push(ws_record(our_key, spec, WiringAction::Rewritten, original)); } else { - lines.insert(last_entry + 1, format!("{pad}{}: {spec}", yaml_key(our_key))); + lines.insert( + last_entry + 1, + format!("{pad}{}: {spec}", yaml_key(our_key)), + ); wiring.push(ws_record(our_key, spec, WiringAction::Added, None)); } return Ok(WorkspaceEdit { @@ -1346,7 +1435,10 @@ fn apply_workspace_override( .unwrap_or(lines.len()); lines.splice( anchor..anchor, - ["overrides:".to_string(), format!(" {}: {spec}", yaml_key(our_key))], + [ + "overrides:".to_string(), + format!(" {}: {spec}", yaml_key(our_key)), + ], ); wiring.push(ws_record(our_key, spec, WiringAction::Added, None)); Ok(WorkspaceEdit { @@ -1441,7 +1533,7 @@ fn edit_overrides( Ok(true) } -fn overrides_record( +pub(super) fn overrides_record( key: &str, spec: &str, action: WiringAction, @@ -1763,7 +1855,7 @@ fn edit_snapshot_refs( // ───────────────────────────── revert helpers ───────────────────────────── -fn revert_pkg_record( +pub(super) fn revert_pkg_record( doc: &mut Value, rec: &WiringRecord, entry_uuid: &str, @@ -1848,7 +1940,7 @@ fn revert_lock_record( } } -fn revert_overrides_line( +pub(super) fn revert_overrides_line( lines: &mut Vec, rec: &WiringRecord, key: &str, @@ -2111,7 +2203,7 @@ fn revert_snapshot_ref( ))); } -fn drifted(detail: impl Into) -> VendorWarning { +pub(super) fn drifted(detail: impl Into) -> VendorWarning { VendorWarning::new("vendor_lock_entry_drifted", detail.into()) } @@ -2121,7 +2213,7 @@ fn drifted(detail: impl Into) -> VendorWarning { /// the lock LAST; a lock failure unwinds both override surfaces so the P3 /// desync (an override with no matching lock entry, which pnpm silently /// unpatches or rejects as a config mismatch) is never left on disk. -async fn commit_surfaces( +pub(super) async fn commit_surfaces( project_root: &Path, new_pkg: Option<&[u8]>, original_pkg: &[u8], @@ -2264,7 +2356,7 @@ pub(super) fn next_block(lines: &[String], mut i: usize, end: usize) -> Option usize { +pub(super) fn indent_of(line: &str) -> usize { line.len() - line.trim_start_matches(' ').len() } @@ -2273,7 +2365,7 @@ fn indent_of(line: &str) -> usize { /// and both quote styles (single quotes are what pnpm emits for `@`-leading /// keys); the value separator is the first `:` followed by a space or EOL /// (keys themselves contain `:` in `file:` specs). -fn parse_key_line(line: &str, indent: usize) -> Option<(String, String, String)> { +pub(super) fn parse_key_line(line: &str, indent: usize) -> Option<(String, String, String)> { if line.len() <= indent || !line.as_bytes()[..indent].iter().all(|&b| b == b' ') { return None; } @@ -2324,7 +2416,7 @@ fn unquote_value(value: &str) -> &str { /// pnpm quotes `@`-leading keys with single quotes; everything we write is /// otherwise bare. -fn yaml_key(key: &str) -> String { +pub(super) fn yaml_key(key: &str) -> String { if key.starts_with('@') { format!("'{key}'") } else { @@ -2333,7 +2425,7 @@ fn yaml_key(key: &str) -> String { } /// Re-spell `key` in the same quoting style as the original `repr`. -fn yaml_key_like(key: &str, original_repr: &str) -> String { +pub(super) fn yaml_key_like(key: &str, original_repr: &str) -> String { match original_repr.as_bytes().first() { Some(b'\'') => format!("'{key}'"), Some(b'"') => format!("\"{key}\""), @@ -2341,11 +2433,11 @@ fn yaml_key_like(key: &str, original_repr: &str) -> String { } } -fn lines_value(lines: &[String]) -> Value { +pub(super) fn lines_value(lines: &[String]) -> Value { Value::Array(lines.iter().map(|l| Value::String(l.clone())).collect()) } -fn value_lines(v: &Value) -> Option> { +pub(super) fn value_lines(v: &Value) -> Option> { v.as_array().map(|a| { a.iter() .filter_map(Value::as_str) @@ -2913,6 +3005,109 @@ snapshots: assert_eq!(pnpm_entry_in_use(&entry, fx.root()).await, None); } + // ── empty-wiring (reconstructed) revert guard ────────────────────────── + + /// Vendor, then reshape the entry into what `repair`'s no-ledger + /// reconstruction persists: same uuid/artifact, EMPTY wiring, no meta. + async fn reconstructed_fixture() -> (Fixture, VendorEntry) { + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + entry.wiring.clear(); + entry.pnpm = None; + (fx, entry) + } + + /// P1 regression (empirically confirmed vs a real pnpm@10.34.5 project, + /// 2026-08-18): a `repair`-reconstructed entry carries no wiring + /// records; revert used to remove the artifact dir unconditionally, + /// leaving the lock resolving through a deleted tarball — every later + /// install failed ENOENT, and nothing said so. With nothing to replay, + /// revert must refuse (fail-closed) while the lock still resolves + /// through the artifact. + #[tokio::test] + async fn empty_wiring_revert_refuses_while_lock_resolves_through_artifact() { + let (fx, entry) = reconstructed_fixture().await; + let tgz_path = fx.root().join(fx.rel_tgz()); + let lock_before = fx.read(PNPM_LOCK).await; + + // The dry-run preview must refuse too (same precedent as the uuid + // grammar guard: never advertise a revert the wet run refuses). + for dry_run in [true, false] { + let outcome = revert_pnpm(&entry, fx.root(), dry_run).await; + assert!(!outcome.success, "dry_run={dry_run}: must refuse"); + let err = outcome.error.as_deref().unwrap_or_default(); + assert!(err.contains("socket-patch repair"), "{err}"); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_wiring_unknown_revert_blocked"), + "{:?}", + outcome.warnings + ); + assert!(tgz_path.exists(), "the artifact must survive the refusal"); + assert_eq!(fx.read(PNPM_LOCK).await, lock_before, "lock untouched"); + } + } + + /// The flip side: when the lock provably no longer resolves through the + /// artifact (dependency removed and re-locked; only the mirrored + /// overrides declaration lingers), the empty-wiring revert keeps its + /// pre-guard behavior and removes the genuinely orphaned artifact. + #[tokio::test] + async fn empty_wiring_revert_removes_a_genuinely_orphaned_artifact() { + let (fx, entry) = reconstructed_fixture().await; + let removed_lock = format!( + "lockfileVersion: '9.0'\n\noverrides:\n left-pad@1.3.0: file:{}\n\nimporters:\n\n \ + .:\n dependencies:\n consumer:\n specifier: file:./consumer\n \ + version: file:consumer\n\npackages:\n\n consumer@file:consumer:\n \ + resolution: {{directory: consumer, type: directory}}\n\nsnapshots:\n\n \ + consumer@file:consumer: {{}}\n", + fx.rel_tgz() + ); + tokio::fs::write(fx.root().join(PNPM_LOCK), &removed_lock) + .await + .unwrap(); + + let outcome = revert_pnpm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + !fx.root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "orphaned artifact dir removed" + ); + assert_eq!( + fx.read(PNPM_LOCK).await, + removed_lock, + "empty wiring replays nothing" + ); + } + + /// Undeterminable lock (present but unsupported grammar): fail closed — + /// it may still resolve through the artifact. A lock that is absent + /// altogether cannot reference anything, so removal proceeds. + #[tokio::test] + async fn empty_wiring_revert_fails_closed_on_undeterminable_lock() { + let (fx, entry) = reconstructed_fixture().await; + let tgz_path = fx.root().join(fx.rel_tgz()); + + tokio::fs::write(fx.root().join(PNPM_LOCK), "lockfileVersion: '6.0'\n") + .await + .unwrap(); + let outcome = revert_pnpm(&entry, fx.root(), false).await; + assert!(!outcome.success, "unparseable-lock revert must refuse"); + assert!(tgz_path.exists()); + + tokio::fs::remove_file(fx.root().join(PNPM_LOCK)) + .await + .unwrap(); + let outcome = revert_pnpm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(!tgz_path.exists(), "no lock, no reference: removal is safe"); + } + // ── exact-version pin takeover ───────────────────────────────────────── /// package.json with a user-authored override pin (`key: value`) plus the @@ -4018,6 +4213,61 @@ snapshots: ); } + /// The unsupported-version remedy must point the right way. The router + /// sniff is now the ALLOWLIST `sniff_lock_grammar` (5.4 / 6.0 / 9.0 — + /// the legacy grammars route to their own backend instead of refusing), + /// so the remedy assertions moved onto it: pre-allowlist locks (pnpm + /// <= 6's 5.x line) are fixed by upgrading pnpm, but a FUTURE lock + /// version means pnpm already outgrew this build — "re-lock with + /// pnpm >= 9" would loop those users back to the lock they have. + #[test] + fn lock_version_remedy_is_version_aware() { + use super::super::pnpm_lock_legacy::{sniff_lock_grammar, PnpmLockGrammar}; + + assert!(check_lock_version("lockfileVersion: '9.0'\n").is_ok()); + assert_eq!( + sniff_lock_grammar("lockfileVersion: '9.0'\n"), + Ok(PnpmLockGrammar::V9) + ); + + // The legacy grammars are ALLOWLISTED now (pnpm 7's bare-float 5.4, + // pnpm 8's quoted 6.0): the router routes them to the legacy + // backend rather than refusing. + assert_eq!( + sniff_lock_grammar("lockfileVersion: 5.4\n"), + Ok(PnpmLockGrammar::V54) + ); + assert_eq!( + sniff_lock_grammar("lockfileVersion: '6.0'\n"), + Ok(PnpmLockGrammar::V60) + ); + // …while the v9 BACKEND's own guard still holds them out (they can + // only reach `vendor_pnpm` through a router bug). + for head in ["lockfileVersion: 5.4\n", "lockfileVersion: '6.0'\n"] { + assert!(check_lock_version(head).is_err(), "{head}"); + } + + // Too old for the allowlist (pnpm 6's 5.3): upgrade pnpm. + let err = sniff_lock_grammar("lockfileVersion: 5.3\n").unwrap_err(); + assert!(err.contains("re-lock with pnpm >= 9"), "{err}"); + + // Future versions: the fix is an allowlisted-emitting pnpm or a + // newer build, never "pnpm >= 9" (the user's pnpm already is). + for head in ["lockfileVersion: '9.1'\n", "lockfileVersion: '10.0'\n"] { + let err = sniff_lock_grammar(head).unwrap_err(); + assert!(err.contains("update socket-patch"), "{err}"); + assert!(!err.contains("re-lock with pnpm >= 9"), "{err}"); + // The v9 backend's guard agrees on the direction. + let err = check_lock_version(head).unwrap_err(); + assert!(err.contains("update socket-patch"), "{err}"); + } + + // No version in the head: unrecognizably old — upgrade advice stands. + let err = sniff_lock_grammar("importers:\n").unwrap_err(); + assert!(err.contains("re-lock with pnpm >= 9"), "{err}"); + assert!(check_lock_version("importers:\n").is_err()); + } + #[test] fn override_key_name_grammar() { assert_eq!(override_key_name("left-pad"), "left-pad"); @@ -4103,7 +4353,10 @@ snapshots: #[tokio::test] async fn workspace_file_is_created_with_root_scaffold_and_revert_deletes_it() { let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; - assert!(!ws_exists(&fx).await, "fixture starts with no workspace file"); + assert!( + !ws_exists(&fx).await, + "fixture starts with no workspace file" + ); let (_, entry, _) = expect_done(fx.vendor(false).await); let entry = entry.unwrap(); @@ -4114,9 +4367,10 @@ snapshots: "created workspace carries `packages: ['.']` + the override" ); // The three surfaces agree on the same key → value (no config mismatch). - assert!(fx.read(PNPM_WORKSPACE).await.contains(&format!( - "overrides:\n left-pad@1.3.0: {spec}" - ))); + assert!(fx + .read(PNPM_WORKSPACE) + .await + .contains(&format!("overrides:\n left-pad@1.3.0: {spec}"))); assert!(fx .read(PNPM_LOCK) .await @@ -4240,7 +4494,10 @@ snapshots: assert!(pnpm_meta.created_pnpm_table && pnpm_meta.created_overrides_table); assert!(prev.wiring.iter().any(|r| r.file == PACKAGE_JSON)); assert!(prev.wiring.iter().any(|r| r.file == PNPM_LOCK)); - assert!(!ws_exists(&fx).await, "downgraded state has no workspace file"); + assert!( + !ws_exists(&fx).await, + "downgraded state has no workspace file" + ); // 2. Re-vendor under the current code: package.json + lock are already // in sync, so ONLY the workspace mirror is written and the fresh @@ -4267,7 +4524,11 @@ snapshots: P1_BEFORE_PKG, "package.json byte-restored" ); - assert_eq!(fx.read(PNPM_LOCK).await, P1_BEFORE_LOCK, "lock byte-restored"); + assert_eq!( + fx.read(PNPM_LOCK).await, + P1_BEFORE_LOCK, + "lock byte-restored" + ); assert!( !ws_exists(&fx).await, "the workspace file the re-vendor created is deleted" diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs new file mode 100644 index 00000000..17b03197 --- /dev/null +++ b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs @@ -0,0 +1,2702 @@ +//! pnpm LEGACY vendor backend: the pre-9 lock grammars — `lockfileVersion: +//! 5.4` (pnpm 7) and `'6.0'` (pnpm 8) — wired through the same +//! `package.json pnpm.overrides` + `pnpm-lock.yaml` pair surgery as the v9 +//! backend ([`super::pnpm_lock`]), with the legacy serialization shapes. +//! +//! Every splice below is a faithful port of REAL captured pnpm output +//! (spike `matrix/vendor-legacy-spike/{p7,p8,t7,t8}`, pnpm 7.33.5 / +//! 8.15.9, 2026-08-18): a `file:` tarball override was added to +//! `package.json` and THAT pnpm's own `install` re-serialized the lock; the +//! unit-test fixtures quote those locks verbatim. Both majors were also +//! proven byte-stable across an install re-run of the captured shape. +//! +//! ## The legacy shapes (vs the v9 grammar) +//! +//! 1. `overrides:` — same `@: file:` entry, but the +//! section sits after `lockfileVersion:`/`settings:` (pnpm's +//! ROOT_KEYS_ORDER puts `overrides` at priority 4, before +//! `specifiers:`/`dependencies:`/`packages:`). +//! 2. root dependency — v5.4 keeps flat top-level maps (`specifiers:` + +//! `dependencies:`/`devDependencies:`/`optionalDependencies:` with bare +//! values); v6.0 nests `specifier:`/`version:` under each dep. The +//! resolved value moves to `file:` in both. +//! 3. the SPECIFIER is ABSOLUTE: pnpm <= 8 absolutizes `file:` override +//! prefs against the project root before recording them (verified in the +//! bundled `createVersionsOverrider`: `path.join(rootDir, pkgPath)`), so +//! the captured locks carry `file:/abs/project/.socket/...`. This makes +//! the frozen check path-bound: `pnpm install --frozen-lockfile` only +//! passes in a checkout at that exact absolute path (spike probes A/B), +//! while a `pnpm install --offline --no-frozen-lockfile` at any path installs the +//! patched tarball and re-resolves only that specifier line (probe C). +//! Vendoring writes the absolute spelling pnpm itself emits and surfaces +//! the portability limit as `vendor_pnpm_legacy_absolute_specifier`. +//! 4. `packages:` — the registry entry (`/name/version` in v5.4, +//! `/name@version` in v6.0) is REKEYED to the bare `file:` key +//! (no `name@` prefix, unlike v9), its `resolution:` replaced with +//! `{integrity: , tarball: file:}`, `name:`/`version:` +//! lines inserted after it (legacy registry entries derive both from the +//! key; file: entries spell them out), and `deprecated:` dropped — +//! everything else (`dev: false`, engines, …) verbatim. The rekey MOVES +//! the block to its byte-sorted position (pnpm sorts package keys with +//! the default code-unit compare; `/`-keys sort before `file:`-keys). +//! 5. other packages' `dependencies:`/`optionalDependencies:` refs to the +//! exact version become `name: file:`. +//! +//! No `pnpm-workspace.yaml` is written for legacy locks: pnpm <= 8 reads +//! overrides ONLY from package.json `pnpm.overrides` (proven by the spike — +//! the override applied with no workspace file present), and creating one +//! would flip the project into workspace mode. Legacy WORKSPACE locks +//! (an `importers:` section) are refused fail-closed: the flat-map surgery +//! has no captured fixtures for them. +//! +//! Same commit discipline as v9: package.json first, lock second, unwind on +//! a lock write failure; wiring fragments recorded for byte-identical +//! revert; peer-suffixed / aliased reference spellings refuse before any +//! write. + +use std::path::Path; + +use serde_json::Value; + +use crate::manifest::schema::PatchRecord; +use crate::patch::apply::PatchSources; +use crate::patch::copy_tree::remove_tree; +use crate::utils::fs::atomic_write_bytes_preserving_mode; + +use super::common::{already_patched_result, detect_indent, done, refused, serialize_json}; +use super::npm_common::{ + done_failure, guard_coordinates, guard_revert_uuid_dir, stage_patch_pack, tgz_rel_leaf, +}; +use super::path::parse_vendor_path; +use super::pnpm_lock::{ + apply_pkg_override, check_lock_override, classify_pkg_override, commit_surfaces, drifted, + guard_unwired_revert, lines_value, next_block, overrides_record, parse_key_line, + revert_overrides_line, revert_pkg_record, section_bounds, split_lines, value_lines, + vendor_value_is_for, yaml_key, yaml_key_like, KIND_LOCK_OVERRIDES, +}; +use super::state::{ + write_marker, PnpmMeta, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, +}; +use super::{RevertOutcome, VendorOutcome, VendorWarning}; + +const PACKAGE_JSON: &str = "package.json"; +const PNPM_LOCK: &str = "pnpm-lock.yaml"; + +/// The [`VendorEntry::flavor`] string legacy wirings are stamped with. +/// Distinct from the v9 backend's `"pnpm"` so an older binary (which has no +/// legacy backend) fails CLOSED on revert instead of misreading the records. +pub(super) const FLAVOR: &str = "pnpm-legacy"; + +/// Wiring kinds. `pnpm_pkg_override`/`pnpm_lock_overrides` are shared with +/// the v9 backend (identical fragment shapes); the rest are legacy-only. +const KIND_LOCK_SPECIFIER: &str = "pnpm_lock_specifier"; +const KIND_LOCK_ROOT_DEP: &str = "pnpm_lock_root_dep"; +const KIND_LOCK_ROOT_DEP_PAIR: &str = "pnpm_lock_root_dep_pair"; +const KIND_LOCK_PACKAGE: &str = "pnpm_lock_package"; +const KIND_LOCK_PKG_DEP_REF: &str = "pnpm_lock_pkg_dep_ref"; + +/// SECURITY: same rule as the v9 backend — revert writes are restricted to +/// exactly the pair vendor edits (legacy never touches pnpm-workspace.yaml). +const REVERT_ALLOWLIST: [&str; 2] = [PNPM_LOCK, PACKAGE_JSON]; + +/// The flat root-dependency sections a v5.4/v6.0 single-package lock keys +/// its direct deps under. +const ROOT_DEP_SECTIONS: [&str; 3] = ["dependencies", "devDependencies", "optionalDependencies"]; + +/// Top-level keys that sort BEFORE `overrides:` (pnpm's ROOT_KEYS_ORDER, +/// identical in the 7.33.5 and 8.15.9 bundles); the insert anchor is the +/// first section that is none of these. +const OVERRIDES_PRECEDING: [&str; 4] = [ + "lockfileVersion", + "settings", + "neverBuiltDependencies", + "onlyBuiltDependencies", +]; + +/// Normalize a `std::fs::canonicalize`d project-root path for embedding in +/// the pnpm <= 8 absolute `file:` override specifier. +/// +/// On Windows `canonicalize` returns a VERBATIM path — `\\?\C:\dir` (or +/// `\\?\UNC\server\share\dir` for network paths) with backslash separators +/// — a spelling pnpm itself never emits (its `path.join(rootDir, pkgPath)` +/// records `C:/dir`-shaped, forward-slashed specifiers). Embedding the +/// verbatim spelling makes the very next `pnpm install` re-serialize the +/// specifier line (lock churn) and `pnpm install --frozen-lockfile` fail +/// even in a checkout at the recorded path — contradicting the +/// `vendor_pnpm_legacy_absolute_specifier` warning. So: strip the verbatim +/// prefix (`\\?\` → drive path, `\\?\UNC\` → `\\`-rooted UNC path) and +/// forward-slash the separators of Windows-shaped inputs. +/// +/// Unix paths pass through BYTE-UNCHANGED — including any literal `\` in a +/// unix file name, which is only a separator on Windows-shaped inputs. +/// Pure string-level so the transformation is unit-testable on any host; +/// a real Windows CI leg should confirm pnpm 7/8's own emission spelling. +/// +/// `pub` because the e2e capstone's byte-exact lock oracle must build its +/// expected absolute specifier with THIS transformation — a hand-copied +/// oracle drifted on Windows the moment the real spelling differed. +pub fn normalize_canonical_root(path: &str) -> String { + /// Drive-letter (`C:\...` / `C:/...`) or UNC (`\\server\...`) shape. + fn is_windows_shaped(path: &str) -> bool { + let b = path.as_bytes(); + let drive_letter = b.len() >= 3 + && b[0].is_ascii_alphabetic() + && b[1] == b':' + && (b[2] == b'\\' || b[2] == b'/'); + drive_letter || path.starts_with(r"\\") + } + + if let Some(rest) = path.strip_prefix(r"\\?\UNC\") { + // Verbatim UNC: `\\?\UNC\server\share\dir` → `//server/share/dir`. + format!("//{}", rest.replace('\\', "/")) + } else if let Some(rest) = path.strip_prefix(r"\\?\") { + // Verbatim drive: `\\?\C:\dir` → `C:/dir`. + rest.replace('\\', "/") + } else if is_windows_shaped(path) { + path.replace('\\', "/") + } else { + path.to_string() + } +} + +// ───────────────────────────── grammar sniff ────────────────────────────── + +/// Which pnpm lock grammar a `pnpm-lock.yaml` head declares. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PnpmLockGrammar { + /// `lockfileVersion: '9.0'` — the [`super::pnpm_lock`] backend. + V9, + /// `lockfileVersion: 5.4` (pnpm 7, bare float spelling). + V54, + /// `lockfileVersion: '6.0'` (pnpm 8). + V60, +} + +/// The full vendor allowlist sniff (5.4 / 6.0 / 9.0) the flavor router +/// uses; anything else refuses with a version-aware remedy: pre-allowlist +/// versions (pnpm <= 6's 5.x line) are fixed by upgrading pnpm, but a +/// FUTURE version means the user's pnpm already outgrew this build — +/// looping them back to "re-lock with pnpm >= 9" would hand them the lock +/// they have. +pub(crate) fn sniff_lock_grammar(text: &str) -> Result { + let version = text + .lines() + .take(5) + .find_map(|line| line.strip_prefix("lockfileVersion:")) + .map(|rest| rest.trim().trim_matches(['\'', '"']).to_string()); + match version.as_deref() { + Some("9.0") => Ok(PnpmLockGrammar::V9), + Some("5.4") => Ok(PnpmLockGrammar::V54), + Some("6.0") => Ok(PnpmLockGrammar::V60), + Some(v) => { + let major = v.split('.').next().and_then(|m| m.parse::().ok()); + Err(match major { + Some(m) if m < 9 => format!( + "{PNPM_LOCK} has lockfileVersion {v}; supported versions are 5.4 \ + (pnpm 7), 6.0 (pnpm 8), and 9.0 (pnpm >= 9) — re-lock with pnpm >= 9" + ), + _ => format!( + "{PNPM_LOCK} has lockfileVersion {v}; this socket-patch build supports \ + lockfileVersions 5.4, 6.0, and 9.0 — re-lock with a pnpm release that \ + emits one of them, or update socket-patch" + ), + }) + } + None => Err(format!( + "{PNPM_LOCK} has no lockfileVersion in its head; supported versions are 5.4, \ + 6.0, and 9.0 — re-lock with pnpm >= 9" + )), + } +} + +impl PnpmLockGrammar { + /// Human name for diagnostics (`pnpm 7 (lockfileVersion 5.4)`). + fn describe(self) -> &'static str { + match self { + PnpmLockGrammar::V9 => "pnpm >= 9 (lockfileVersion 9.0)", + PnpmLockGrammar::V54 => "pnpm 7 (lockfileVersion 5.4)", + PnpmLockGrammar::V60 => "pnpm 8 (lockfileVersion 6.0)", + } + } +} + +// ───────────────────────────── edit context ────────────────────────────── + +struct Ctx<'a> { + grammar: PnpmLockGrammar, + name: &'a str, + version: &'a str, + /// `.socket/vendor/npm//` (forward slashes, root-relative). + rel_tgz: &'a str, + /// `file:` — override values, root-dep values, packages key. + spec: &'a str, + /// `file:/` — the SPECIFIER spelling pnpm + /// <= 8 itself emits (module doc §3). + abs_spec: &'a str, + /// `sha512-` of the packed tarball. + integrity: &'a str, + /// The override key both surfaces edit (canonical `name@version`, or a + /// taken-over user key). + override_key: &'a str, +} + +impl Ctx<'_> { + /// The registry packages key this grammar spells for `name@version`. + fn reg_key(&self) -> String { + match self.grammar { + PnpmLockGrammar::V54 => format!("/{}/{}", self.name, self.version), + _ => format!("/{}@{}", self.name, self.version), + } + } + + /// Our rekeyed packages key (`file:` — bare, no `name@`). + fn new_key(&self) -> String { + format!("file:{}", self.rel_tgz) + } + + /// Does `value` point at OUR vendored tarball for THIS name@version + /// (any uuid, relative or the machine-absolute specifier spelling — + /// `parse_vendor_path` anchors on `.socket/vendor/` anywhere in the + /// string)? + fn is_ours(&self, value: &str) -> bool { + vendor_value_is_for(value, self.name, self.version) + } + + /// The peer-suffix marker this grammar appends to a version/key + /// (`1.3.0_peer@1.0.0` in v5.4, `1.3.0(peer@1.0.0)` in v6.0). + fn peer_sep(&self) -> char { + match self.grammar { + PnpmLockGrammar::V54 => '_', + _ => '(', + } + } +} + +// ─────────────────────────────── vendor ─────────────────────────────────── + +/// Vendor one installed npm package into a pnpm 7/8 project. Same contract +/// as [`super::pnpm_lock::vendor_pnpm`]: refuse-early / wire-last, `entry` +/// present iff success and not a dry run, in-sync re-runs synthesize +/// AlreadyPatched. +#[allow(clippy::too_many_arguments)] +pub async fn vendor_pnpm_legacy( + purl: &str, + installed_dir: &Path, + project_root: &Path, + record: &PatchRecord, + sources: &PatchSources<'_>, + vendored_at: &str, + dry_run: bool, + force: bool, + service: Option<&super::VendorServiceConfig>, +) -> VendorOutcome { + let mut warnings: Vec = Vec::new(); + + // ── 1. Coordinates ──────────────────────────────────────────────────── + let coords = match guard_coordinates(purl, record) { + Ok(coords) => coords, + Err(outcome) => return *outcome, + }; + let (name, version) = (coords.name.as_str(), coords.version.as_str()); + let rel_tgz = format!("{}/{}", coords.uuid_dir_rel, tgz_rel_leaf(name, version)); + let spec = format!("file:{rel_tgz}"); + let override_key = format!("{name}@{version}"); + + // ── 2. Read the pair (refuse before any write) ─────────────────────── + let pkg_bytes = match tokio::fs::read(project_root.join(PACKAGE_JSON)).await { + Ok(bytes) => bytes, + Err(e) => { + return refused( + "vendor_lockfile_missing", + format!( + "cannot read {PACKAGE_JSON}: {e} — the pnpm wiring edits the \ + package.json + pnpm-lock.yaml PAIR (a lock-only edit silently \ + unpatches on the next plain `pnpm install`)" + ), + ); + } + }; + let mut pkg: Value = match serde_json::from_slice(&pkg_bytes) { + Ok(Value::Object(map)) => Value::Object(map), + Ok(_) | Err(_) => { + return refused( + "vendor_pkg_json_unsupported", + format!("{PACKAGE_JSON} is not a JSON object; cannot add pnpm.overrides"), + ); + } + }; + let lock_text = match tokio::fs::read_to_string(project_root.join(PNPM_LOCK)).await { + Ok(text) => text, + Err(e) => { + return refused( + "vendor_lockfile_missing", + format!("cannot read {PNPM_LOCK}: {e} — run `pnpm install` first"), + ); + } + }; + let grammar = match sniff_lock_grammar(&lock_text) { + Ok(PnpmLockGrammar::V9) => { + // Router bug guard: v9 locks belong to the v9 backend. + return refused( + "vendor_lockfile_version_unsupported", + format!("{PNPM_LOCK} is a lockfileVersion 9.0 lock; not a legacy grammar"), + ); + } + Ok(g) => g, + Err(detail) => return refused("vendor_lockfile_version_unsupported", detail), + }; + // CRLF fails closed exactly like the v9 backend: every structural probe + // below is byte-exact on LF lines. + if lock_text.contains('\r') { + return refused( + "vendor_lockfile_crlf_unsupported", + format!( + "{PNPM_LOCK} has CRLF line endings, which this rewriter cannot edit \ + byte-faithfully — normalize the file to LF (re-run `pnpm install`, \ + or add `pnpm-lock.yaml text eol=lf` to .gitattributes and re-checkout) \ + and retry" + ), + ); + } + let mut lines = split_lines(&lock_text); + + // Legacy WORKSPACE locks nest everything under `importers:` — a shape + // with no captured fixtures. Fail closed with the upgrade path. + if section_bounds(&lines, "importers").is_some() { + return refused( + "vendor_lock_entry_unsupported", + format!( + "{PNPM_LOCK} is a {} WORKSPACE lock (importers: section); the legacy \ + pair surgery only supports single-package locks — upgrade to pnpm >= 9 \ + (its lockfileVersion 9.0 workspace grammar is supported) and re-lock", + grammar.describe() + ), + ); + } + + // pnpm <= 8 absolutizes file: override prefs against the project root + // (module doc §3), so the specifier splice needs the canonical absolute + // path — the same one pnpm's own process.cwd() yields. On Windows, + // `canonicalize` returns a VERBATIM path (`\\?\C:\...`) that pnpm never + // emits; `normalize_canonical_root` rewrites it to the pnpm spelling so + // the embedded specifier doesn't churn the lock on the next install + // (which would fail `--frozen-lockfile`, contradicting the + // `vendor_pnpm_legacy_absolute_specifier` warning below). + let abs_root = match std::fs::canonicalize(project_root) { + Ok(p) => p, + Err(e) => { + return refused( + "vendor_lock_entry_unsupported", + format!( + "cannot canonicalize the project root ({e}) — the pnpm <= 8 lock \ + records the override specifier as an absolute path" + ), + ); + } + }; + let abs_root = normalize_canonical_root(&abs_root.display().to_string()); + let abs_spec = format!("file:{abs_root}/{rel_tgz}"); + + // ── 3. Pre-flight refusals ──────────────────────────────────────────── + let disposition = match classify_pkg_override(&pkg, name, version, &override_key) { + Ok(d) => d, + Err(detail) => return refused("vendor_override_conflict", detail), + }; + let effective_key = disposition.effective_key(&override_key).to_string(); + if let Err(detail) = check_lock_override(&lines, name, version, &effective_key) { + return refused("vendor_override_conflict", detail); + } + let ctx = Ctx { + grammar, + name, + version, + rel_tgz: &rel_tgz, + spec: &spec, + abs_spec: &abs_spec, + integrity: "", // filled after packing; pre-flight never reads it + override_key: &effective_key, + }; + // Refs guard FIRST: a peer-suffixed packages key is also not the plain + // registry key, and "entry not found" would misdiagnose that shape. + if let Err(detail) = check_rewritable_refs(&lines, &ctx) { + return refused("vendor_lock_entry_unsupported", detail); + } + if !lock_has_target_package(&lines, &ctx) { + return refused( + "vendor_lock_entry_not_found", + format!( + "{PNPM_LOCK} has no packages entry for {name}@{version} — make sure the \ + package is installed and locked (`pnpm install`) before vendoring" + ), + ); + } + + // ── 4. Stage → patch → pack ─────────────────────────────────────────── + let (staged, result) = match stage_patch_pack( + purl, + installed_dir, + project_root, + record, + sources, + dry_run, + force, + &mut warnings, + service, + ) + .await + { + Ok(pair) => pair, + Err(outcome) => return *outcome, + }; + let Some(staged) = staged else { + return done(result, None, warnings); + }; + debug_assert_eq!(staged.rel_tgz, rel_tgz); + let packed = staged.packed; + if staged.staged_pkg_json.is_some() { + // Legacy locks mirror the package's dependency maps inside its + // packages entry, preserved verbatim here — same caveat as v9. + warnings.push(VendorWarning::new( + "vendor_dep_manifest_stale", + format!( + "the patch rewrites {name}@{version}'s package.json; pnpm-lock.yaml's \ + dependency mirrors were preserved verbatim — if the patch changed \ + dependency ranges, run `pnpm install` to re-resolve them" + ), + )); + } + + // ── 5. Compute both edits in memory ────────────────────────────────── + let ctx = Ctx { + integrity: &packed.integrity, + ..ctx + }; + let mut wiring: Vec = Vec::new(); + + let (pkg_changed, created_pnpm_table, created_overrides_table) = + match apply_pkg_override(&mut pkg, &effective_key, &spec, &mut wiring) { + Ok(out) => out, + Err(e) => return done_failure(purl, e), + }; + + let mut lock_changed = false; + match edit_overrides(&mut lines, &ctx, &mut wiring) { + Ok(changed) => lock_changed |= changed, + Err(e) => return done_failure(purl, format!("{PNPM_LOCK} surgery failed: {e}")), + } + let root_edit = match grammar { + PnpmLockGrammar::V54 => edit_root_deps_v54(&mut lines, &ctx, &mut wiring), + _ => edit_root_deps_v60(&mut lines, &ctx, &mut wiring), + }; + let root_dep_hit = match root_edit { + Ok((changed, hit)) => { + lock_changed |= changed; + hit + } + Err(e) => return done_failure(purl, format!("{PNPM_LOCK} surgery failed: {e}")), + }; + if grammar == PnpmLockGrammar::V54 && root_dep_hit { + match edit_specifier_v54(&mut lines, &ctx, &mut wiring) { + Ok(changed) => lock_changed |= changed, + Err(e) => return done_failure(purl, format!("{PNPM_LOCK} surgery failed: {e}")), + } + } + for edit in [edit_packages, edit_pkg_dep_refs] { + match edit(&mut lines, &ctx, &mut wiring) { + Ok(changed) => lock_changed |= changed, + Err(e) => return done_failure(purl, format!("{PNPM_LOCK} surgery failed: {e}")), + } + } + + if !pkg_changed && !lock_changed { + return done( + already_patched_result(purl, &project_root.join(&rel_tgz), &record.files), + None, + warnings, + ); + } + + if root_dep_hit { + // The committable-artifact caveat this grammar cannot avoid + // (module doc §3) — surfaced every wiring run, not just the first. + warnings.push(VendorWarning::new( + "vendor_pnpm_legacy_absolute_specifier", + format!( + "{} records the override specifier as an absolute path \ + (pnpm <= 8 absolutizes file: overrides itself), so `pnpm install \ + --frozen-lockfile` only passes in a checkout at exactly \ + {} — checkouts at other paths must run `pnpm install --offline \ + --no-frozen-lockfile` once (the flag matters on CI, where pnpm \ + defaults --frozen-lockfile on), which installs the vendored \ + tarball and re-resolves only that specifier line", + grammar.describe(), + abs_root + ), + )); + } + + // ── 6. Commit: package.json first, lock second, unwind on failure ──── + let pkg_indent = detect_indent(&String::from_utf8_lossy(&pkg_bytes)); + let new_pkg_bytes = match serialize_json(&pkg, &pkg_indent) { + Ok(bytes) => bytes, + Err(e) => return done_failure(purl, format!("cannot serialize {PACKAGE_JSON}: {e}")), + }; + let lock_out = lines.join("\n"); + if let Err(e) = commit_surfaces( + project_root, + pkg_changed.then_some(new_pkg_bytes.as_slice()), + &pkg_bytes, + None, + None, + false, + lock_changed.then_some(lock_out.as_bytes()), + ) + .await + { + return done_failure(purl, e); + } + + // ── 7. Marker + ledger entry ────────────────────────────────────────── + let marker = VendorMarker::new("npm", &coords.base_purl, record, vendored_at); + if let Err(e) = write_marker(&project_root.join(&coords.uuid_dir_rel), &marker).await { + warnings.push(VendorWarning::new( + "vendor_marker_write_failed", + format!("could not write the informational vendor marker: {e}"), + )); + } + + let entry = VendorEntry { + ecosystem: "npm".to_string(), + base_purl: coords.base_purl, + uuid: record.uuid.clone(), + artifact: VendorArtifact { + path: rel_tgz, + sha256: packed.sha256_hex, + size: Some(packed.size), + platform_locked: None, + file_inventory: None, + }, + wiring, + lock: None, + took_over_go_patches: false, + detached: false, + record: None, + flavor: Some(FLAVOR.to_string()), + uv: None, + pnpm: Some(PnpmMeta { + created_overrides_table, + created_pnpm_table, + // Legacy never touches pnpm-workspace.yaml (module doc). + created_workspace_file: false, + created_workspace_overrides: false, + }), + poetry: None, + pdm: None, + pipenv: None, + }; + done(result, Some(entry), warnings) +} + +/// Is this legacy-vendored entry still consumed by the lock? `Some(true)` +/// when a `packages:` block is keyed by the entry's artifact path; +/// `Some(false)` when the lock parses as a legacy grammar and carries none +/// (the `overrides:` declaration alone never counts); `None` when +/// undeterminable — callers keep the entry, fail-safe. +pub async fn pnpm_legacy_entry_in_use(entry: &VendorEntry, project_root: &Path) -> Option { + let text = tokio::fs::read_to_string(project_root.join(PNPM_LOCK)) + .await + .ok()?; + match sniff_lock_grammar(&text) { + Ok(PnpmLockGrammar::V54 | PnpmLockGrammar::V60) => {} + _ => return None, + } + let lines = split_lines(&text); + let Some((start, end)) = section_bounds(&lines, "packages") else { + return Some(false); + }; + let mut i = start + 1; + while let Some(block) = next_block(&lines, i, end) { + let ours = + parse_vendor_path(&block.key).is_some_and(|p| p.eco == "npm" && p.uuid == entry.uuid); + if ours { + return Some(true); + } + i = block.end; + } + Some(false) +} + +// ─────────────────────────── pre-flight checks ─────────────────────────── + +/// Does the lock have a packages entry vendoring can target — the grammar's +/// registry key, or our rekeyed `file:` key (the in-sync / stale-uuid +/// re-run)? +fn lock_has_target_package(lines: &[String], ctx: &Ctx<'_>) -> bool { + let Some((start, end)) = section_bounds(lines, "packages") else { + return false; + }; + let reg_key = ctx.reg_key(); + let mut i = start + 1; + while let Some(block) = next_block(lines, i, end) { + if block.key == reg_key || ctx.is_ours(&block.key) { + return true; + } + i = block.end; + } + false +} + +/// Fail-closed guard against legacy reference forms the surgery does not +/// rewrite: PEER-SUFFIXED dep paths (`/name/version_peer…` in v5.4, +/// `/name@version(peer…)` in v6.0) and ALIASED references (a dep whose +/// recorded value IS the registry dep path). Both would survive the rekey +/// verbatim and dangle — pnpm then hard-rejects the lock — and neither has +/// a pnpm-blessed fixture. Scans the root dep maps and every packages +/// block's dep maps. +fn check_rewritable_refs(lines: &[String], ctx: &Ctx<'_>) -> Result<(), String> { + let reg_key = ctx.reg_key(); + let key_peer_prefix = format!("{reg_key}{}", ctx.peer_sep()); + let val_peer_prefix = format!("{}{}", ctx.version, ctx.peer_sep()); + let refuse = |what: &str, spelling: &str| { + Err(format!( + "{PNPM_LOCK} references {}@{} through {what} (`{spelling}`) that the \ + pair surgery cannot rewrite — vendoring would leave a dangling reference \ + pnpm rejects; this lock shape is not supported yet", + ctx.name, ctx.version + )) + }; + // Root dep maps (v5.4 bare values; v6.0 version: fields). + for section in ROOT_DEP_SECTIONS { + let Some((start, end)) = section_bounds(lines, section) else { + continue; + }; + let mut k = start + 1; + while k < end { + let Some((dep, _repr, rest)) = parse_key_line(&lines[k], 2) else { + k += 1; + continue; + }; + let value = if rest.is_empty() { + // v6.0 nested shape: read the version: field. + let (_, ver, f) = dep_field_lines(lines, k + 1, end, 4); + k = f; + match ver { + Some((_, v)) => v, + None => continue, + } + } else { + k += 1; + rest + }; + if value == reg_key || value.starts_with(&key_peer_prefix) { + return refuse("an aliased root dependency", &value); + } + if dep == ctx.name && value.starts_with(&val_peer_prefix) { + return refuse("a peer-suffixed root dependency", &value); + } + } + } + // packages blocks: peer-suffixed keys + aliased/peer-suffixed dep refs. + if let Some((start, end)) = section_bounds(lines, "packages") { + let mut i = start + 1; + while let Some(block) = next_block(lines, i, end) { + if block.key.starts_with(&key_peer_prefix) { + return refuse("a peer-suffixed packages key", &block.key); + } + for line in &lines[block.header + 1..block.end] { + let Some((dep, _repr, rest)) = parse_key_line(line, 6) else { + continue; + }; + if rest == reg_key || rest.starts_with(&key_peer_prefix) { + return refuse("an aliased dependency reference", &rest); + } + if dep == ctx.name && rest.starts_with(&val_peer_prefix) { + return refuse("a peer-suffixed dependency reference", &rest); + } + } + i = block.end; + } + } + Ok(()) +} + +// ───────────────────────────── lock edits ───────────────────────────────── + +/// Locate a dep entry's `specifier:`/`version:` field lines at `indent` +/// starting at `f` (v6.0 root deps use 4; the v9 backend's importers use 8). +#[allow(clippy::type_complexity)] +fn dep_field_lines( + lines: &[String], + mut f: usize, + end: usize, + indent: usize, +) -> (Option<(usize, String)>, Option<(usize, String)>, usize) { + let mut spec = None; + let mut ver = None; + while f < end { + let Some((field, _repr, fval)) = parse_key_line(&lines[f], indent) else { + break; + }; + match field.as_str() { + "specifier" => spec = Some((f, fval)), + "version" => ver = Some((f, fval)), + _ => {} + } + f += 1; + } + (spec, ver, f) +} + +/// Edit 1: the `overrides:` section — splice our entry into an existing one, +/// or insert the section at pnpm's ROOT_KEYS_ORDER slot (after +/// `lockfileVersion:`/`settings:`, before everything else — byte-identical +/// to the p7/p8 captures). +fn edit_overrides( + lines: &mut Vec, + ctx: &Ctx<'_>, + wiring: &mut Vec, +) -> Result { + let our_key = ctx.override_key.to_string(); + let entry_line = format!(" {}: {}", yaml_key(&our_key), ctx.spec); + if let Some((start, end)) = section_bounds(lines, "overrides") { + let mut ours = None; + let mut last_entry = start; + for (i, line) in lines.iter().enumerate().take(end).skip(start + 1) { + if let Some((key, repr, rest)) = parse_key_line(line, 2) { + last_entry = i; + if key == our_key { + ours = Some((i, repr, rest)); + break; + } + } + } + if let Some((i, repr, rest)) = ours { + if rest == ctx.spec { + return Ok(false); // in sync + } + // Ours with a stale uuid (no original), or the user's pinned + // value being TAKEN OVER (recorded as original). + let original = (!super::pnpm_lock::is_vendor_value(&rest)).then(|| rest.clone()); + lines[i] = format!(" {}: {}", yaml_key_like(&our_key, &repr), ctx.spec); + wiring.push(overrides_record( + &our_key, + ctx.spec, + WiringAction::Rewritten, + original, + )); + return Ok(true); + } + lines.insert(last_entry + 1, entry_line); + wiring.push(overrides_record( + &our_key, + ctx.spec, + WiringAction::Added, + None, + )); + return Ok(true); + } + // No overrides section: insert at the first top-level key that sorts + // after it (the captures show it between `lockfileVersion:`/`settings:` + // and `specifiers:`/`dependencies:`). + let anchor = lines + .iter() + .position(|l| { + !l.is_empty() + && !l.starts_with(' ') + && !OVERRIDES_PRECEDING.contains(&l.split(':').next().unwrap_or("")) + }) + .unwrap_or(lines.len()); + lines.splice( + anchor..anchor, + ["overrides:".to_string(), entry_line, String::new()], + ); + wiring.push(overrides_record( + &our_key, + ctx.spec, + WiringAction::Added, + None, + )); + Ok(true) +} + +/// Edit 2a (v5.4): the flat root dep maps — `name: ` moves to +/// `name: file:`. Returns `(changed, root_dep_hit)`; `root_dep_hit` +/// is true when the root depends on the package directly (in-sync re-runs +/// included), which is what gates the specifier edit. +fn edit_root_deps_v54( + lines: &mut [String], + ctx: &Ctx<'_>, + wiring: &mut Vec, +) -> Result<(bool, bool), String> { + let mut changed = false; + let mut hit = false; + for section in ROOT_DEP_SECTIONS { + let Some((start, end)) = section_bounds(lines, section) else { + continue; + }; + for line in lines.iter_mut().take(end).skip(start + 1) { + let Some((dep, repr, rest)) = parse_key_line(line, 2) else { + continue; + }; + if dep != ctx.name { + continue; + } + if rest == ctx.spec { + hit = true; // in sync + continue; + } + let target = rest == ctx.version || ctx.is_ours(&rest); + if !target { + continue; + } + hit = true; + let was_ours = ctx.is_ours(&rest); + let original = (!was_ours).then(|| Value::String(rest.clone())); + *line = format!(" {}: {}", yaml_key_like(&dep, &repr), ctx.spec); + wiring.push(WiringRecord { + file: PNPM_LOCK.to_string(), + kind: KIND_LOCK_ROOT_DEP.to_string(), + action: WiringAction::Rewritten, + key: Some(format!("{section}|{dep}")), + original, + new: Some(Value::String(ctx.spec.to_string())), + }); + changed = true; + } + } + Ok((changed, hit)) +} + +/// Edit 2b (v5.4): the `specifiers:` entry — whatever range the user wrote +/// moves to the machine-absolute `file:` spelling pnpm itself records +/// (module doc §3). Only runs when the root depends on the package. +fn edit_specifier_v54( + lines: &mut [String], + ctx: &Ctx<'_>, + wiring: &mut Vec, +) -> Result { + let Some((start, end)) = section_bounds(lines, "specifiers") else { + return Ok(false); + }; + for line in lines.iter_mut().take(end).skip(start + 1) { + let Some((key, repr, rest)) = parse_key_line(line, 2) else { + continue; + }; + if key != ctx.name { + continue; + } + if rest == ctx.abs_spec { + return Ok(false); // in sync + } + // Ours at a stale root/uuid (a moved checkout being re-vendored) has + // no original; anything else is the user's range, recorded. + let original = (!ctx.is_ours(&rest)).then(|| Value::String(rest.clone())); + *line = format!(" {}: {}", yaml_key_like(&key, &repr), ctx.abs_spec); + wiring.push(WiringRecord { + file: PNPM_LOCK.to_string(), + kind: KIND_LOCK_SPECIFIER.to_string(), + action: WiringAction::Rewritten, + key: Some(key), + original, + new: Some(Value::String(ctx.abs_spec.to_string())), + }); + return Ok(true); + } + Ok(false) +} + +/// Edit 2 (v6.0): the nested root dep entries — `specifier:` moves to the +/// machine-absolute spelling, `version:` to the relative `file:` spec (both +/// captured verbatim from pnpm 8.15.9). Returns `(changed, root_dep_hit)`. +fn edit_root_deps_v60( + lines: &mut [String], + ctx: &Ctx<'_>, + wiring: &mut Vec, +) -> Result<(bool, bool), String> { + let mut changed = false; + let mut hit = false; + for section in ROOT_DEP_SECTIONS { + let Some((start, end)) = section_bounds(lines, section) else { + continue; + }; + let mut k = start + 1; + while k < end { + let Some((dep, _repr, rest)) = parse_key_line(&lines[k], 2) else { + k += 1; + continue; + }; + if dep != ctx.name || !rest.is_empty() { + k += 1; + continue; + } + let (spec_f, ver_f, f) = dep_field_lines(lines, k + 1, end, 4); + if let (Some((si, old_spec)), Some((vi, old_ver))) = (spec_f, ver_f) { + let target = old_ver == ctx.version || ctx.is_ours(&old_ver); + if target { + hit = true; + if old_ver == ctx.spec && old_spec == ctx.abs_spec { + k = f; + continue; // in sync + } + let was_ours = ctx.is_ours(&old_ver); + lines[si] = format!(" specifier: {}", ctx.abs_spec); + lines[vi] = format!(" version: {}", ctx.spec); + wiring.push(WiringRecord { + file: PNPM_LOCK.to_string(), + kind: KIND_LOCK_ROOT_DEP_PAIR.to_string(), + action: WiringAction::Rewritten, + key: Some(format!("{section}|{dep}")), + original: if was_ours { + None + } else { + Some(serde_json::json!({ + "specifier": old_spec, + "version": old_ver, + })) + }, + new: Some(serde_json::json!({ + "specifier": ctx.abs_spec, + "version": ctx.spec, + })), + }); + changed = true; + } + } + k = f; + } + } + Ok((changed, hit)) +} + +/// Edit 3: rekey the `packages:` entry to the bare `file:` key at +/// its byte-sorted position — resolution replaced with our integrity + +/// tarball, `name:`/`version:` inserted after it, `deprecated:` dropped, +/// everything else verbatim (module doc §4). +fn edit_packages( + lines: &mut Vec, + ctx: &Ctx<'_>, + wiring: &mut Vec, +) -> Result { + let (start, end) = section_bounds(lines, "packages").ok_or("no packages: section")?; + let reg_key = ctx.reg_key(); + let new_key = ctx.new_key(); + + // Fail closed on a half-drifted lock carrying BOTH spellings. + let mut has_registry = false; + let mut has_ours = false; + let mut j = start + 1; + while let Some(block) = next_block(lines, j, end) { + if block.key == reg_key { + has_registry = true; + } else if ctx.is_ours(&block.key) { + has_ours = true; + } + j = block.end; + } + if has_registry && has_ours { + return Err(format!( + "packages section carries BOTH `{reg_key}` and a `file:…` entry (a \ + half-edited lock); run `pnpm install` to re-resolve it, then re-vendor" + )); + } + + let mut i = start + 1; + while let Some(block) = next_block(lines, i, end) { + let is_registry = block.key == reg_key; + let is_ours_key = ctx.is_ours(&block.key); + if !is_registry && !is_ours_key { + i = block.end; + continue; + } + let original_lines: Vec = lines[block.header..block.end].to_vec(); + let expected_resolution = format!( + " resolution: {{integrity: {}, tarball: {}}}", + ctx.integrity, ctx.spec + ); + if block.key == new_key && original_lines.iter().any(|l| l == &expected_resolution) { + return Ok(false); // in sync + } + let mut new_lines = Vec::with_capacity(original_lines.len() + 2); + // file: keys are emitted bare by pnpm (never quoted) — captured. + new_lines.push(format!(" {new_key}:")); + let mut replaced_resolution = false; + for line in &original_lines[1..] { + if let Some((field, _repr, _rest)) = parse_key_line(line, 4) { + match field.as_str() { + "resolution" => { + new_lines.push(expected_resolution.clone()); + new_lines.push(format!(" name: {}", ctx.name)); + new_lines.push(format!(" version: {}", ctx.version)); + replaced_resolution = true; + continue; + } + // Re-emitted canonically after resolution / dropped + // (pnpm drops `deprecated:` for file: entries — captured). + "name" | "version" | "deprecated" => continue, + _ => {} + } + } + new_lines.push(line.clone()); + } + if !replaced_resolution { + return Err(format!( + "packages entry `{}` has no resolution line", + block.key + )); + } + let old_key = block.key.clone(); + swap_block_sorted(lines, "packages", &old_key, &new_key, &new_lines)?; + wiring.push(WiringRecord { + file: PNPM_LOCK.to_string(), + kind: KIND_LOCK_PACKAGE.to_string(), + action: WiringAction::Rewritten, + key: Some(old_key), + original: if is_ours_key { + None + } else { + Some(lines_value(&original_lines)) + }, + new: Some(lines_value(&new_lines)), + }); + return Ok(true); + } + Err(format!("packages entry for {reg_key} vanished mid-rewrite")) +} + +/// Edit 4: every OTHER packages block's `dependencies:` / +/// `optionalDependencies:` reference to the exact version — `name: +/// ` → `name: file:` (captured: the `file:consumer` +/// directory dep's map). `peerDependencies` values are RANGES, never +/// resolutions, so only the two resolution maps are touched. +// &mut Vec keeps both edit functions' signatures unifiable into the one fn +// array `vendor_pnpm_legacy` iterates (edit_packages needs the Vec). +#[allow(clippy::ptr_arg)] +fn edit_pkg_dep_refs( + lines: &mut Vec, + ctx: &Ctx<'_>, + wiring: &mut Vec, +) -> Result { + let Some((start, end)) = section_bounds(lines, "packages") else { + return Ok(false); + }; + let mut changed = false; + let mut i = start + 1; + while let Some(block) = next_block(lines, i, end) { + let mut in_dep_map = false; + for line in lines[block.header + 1..block.end].iter_mut() { + if let Some((field, _repr, rest)) = parse_key_line(line, 4) { + in_dep_map = rest.is_empty() + && matches!(field.as_str(), "dependencies" | "optionalDependencies"); + continue; + } + if !in_dep_map { + continue; + } + let Some((dep, _repr, rest)) = parse_key_line(line, 6) else { + continue; + }; + if dep != ctx.name { + continue; + } + let target = rest == ctx.version || (rest != ctx.spec && ctx.is_ours(&rest)); + if !target { + continue; + } + let was_ours = ctx.is_ours(&rest); + *line = format!(" {}: {}", yaml_key(&dep), ctx.spec); + wiring.push(WiringRecord { + file: PNPM_LOCK.to_string(), + kind: KIND_LOCK_PKG_DEP_REF.to_string(), + action: WiringAction::Rewritten, + key: Some(format!("{}|{dep}", block.key)), + original: if was_ours { + None + } else { + Some(Value::String(rest.clone())) + }, + new: Some(Value::String(ctx.spec.to_string())), + }); + changed = true; + } + i = block.end; + } + Ok(changed) +} + +/// Replace `old_key`'s block with `new_block` at `new_block`'s byte-sorted +/// position inside a top-level section, preserving the blank-line-separated +/// shape pnpm emits (a rekey can MOVE across the sort boundary — `/`-keys +/// sort before `file:`-keys). pnpm sorts package keys with the `sort-keys` +/// default compare (JS code-unit order), which plain Rust `str` ordering +/// matches for these ASCII keys. +fn swap_block_sorted( + lines: &mut Vec, + section: &str, + old_key: &str, + new_key: &str, + new_block: &[String], +) -> Result<(), String> { + let (start, end) = section_bounds(lines, section).ok_or("section vanished mid-rewrite")?; + // Collect the section's blocks in order. + let mut blocks: Vec<(String, Vec)> = Vec::new(); + let mut last_block_end = start + 1; + let mut i = start + 1; + while let Some(block) = next_block(lines, i, end) { + blocks.push((block.key.clone(), lines[block.header..block.end].to_vec())); + last_block_end = block.end; + i = block.end; + } + // Whatever trails the final block (the file's trailing blank when the + // section is last) is preserved verbatim. + let trailer: Vec = lines[last_block_end..end].to_vec(); + + let pos = blocks + .iter() + .position(|(k, _)| k == old_key) + .ok_or_else(|| format!("{section} entry `{old_key}` vanished mid-rewrite"))?; + blocks.remove(pos); + let insert_at = blocks + .iter() + .position(|(k, _)| k.as_str() > new_key) + .unwrap_or(blocks.len()); + blocks.insert(insert_at, (new_key.to_string(), new_block.to_vec())); + + let mut rebuilt: Vec = Vec::with_capacity(end - start); + for (_, block_lines) in &blocks { + rebuilt.push(String::new()); + rebuilt.extend(block_lines.iter().cloned()); + } + rebuilt.extend(trailer); + lines.splice(start + 1..end, rebuilt); + Ok(()) +} + +// ─────────────────────────────── revert ─────────────────────────────────── + +/// Undo one legacy-vendored package: restore the recorded pair fragments +/// and remove the artifact dir. Reverse application order; per-record +/// ownership re-checked against the live fragment (drift ⇒ warning, left +/// alone) — same discipline as [`super::pnpm_lock::revert_pnpm`]. +pub async fn revert_pnpm_legacy( + entry: &VendorEntry, + project_root: &Path, + dry_run: bool, +) -> RevertOutcome { + let uuid_dir_rel = match guard_revert_uuid_dir(&entry.uuid) { + Ok(d) => d, + Err(outcome) => return outcome, + }; + // Nothing to replay (a `repair`-reconstructed entry): refuse the + // artifact removal while the legacy lock still resolves through it — + // fail-closed, before the dry-run return, exactly like the v9 backend + // (see [`super::pnpm_lock::guard_unwired_revert`]). + if entry.wiring.is_empty() { + let in_use = pnpm_legacy_entry_in_use(entry, project_root).await; + if let Some(blocked) = guard_unwired_revert(project_root, in_use, &uuid_dir_rel).await { + return blocked; + } + } + if dry_run { + return RevertOutcome::ok(); + } + let mut outcome = RevertOutcome::ok(); + + let mut touches_pkg = false; + let mut touches_lock = false; + for rec in &entry.wiring { + if !REVERT_ALLOWLIST.contains(&rec.file.as_str()) { + outcome.warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!( + "ignoring wiring record for non-allowlisted file `{}`", + rec.file + ), + )); + continue; + } + if rec.file == PACKAGE_JSON { + touches_pkg = true; + } else { + touches_lock = true; + } + } + + let mut lock_lines: Option> = None; + if touches_lock { + match tokio::fs::read_to_string(project_root.join(PNPM_LOCK)).await { + Ok(text) => lock_lines = Some(split_lines(&text)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + outcome.warnings.push(VendorWarning::new( + "vendor_lockfile_missing", + format!("{PNPM_LOCK} is missing; lock fragments cannot be restored"), + )); + } + Err(e) => return RevertOutcome::failed(format!("cannot read {PNPM_LOCK}: {e}")), + } + } + let mut pkg_state: Option<(Value, String)> = None; + if touches_pkg { + match tokio::fs::read(project_root.join(PACKAGE_JSON)).await { + Ok(bytes) => match serde_json::from_slice::(&bytes) { + Ok(doc) if doc.is_object() => { + let indent = detect_indent(&String::from_utf8_lossy(&bytes)); + pkg_state = Some((doc, indent)); + } + _ => { + return RevertOutcome::failed(format!( + "{PACKAGE_JSON} is not a JSON object; fix it and re-run revert" + )) + } + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + outcome.warnings.push(VendorWarning::new( + "vendor_lockfile_missing", + format!("{PACKAGE_JSON} is missing; the pnpm override cannot be removed"), + )); + } + Err(e) => return RevertOutcome::failed(format!("cannot read {PACKAGE_JSON}: {e}")), + } + } + + let mut lock_dirty = false; + let mut pkg_dirty = false; + for rec in entry.wiring.iter().rev() { + match rec.file.as_str() { + PNPM_LOCK => { + if let Some(lines) = lock_lines.as_mut() { + revert_lock_record( + lines, + rec, + &entry.uuid, + &mut lock_dirty, + &mut outcome.warnings, + ); + } + } + PACKAGE_JSON => { + if let Some((doc, _)) = pkg_state.as_mut() { + revert_pkg_record(doc, rec, &entry.uuid, &mut pkg_dirty, &mut outcome.warnings); + } + } + _ => {} // warned above + } + } + + // Remove the now-empty tables iff vendor created them. + if let Some((doc, _)) = pkg_state.as_mut() { + let (created_overrides, created_pnpm) = match &entry.pnpm { + Some(meta) => (meta.created_overrides_table, meta.created_pnpm_table), + None => (false, false), + }; + if let Some(obj) = doc.as_object_mut() { + if let Some(pnpm_tbl) = obj.get_mut("pnpm").and_then(Value::as_object_mut) { + if created_overrides + && pnpm_tbl + .get("overrides") + .and_then(Value::as_object) + .is_some_and(serde_json::Map::is_empty) + { + pnpm_tbl.shift_remove("overrides"); + pkg_dirty = true; + } + } + if created_pnpm + && obj + .get("pnpm") + .and_then(Value::as_object) + .is_some_and(serde_json::Map::is_empty) + { + obj.shift_remove("pnpm"); + pkg_dirty = true; + } + } + } + + // Reverse write order: lock first, package.json second. + if lock_dirty { + if let Some(lines) = &lock_lines { + if let Err(e) = atomic_write_bytes_preserving_mode( + &project_root.join(PNPM_LOCK), + lines.join("\n").as_bytes(), + ) + .await + { + return RevertOutcome::failed(format!("cannot write {PNPM_LOCK}: {e}")); + } + } + } + if pkg_dirty { + if let Some((doc, indent)) = &pkg_state { + let bytes = match serialize_json(doc, indent) { + Ok(b) => b, + Err(e) => { + return RevertOutcome::failed(format!("cannot serialize {PACKAGE_JSON}: {e}")) + } + }; + if let Err(e) = + atomic_write_bytes_preserving_mode(&project_root.join(PACKAGE_JSON), &bytes).await + { + return RevertOutcome::failed(format!("cannot write {PACKAGE_JSON}: {e}")); + } + } + } + + if let Err(e) = remove_tree(&project_root.join(&uuid_dir_rel)).await { + return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); + } + outcome +} + +fn revert_lock_record( + lines: &mut Vec, + rec: &WiringRecord, + entry_uuid: &str, + dirty: &mut bool, + warnings: &mut Vec, +) { + let Some(key) = rec.key.as_deref() else { + warnings.push(drifted(format!( + "wiring record in {PNPM_LOCK} has no key; left alone" + ))); + return; + }; + match rec.kind.as_str() { + KIND_LOCK_OVERRIDES => revert_overrides_line(lines, rec, key, entry_uuid, dirty, warnings), + KIND_LOCK_SPECIFIER => { + revert_value_line(lines, rec, "specifiers", key, entry_uuid, dirty, warnings) + } + KIND_LOCK_ROOT_DEP => match key.rsplit_once('|') { + Some((section, dep)) => { + revert_value_line(lines, rec, section, dep, entry_uuid, dirty, warnings) + } + None => warnings.push(drifted(format!( + "malformed root-dep key `{key}`; left alone" + ))), + }, + KIND_LOCK_ROOT_DEP_PAIR => { + revert_root_dep_pair(lines, rec, key, entry_uuid, dirty, warnings) + } + KIND_LOCK_PACKAGE => revert_package_block(lines, rec, key, entry_uuid, dirty, warnings), + KIND_LOCK_PKG_DEP_REF => revert_pkg_dep_ref(lines, rec, key, entry_uuid, dirty, warnings), + other => warnings.push(drifted(format!( + "unknown wiring kind `{other}` for `{key}`; left alone" + ))), + } +} + +/// Restore one flat `key: value` line (v5.4 specifiers / root dep maps) to +/// its recorded original. Fail-closed on drift. +fn revert_value_line( + lines: &mut [String], + rec: &WiringRecord, + section: &str, + dep: &str, + entry_uuid: &str, + dirty: &mut bool, + warnings: &mut Vec, +) { + let Some((start, end)) = section_bounds(lines, section) else { + warnings.push(drifted(format!( + "{section} section is gone; `{dep}` not restored" + ))); + return; + }; + for line in lines.iter_mut().take(end).skip(start + 1) { + let Some((k, repr, rest)) = parse_key_line(line, 2) else { + continue; + }; + if k != dep { + continue; + } + let ours = Some(rest.as_str()) == rec.new.as_ref().and_then(Value::as_str) + || parse_vendor_path(&rest).is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); + if !ours { + warnings.push(drifted(format!( + "{section} entry `{dep}` was changed since vendoring ({rest}); left alone" + ))); + return; + } + let Some(orig) = rec.original.as_ref().and_then(Value::as_str) else { + warnings.push(drifted(format!( + "{section} entry `{dep}` has no recorded pre-vendor original; left as-is \ + (re-run `pnpm install` to re-resolve it)" + ))); + return; + }; + *line = format!(" {}: {orig}", yaml_key_like(dep, &repr)); + *dirty = true; + return; + } + warnings.push(drifted(format!( + "{section} entry `{dep}` no longer exists; nothing to restore" + ))); +} + +/// Restore a v6.0 root dep's `specifier:`/`version:` pair. +fn revert_root_dep_pair( + lines: &mut [String], + rec: &WiringRecord, + key: &str, + entry_uuid: &str, + dirty: &mut bool, + warnings: &mut Vec, +) { + let Some((section, dep)) = key.rsplit_once('|') else { + warnings.push(drifted(format!( + "malformed root-dep key `{key}`; left alone" + ))); + return; + }; + let Some((start, end)) = section_bounds(lines, section) else { + warnings.push(drifted(format!( + "{section} section is gone; `{dep}` not restored" + ))); + return; + }; + let mut k = start + 1; + while k < end { + let Some((d, _repr, rest)) = parse_key_line(&lines[k], 2) else { + k += 1; + continue; + }; + if d != dep || !rest.is_empty() { + k += 1; + continue; + } + let (spec_f, ver_f, _) = dep_field_lines(lines, k + 1, end, 4); + let (Some((si, _)), Some((vi, live_ver))) = (spec_f, ver_f) else { + break; + }; + let new_ver = rec + .new + .as_ref() + .and_then(|n| n.get("version")) + .and_then(Value::as_str); + let ours = Some(live_ver.as_str()) == new_ver + || parse_vendor_path(&live_ver).is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); + if !ours { + warnings.push(drifted(format!( + "root dep `{key}` was re-resolved since vendoring ({live_ver}); left alone" + ))); + return; + } + let Some(original) = rec.original.as_ref() else { + warnings.push(drifted(format!( + "root dep `{key}` has no recorded pre-vendor original; left as-is \ + (re-run `pnpm install` to re-resolve it)" + ))); + return; + }; + let (Some(orig_spec), Some(orig_ver)) = ( + original.get("specifier").and_then(Value::as_str), + original.get("version").and_then(Value::as_str), + ) else { + warnings.push(drifted(format!("root dep `{key}` original is malformed"))); + return; + }; + lines[si] = format!(" specifier: {orig_spec}"); + lines[vi] = format!(" version: {orig_ver}"); + *dirty = true; + return; + } + warnings.push(drifted(format!( + "root dep `{key}` no longer exists; nothing to restore" + ))); +} + +/// Restore the rekeyed packages block: locate it by the NEW `file:` key, +/// verify ownership, then reinsert the ORIGINAL block at its byte-sorted +/// position (the rekey moved it across the `/` vs `file:` sort boundary, so +/// an in-place splice would restore it out of order and break byte-identity +/// with the pre-vendor lock). +fn revert_package_block( + lines: &mut Vec, + rec: &WiringRecord, + key: &str, + entry_uuid: &str, + dirty: &mut bool, + warnings: &mut Vec, +) { + let Some(new_lines) = rec.new.as_ref().and_then(value_lines) else { + warnings.push(drifted(format!( + "record for `{key}` has no `new` fragment; left alone" + ))); + return; + }; + let Some((new_key, _repr, _rest)) = new_lines.first().and_then(|l| parse_key_line(l, 2)) else { + warnings.push(drifted(format!( + "record for `{key}` has a malformed fragment" + ))); + return; + }; + let Some((start, end)) = section_bounds(lines, "packages") else { + warnings.push(drifted(format!( + "packages section is gone; `{key}` not restored" + ))); + return; + }; + let mut i = start + 1; + while let Some(block) = next_block(lines, i, end) { + if block.key != new_key { + i = block.end; + continue; + } + let live: Vec = lines[block.header..block.end].to_vec(); + let key_is_ours = + parse_vendor_path(&new_key).is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); + if live != new_lines && !key_is_ours { + warnings.push(drifted(format!( + "packages entry `{new_key}` was changed since vendoring; left alone" + ))); + return; + } + let Some(original) = rec.original.as_ref().and_then(value_lines) else { + warnings.push(drifted(format!( + "packages entry `{key}` has no recorded pre-vendor original; left as-is \ + (re-run `pnpm install` to re-resolve it)" + ))); + return; + }; + let Some((orig_key, _r, _v)) = original.first().and_then(|l| parse_key_line(l, 2)) else { + warnings.push(drifted(format!( + "packages entry `{key}` original is malformed" + ))); + return; + }; + if swap_block_sorted(lines, "packages", &new_key, &orig_key, &original).is_err() { + warnings.push(drifted(format!( + "packages entry `{new_key}` vanished mid-restore; left alone" + ))); + return; + } + *dirty = true; + return; + } + warnings.push(drifted(format!( + "packages entry `{new_key}` no longer exists; nothing to restore" + ))); +} + +fn revert_pkg_dep_ref( + lines: &mut [String], + rec: &WiringRecord, + key: &str, + entry_uuid: &str, + dirty: &mut bool, + warnings: &mut Vec, +) { + let Some((pkg_key, dep)) = key.rsplit_once('|') else { + warnings.push(drifted(format!( + "malformed dep-ref key `{key}`; left alone" + ))); + return; + }; + let Some((start, end)) = section_bounds(lines, "packages") else { + warnings.push(drifted( + "packages section is gone; nothing to restore".to_string(), + )); + return; + }; + let mut i = start + 1; + while let Some(block) = next_block(lines, i, end) { + if block.key != pkg_key { + i = block.end; + continue; + } + let mut in_dep_map = false; + for line in lines[block.header + 1..block.end].iter_mut() { + if let Some((field, _repr, rest)) = parse_key_line(line, 4) { + in_dep_map = rest.is_empty() + && matches!(field.as_str(), "dependencies" | "optionalDependencies"); + continue; + } + if !in_dep_map { + continue; + } + let Some((d, _repr, rest)) = parse_key_line(line, 6) else { + continue; + }; + if d != dep { + continue; + } + let ours = Some(rest.as_str()) == rec.new.as_ref().and_then(Value::as_str) + || parse_vendor_path(&rest).is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); + if !ours { + warnings.push(drifted(format!( + "dep ref `{key}` was re-resolved since vendoring ({rest}); left alone" + ))); + return; + } + let Some(original) = rec.original.as_ref().and_then(Value::as_str) else { + warnings.push(drifted(format!( + "dep ref `{key}` has no recorded pre-vendor original; left as-is" + ))); + return; + }; + *line = format!(" {}: {original}", yaml_key(dep)); + *dirty = true; + return; + } + break; + } + warnings.push(drifted(format!( + "dep ref `{key}` no longer exists; nothing to restore" + ))); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hash::git_sha256::compute_git_sha256_from_bytes; + use crate::manifest::schema::PatchFileInfo; + use crate::patch::apply::{ApplyResult, VerifyStatus}; + use base64::Engine as _; + use sha2::{Digest, Sha512}; + use std::collections::HashMap; + use std::path::PathBuf; + + // ── normalize_canonical_root (pure string level; no Windows host) ───── + // The synthetic inputs mirror what `std::fs::canonicalize` returns on + // Windows (verbatim paths); a real Windows CI leg should confirm pnpm + // 7/8's own emission spelling (tracked residual). + + #[test] + fn normalize_strips_windows_verbatim_drive_prefix_and_forward_slashes() { + assert_eq!( + normalize_canonical_root(r"\\?\C:\Users\dev\proj"), + "C:/Users/dev/proj" + ); + // The full specifier shape the lock embeds — never `\\?\`-prefixed, + // never backslashed. + let spec = format!( + "file:{}/{}", + normalize_canonical_root(r"\\?\C:\proj"), + ".socket/vendor/npm/uuid/left-pad-1.3.0.tgz" + ); + assert_eq!( + spec, + "file:C:/proj/.socket/vendor/npm/uuid/left-pad-1.3.0.tgz" + ); + } + + #[test] + fn normalize_strips_windows_verbatim_unc_prefix() { + assert_eq!( + normalize_canonical_root(r"\\?\UNC\srv\share\proj"), + "//srv/share/proj" + ); + } + + #[test] + fn normalize_forward_slashes_plain_windows_shapes() { + // Non-verbatim spellings (defensive: canonicalize is verbatim on + // Windows today, but the splice must never emit a backslash). + assert_eq!(normalize_canonical_root(r"C:\proj"), "C:/proj"); + assert_eq!( + normalize_canonical_root(r"\\srv\share\proj"), + "//srv/share/proj" + ); + } + + #[test] + fn normalize_leaves_unix_paths_byte_unchanged() { + assert_eq!(normalize_canonical_root("/home/dev/proj"), "/home/dev/proj"); + // A literal backslash inside a unix file name is NOT a separator + // and must survive untouched. + assert_eq!( + normalize_canonical_root(r"/home/we\ird/proj"), + r"/home/we\ird/proj" + ); + } + + /// The uuid the 2026-08-18 legacy spike vendored under (the captured + /// locks quote it verbatim). + const UUID: &str = "1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab"; + const ORIG_INDEX: &[u8] = b"module.exports = () => 'orig';\n"; + const PATCHED_INDEX: &[u8] = b"module.exports = () => 'patched';\n"; + + /// The spike tarball's integrity as the captured after-locks record it. + /// Our pack pipeline produces a DIFFERENT (deterministic) tarball, so + /// fixture comparisons substitute the actual integrity for this token — + /// everything else must be byte-identical. + const SPIKE_INTEGRITY: &str = + "sha512-pceaN98Av+E8ugNGKlqbfzvbJWVAdWx3RKI7kc7jPThP6QHZg7c2xbZhCqV8N42Jf9hKWdLW4ZNDnFHQinZ0Hw=="; + + /// The absolute project root the captured locks embedded (pnpm <= 8 + /// absolutizes the override specifier). Fixtures carry this token; the + /// tests substitute the tempdir's canonical path. + const ROOT_TOKEN: &str = "__PROJECT_ROOT__"; + + // ── tool-generated byte-exact oracles ───────────────────────────────── + // Provenance: matrix/vendor-legacy-spike/{t7,t8} — a `file:` tarball + // pnpm.overrides entry added to the fixture below, then serialized by + // REAL `corepack pnpm@7.33.5` / `pnpm@8.15.9` installs (2026-08-18) and + // proven byte-stable across an install re-run. Only the machine path + // and the tarball integrity are tokenized. + const T_BEFORE_PKG: &str = r#"{ + "name": "legacy-spike2", + "version": "0.0.0", + "private": true, + "dependencies": { + "consumer": "file:./consumer", + "left-pad": "1.3.0", + "left-pad-old": "npm:left-pad@1.2.0" + } +} +"#; + const T_AFTER_PKG: &str = r#"{ + "name": "legacy-spike2", + "version": "0.0.0", + "private": true, + "dependencies": { + "consumer": "file:./consumer", + "left-pad": "1.3.0", + "left-pad-old": "npm:left-pad@1.2.0" + }, + "pnpm": { + "overrides": { + "left-pad@1.3.0": "file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz" + } + } +} +"#; + const T7_BEFORE_LOCK: &str = "lockfileVersion: 5.4 + +specifiers: + consumer: file:./consumer + left-pad: 1.3.0 + left-pad-old: npm:left-pad@1.2.0 + +dependencies: + consumer: file:consumer + left-pad: 1.3.0 + left-pad-old: /left-pad/1.2.0 + +packages: + + /left-pad/1.2.0: + resolution: {integrity: sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg==} + deprecated: use String.prototype.padStart() + dev: false + + /left-pad/1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + deprecated: use String.prototype.padStart() + dev: false + + file:consumer: + resolution: {directory: consumer, type: directory} + name: consumer + version: 1.0.0 + dependencies: + left-pad: 1.3.0 + dev: false +"; + const T7_AFTER_LOCK: &str = "lockfileVersion: 5.4 + +overrides: + left-pad@1.3.0: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + +specifiers: + consumer: file:./consumer + left-pad: file:__PROJECT_ROOT__/.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + left-pad-old: npm:left-pad@1.2.0 + +dependencies: + consumer: file:consumer + left-pad: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + left-pad-old: /left-pad/1.2.0 + +packages: + + /left-pad/1.2.0: + resolution: {integrity: sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg==} + deprecated: use String.prototype.padStart() + dev: false + + file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz: + resolution: {integrity: sha512-pceaN98Av+E8ugNGKlqbfzvbJWVAdWx3RKI7kc7jPThP6QHZg7c2xbZhCqV8N42Jf9hKWdLW4ZNDnFHQinZ0Hw==, tarball: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz} + name: left-pad + version: 1.3.0 + dev: false + + file:consumer: + resolution: {directory: consumer, type: directory} + name: consumer + version: 1.0.0 + dependencies: + left-pad: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + dev: false +"; + const T8_BEFORE_LOCK: &str = "lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +dependencies: + consumer: + specifier: file:./consumer + version: file:consumer + left-pad: + specifier: 1.3.0 + version: 1.3.0 + left-pad-old: + specifier: npm:left-pad@1.2.0 + version: /left-pad@1.2.0 + +packages: + + /left-pad@1.2.0: + resolution: {integrity: sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg==} + deprecated: use String.prototype.padStart() + dev: false + + /left-pad@1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + deprecated: use String.prototype.padStart() + dev: false + + file:consumer: + resolution: {directory: consumer, type: directory} + name: consumer + dependencies: + left-pad: 1.3.0 + dev: false +"; + const T8_AFTER_LOCK: &str = "lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + left-pad@1.3.0: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + +dependencies: + consumer: + specifier: file:./consumer + version: file:consumer + left-pad: + specifier: file:__PROJECT_ROOT__/.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + version: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + left-pad-old: + specifier: npm:left-pad@1.2.0 + version: /left-pad@1.2.0 + +packages: + + /left-pad@1.2.0: + resolution: {integrity: sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg==} + deprecated: use String.prototype.padStart() + dev: false + + file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz: + resolution: {integrity: sha512-pceaN98Av+E8ugNGKlqbfzvbJWVAdWx3RKI7kc7jPThP6QHZg7c2xbZhCqV8N42Jf9hKWdLW4ZNDnFHQinZ0Hw==, tarball: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz} + name: left-pad + version: 1.3.0 + dev: false + + file:consumer: + resolution: {directory: consumer, type: directory} + name: consumer + dependencies: + left-pad: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + dev: false +"; + + // Provenance: matrix/vendor-legacy-spike/x7 — the transitive-ONLY shape + // (root depends on `consumer` only): pnpm rekeys the packages entry and + // the consumer's dep ref but touches NO root section — no absolute path + // appears anywhere. + const X7_BEFORE_PKG: &str = r#"{ + "name": "legacy-spike3", + "version": "0.0.0", + "private": true, + "dependencies": { + "consumer": "file:./consumer" + } +} +"#; + const X7_BEFORE_LOCK: &str = "lockfileVersion: 5.4 + +specifiers: + consumer: file:./consumer + +dependencies: + consumer: file:consumer + +packages: + + /left-pad/1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + deprecated: use String.prototype.padStart() + dev: false + + file:consumer: + resolution: {directory: consumer, type: directory} + name: consumer + version: 1.0.0 + dependencies: + left-pad: 1.3.0 + dev: false +"; + const X7_AFTER_LOCK: &str = "lockfileVersion: 5.4 + +overrides: + left-pad@1.3.0: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + +specifiers: + consumer: file:./consumer + +dependencies: + consumer: file:consumer + +packages: + + file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz: + resolution: {integrity: sha512-pceaN98Av+E8ugNGKlqbfzvbJWVAdWx3RKI7kc7jPThP6QHZg7c2xbZhCqV8N42Jf9hKWdLW4ZNDnFHQinZ0Hw==, tarball: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz} + name: left-pad + version: 1.3.0 + dev: false + + file:consumer: + resolution: {directory: consumer, type: directory} + name: consumer + version: 1.0.0 + dependencies: + left-pad: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + dev: false +"; + + struct Fixture { + tmp: tempfile::TempDir, + record: PatchRecord, + } + + impl Fixture { + fn root(&self) -> &Path { + self.tmp.path() + } + + /// The canonical root — what the backend embeds in the absolute + /// specifier (macOS tempdirs are symlinks; pnpm's process.cwd() and + /// our canonicalize agree on the physical path). + fn canon_root(&self) -> PathBuf { + std::fs::canonicalize(self.root()).unwrap() + } + + /// The root as the BACKEND spells it into locks — the raw + /// `canonicalize().display()` form differs on Windows (`\\?\C:\...`), + /// so every oracle/fixture must go through the same normalizer the + /// splice uses or the byte-exact asserts diverge there. + fn canon_root_str(&self) -> String { + normalize_canonical_root(&self.canon_root().display().to_string()) + } + + fn installed(&self) -> PathBuf { + self.root().join("node_modules/left-pad") + } + + fn rel_tgz(&self) -> String { + format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz") + } + + async fn read(&self, name: &str) -> String { + tokio::fs::read_to_string(self.root().join(name)) + .await + .unwrap() + } + + /// The actual SRI of the tarball our pack produced. + async fn actual_integrity(&self) -> String { + let tgz = tokio::fs::read(self.root().join(self.rel_tgz())) + .await + .unwrap(); + format!( + "sha512-{}", + base64::engine::general_purpose::STANDARD.encode(Sha512::digest(&tgz)) + ) + } + + /// Instantiate a captured after-lock for THIS tempdir: the spike's + /// integrity and absolute-root tokens swapped for the live values. + async fn expected_lock(&self, fixture: &str) -> String { + fixture + .replace(SPIKE_INTEGRITY, &self.actual_integrity().await) + .replace(ROOT_TOKEN, &self.canon_root_str()) + } + + async fn vendor(&self, dry_run: bool) -> VendorOutcome { + let blobs = self.root().join(".socket/blobs"); + let sources = PatchSources::blobs_only(&blobs); + vendor_pnpm_legacy( + "pkg:npm/left-pad@1.3.0", + &self.installed(), + self.root(), + &self.record, + &sources, + "2026-08-18T00:00:00Z", + dry_run, + false, + None, + ) + .await + } + } + + async fn fixture_with(pkg_json: &str, lock: &str) -> Fixture { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + let installed = root.join("node_modules/left-pad"); + tokio::fs::create_dir_all(&installed).await.unwrap(); + tokio::fs::write( + installed.join("package.json"), + br#"{"name":"left-pad","version":"1.3.0"}"#, + ) + .await + .unwrap(); + tokio::fs::write(installed.join("index.js"), ORIG_INDEX) + .await + .unwrap(); + + let blobs = root.join(".socket/blobs"); + tokio::fs::create_dir_all(&blobs).await.unwrap(); + let after_hash = compute_git_sha256_from_bytes(PATCHED_INDEX); + tokio::fs::write(blobs.join(&after_hash), PATCHED_INDEX) + .await + .unwrap(); + + tokio::fs::write(root.join(PACKAGE_JSON), pkg_json) + .await + .unwrap(); + tokio::fs::write(root.join(PNPM_LOCK), lock).await.unwrap(); + + let mut files = HashMap::new(); + files.insert( + "package/index.js".to_string(), + PatchFileInfo { + before_hash: compute_git_sha256_from_bytes(ORIG_INDEX), + after_hash, + }, + ); + let record = PatchRecord { + uuid: UUID.to_string(), + exported_at: "2026-08-01T00:00:00Z".to_string(), + files, + vulnerabilities: HashMap::new(), + description: "test patch".to_string(), + license: "MIT".to_string(), + tier: "free".to_string(), + }; + Fixture { tmp, record } + } + + fn expect_done( + outcome: VendorOutcome, + ) -> (ApplyResult, Option, Vec) { + match outcome { + VendorOutcome::Done { + result, + entry, + warnings, + } => (result, entry, warnings), + VendorOutcome::Refused { code, detail } => { + panic!("expected Done, got Refused {code}: {detail}") + } + } + } + + fn expect_refused(outcome: VendorOutcome, want_code: &str) -> String { + match outcome { + VendorOutcome::Refused { code, detail } => { + assert_eq!(code, want_code, "wrong refusal code ({detail})"); + detail + } + VendorOutcome::Done { result, .. } => { + panic!( + "expected Refused {want_code}, got Done (success={})", + result.success + ) + } + } + } + + // ── oracle transforms ───────────────────────────────────────────────── + + /// pnpm 7 (5.4): the whole captured transform — overrides inserted at + /// the ROOT_KEYS_ORDER slot, specifier absolutized, root dep + the + /// consumer's dep ref moved to the relative file: spec, the packages + /// entry rekeyed ACROSS the `/` vs `file:` sort boundary — byte-identical + /// to what pnpm 7.33.5 itself serialized. + #[tokio::test] + async fn v54_oracle_transform_is_byte_identical_for_both_files() { + let fx = fixture_with(T_BEFORE_PKG, T7_BEFORE_LOCK).await; + let (result, entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.expect("success carries a ledger entry"); + + assert_eq!(fx.read(PACKAGE_JSON).await, T_AFTER_PKG); + assert_eq!( + fx.read(PNPM_LOCK).await, + fx.expected_lock(T7_AFTER_LOCK).await + ); + + // Ledger facts: flavor + meta (NO workspace surface for legacy). + assert_eq!(entry.flavor.as_deref(), Some("pnpm-legacy")); + assert_eq!( + entry.pnpm, + Some(PnpmMeta { + created_overrides_table: true, + created_pnpm_table: true, + created_workspace_file: false, + created_workspace_overrides: false, + }) + ); + assert_eq!(entry.artifact.path, fx.rel_tgz()); + let kinds: Vec<&str> = entry.wiring.iter().map(|r| r.kind.as_str()).collect(); + assert_eq!( + kinds, + vec![ + "pnpm_pkg_override", + KIND_LOCK_OVERRIDES, + KIND_LOCK_ROOT_DEP, + KIND_LOCK_SPECIFIER, + KIND_LOCK_PACKAGE, + KIND_LOCK_PKG_DEP_REF, + ], + "{:?}", + entry.wiring + ); + // The consumer's dep ref is keyed pkg|dep with the bare-version + // original recorded for revert. + let dep_ref = entry + .wiring + .iter() + .find(|r| r.kind == KIND_LOCK_PKG_DEP_REF) + .unwrap(); + assert_eq!(dep_ref.key.as_deref(), Some("file:consumer|left-pad")); + assert_eq!(dep_ref.original, Some(Value::String("1.3.0".into()))); + + // The absolute-specifier portability caveat is surfaced. + assert!( + warnings + .iter() + .any(|w| w.code == "vendor_pnpm_legacy_absolute_specifier"), + "{warnings:?}" + ); + // …and no workspace file appeared. + assert!(!fx.root().join("pnpm-workspace.yaml").exists()); + } + + /// pnpm 8 (6.0): same transform through the nested specifier/version + /// grammar — byte-identical to what pnpm 8.15.9 itself serialized. + #[tokio::test] + async fn v60_oracle_transform_is_byte_identical_for_both_files() { + let fx = fixture_with(T_BEFORE_PKG, T8_BEFORE_LOCK).await; + let (result, entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.expect("success carries a ledger entry"); + + assert_eq!(fx.read(PACKAGE_JSON).await, T_AFTER_PKG); + assert_eq!( + fx.read(PNPM_LOCK).await, + fx.expected_lock(T8_AFTER_LOCK).await + ); + + assert_eq!(entry.flavor.as_deref(), Some("pnpm-legacy")); + let kinds: Vec<&str> = entry.wiring.iter().map(|r| r.kind.as_str()).collect(); + assert_eq!( + kinds, + vec![ + "pnpm_pkg_override", + KIND_LOCK_OVERRIDES, + KIND_LOCK_ROOT_DEP_PAIR, + KIND_LOCK_PACKAGE, + KIND_LOCK_PKG_DEP_REF, + ], + "{:?}", + entry.wiring + ); + let pair = entry + .wiring + .iter() + .find(|r| r.kind == KIND_LOCK_ROOT_DEP_PAIR) + .unwrap(); + assert_eq!(pair.key.as_deref(), Some("dependencies|left-pad")); + assert_eq!( + pair.original, + Some(serde_json::json!({"specifier": "1.3.0", "version": "1.3.0"})) + ); + assert!( + warnings + .iter() + .any(|w| w.code == "vendor_pnpm_legacy_absolute_specifier"), + "{warnings:?}" + ); + assert!(!fx.root().join("pnpm-workspace.yaml").exists()); + } + + /// The transitive-ONLY capture (x7): no root section mentions the + /// package, so nothing absolute is written and no portability warning + /// fires — overrides + rekeyed packages entry + the consumer's dep ref + /// only, byte-identical to pnpm 7.33.5's own serialization. + #[tokio::test] + async fn v54_transitive_only_writes_no_absolute_specifier() { + let fx = fixture_with(X7_BEFORE_PKG, X7_BEFORE_LOCK).await; + let (result, entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.expect("entry"); + + let lock_after = fx.read(PNPM_LOCK).await; + assert_eq!(lock_after, fx.expected_lock(X7_AFTER_LOCK).await); + assert!( + !lock_after.contains(&fx.canon_root_str()), + "no machine path may leak into a transitive-only wiring:\n{lock_after}" + ); + assert!( + !warnings + .iter() + .any(|w| w.code == "vendor_pnpm_legacy_absolute_specifier"), + "{warnings:?}" + ); + let kinds: Vec<&str> = entry.wiring.iter().map(|r| r.kind.as_str()).collect(); + assert_eq!( + kinds, + vec![ + "pnpm_pkg_override", + KIND_LOCK_OVERRIDES, + KIND_LOCK_PACKAGE, + KIND_LOCK_PKG_DEP_REF, + ], + "{:?}", + entry.wiring + ); + } + + // ── idempotency + revert ────────────────────────────────────────────── + + /// A second vendor over an in-sync legacy wiring is AlreadyPatched: no + /// new ledger entry, every byte stable — for BOTH grammars. + #[tokio::test] + async fn rerun_is_already_patched_and_byte_stable() { + for (before_lock, tag) in [(T7_BEFORE_LOCK, "5.4"), (T8_BEFORE_LOCK, "6.0")] { + let fx = fixture_with(T_BEFORE_PKG, before_lock).await; + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{tag}: {:?}", result.error); + assert!(entry.is_some(), "{tag}"); + let pkg_after = fx.read(PACKAGE_JSON).await; + let lock_after = fx.read(PNPM_LOCK).await; + + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{tag}: {:?}", result.error); + assert!(entry.is_none(), "{tag}: in-sync rerun records nothing"); + assert!( + result + .files_verified + .iter() + .all(|v| v.status == VerifyStatus::AlreadyPatched), + "{tag}" + ); + assert_eq!( + fx.read(PACKAGE_JSON).await, + pkg_after, + "{tag}: bytes stable" + ); + assert_eq!(fx.read(PNPM_LOCK).await, lock_after, "{tag}: bytes stable"); + } + } + + /// Revert restores BOTH files byte-identical (the packages block moves + /// back across the sort boundary to its original slot) and removes the + /// artifact dir — for BOTH grammars. + #[tokio::test] + async fn revert_round_trips_both_files_and_removes_the_artifact() { + for (before_lock, tag) in [(T7_BEFORE_LOCK, "5.4"), (T8_BEFORE_LOCK, "6.0")] { + let fx = fixture_with(T_BEFORE_PKG, before_lock).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + let tgz_path = fx.root().join(fx.rel_tgz()); + assert!(tgz_path.exists(), "{tag}"); + + // Dry-run revert touches nothing. + let outcome = revert_pnpm_legacy(&entry, fx.root(), true).await; + assert!(outcome.success, "{tag}"); + assert!(tgz_path.exists(), "{tag}"); + assert_ne!(fx.read(PNPM_LOCK).await, before_lock, "{tag}"); + + let outcome = revert_pnpm_legacy(&entry, fx.root(), false).await; + assert!(outcome.success, "{tag}: {:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{tag}: {:?}", outcome.warnings); + assert_eq!( + fx.read(PACKAGE_JSON).await, + T_BEFORE_PKG, + "{tag}: package.json byte-restored" + ); + assert_eq!( + fx.read(PNPM_LOCK).await, + before_lock, + "{tag}: lock byte-restored" + ); + assert!(!tgz_path.exists(), "{tag}"); + assert!( + !fx.root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "{tag}" + ); + } + } + + // ── empty-wiring (reconstructed) revert guard ───────────────────────── + + /// Same P1 regression guard as the v9 backend: a `repair`-reconstructed + /// entry (empty wiring — the legacy fragments are just as + /// offline-unrecoverable) must not have its artifact deleted while the + /// legacy lock still resolves through it; a provably orphaned artifact + /// still gets removed. Both grammars. + #[tokio::test] + async fn empty_wiring_revert_refuses_then_removes_orphan_both_grammars() { + for (before_lock, tag) in [(T7_BEFORE_LOCK, "5.4"), (T8_BEFORE_LOCK, "6.0")] { + let fx = fixture_with(T_BEFORE_PKG, before_lock).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + entry.wiring.clear(); + entry.pnpm = None; + let tgz_path = fx.root().join(fx.rel_tgz()); + let lock_wired = fx.read(PNPM_LOCK).await; + + // Wired lock (dry and wet): refuse, artifact + lock untouched. + for dry_run in [true, false] { + let outcome = revert_pnpm_legacy(&entry, fx.root(), dry_run).await; + assert!(!outcome.success, "{tag} dry_run={dry_run}: must refuse"); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_wiring_unknown_revert_blocked"), + "{tag}: {:?}", + outcome.warnings + ); + assert!(tgz_path.exists(), "{tag}: artifact survives the refusal"); + assert_eq!( + fx.read(PNPM_LOCK).await, + lock_wired, + "{tag}: lock untouched" + ); + } + + // Undeterminable grammar (a v9 re-lock): still fail-closed. + tokio::fs::write(fx.root().join(PNPM_LOCK), "lockfileVersion: '9.0'\n") + .await + .unwrap(); + let outcome = revert_pnpm_legacy(&entry, fx.root(), false).await; + assert!(!outcome.success, "{tag}: unparseable grammar must refuse"); + assert!(tgz_path.exists(), "{tag}"); + + // Pre-vendor lock restored: provably orphaned → removal proceeds. + tokio::fs::write(fx.root().join(PNPM_LOCK), before_lock) + .await + .unwrap(); + let outcome = revert_pnpm_legacy(&entry, fx.root(), false).await; + assert!(outcome.success, "{tag}: {:?}", outcome.error); + assert!( + !fx.root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "{tag}: orphaned artifact dir removed" + ); + assert_eq!( + fx.read(PNPM_LOCK).await, + before_lock, + "{tag}: empty wiring replays nothing" + ); + } + } + + // ── takeover / conflict ─────────────────────────────────────────────── + + /// A user-authored exact-version pin is TAKEN OVER on both surfaces and + /// restored verbatim on revert (v6.0 grammar; the package.json handling + /// is grammar-independent and shared with the v9 backend). + #[tokio::test] + async fn exact_pin_takeover_round_trips() { + let pkg_before = r#"{ + "name": "legacy-spike2", + "version": "0.0.0", + "private": true, + "dependencies": { + "consumer": "file:./consumer", + "left-pad": "1.3.0", + "left-pad-old": "npm:left-pad@1.2.0" + }, + "pnpm": { + "overrides": { + "left-pad": "1.3.0" + } + } +} +"#; + let fx = fixture_with(pkg_before, T8_BEFORE_LOCK).await; + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.unwrap(); + + // The USER's key carries our spec now (never a second key). + let pkg: Value = serde_json::from_str(&fx.read(PACKAGE_JSON).await).unwrap(); + assert_eq!( + pkg["pnpm"]["overrides"]["left-pad"].as_str(), + Some(format!("file:{}", fx.rel_tgz()).as_str()) + ); + assert!(pkg["pnpm"]["overrides"] + .as_object() + .unwrap() + .get("left-pad@1.3.0") + .is_none()); + // The lock's overrides section mirrors the taken-over key. + let lock = fx.read(PNPM_LOCK).await; + assert!( + lock.contains(&format!("\n left-pad: file:{}\n", fx.rel_tgz())), + "{lock}" + ); + + let outcome = revert_pnpm_legacy(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!(fx.read(PACKAGE_JSON).await, pkg_before, "pin restored"); + assert_eq!(fx.read(PNPM_LOCK).await, T8_BEFORE_LOCK); + } + + /// A same-name override that is NOT an ownable pin refuses fail-closed. + #[tokio::test] + async fn conflicting_override_refuses() { + let pkg = r#"{ + "name": "x", + "dependencies": { "left-pad": "1.3.0" }, + "pnpm": { "overrides": { "left-pad": "^1.0.0" } } +} +"#; + let fx = fixture_with(pkg, T8_BEFORE_LOCK).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_override_conflict"); + assert!(detail.contains("left-pad"), "{detail}"); + assert_eq!( + fx.read(PNPM_LOCK).await, + T8_BEFORE_LOCK, + "refusal writes nothing" + ); + } + + // ── fail-closed refusals ────────────────────────────────────────────── + + /// Both grammars' PEER-SUFFIXED packages keys and ALIASED references + /// refuse before any write (the rekey cannot follow those spellings — + /// they would dangle). + #[tokio::test] + async fn peer_suffixed_and_aliased_spellings_refuse() { + // v5.4 peer-suffixed key (`_peer` spelling). + let lock = T7_BEFORE_LOCK.replace("/left-pad/1.3.0:", "/left-pad/1.3.0_react@18.2.0:"); + let fx = fixture_with(T_BEFORE_PKG, &lock).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_lock_entry_unsupported"); + assert!(detail.contains("_react@18.2.0"), "{detail}"); + assert_eq!(fx.read(PNPM_LOCK).await, lock, "refusal writes nothing"); + + // v6.0 peer-suffixed key (`(peer)` spelling). + let lock = T8_BEFORE_LOCK.replace("/left-pad@1.3.0:", "/left-pad@1.3.0(react@18.2.0):"); + let fx = fixture_with(T_BEFORE_PKG, &lock).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_lock_entry_unsupported"); + assert!(detail.contains("(react@18.2.0)"), "{detail}"); + + // v5.4 aliased root dep resolving to the SAME version (`npm:` spec + // records the registry dep path as its value). + let lock = T7_BEFORE_LOCK.replace( + " left-pad-old: /left-pad/1.2.0", + " left-pad-old: /left-pad/1.3.0", + ); + let fx = fixture_with(T_BEFORE_PKG, &lock).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_lock_entry_unsupported"); + assert!(detail.contains("aliased"), "{detail}"); + + // v6.0 aliased root dep, nested grammar. + let lock = T8_BEFORE_LOCK.replace( + " version: /left-pad@1.2.0", + " version: /left-pad@1.3.0", + ); + let fx = fixture_with(T_BEFORE_PKG, &lock).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_lock_entry_unsupported"); + assert!(detail.contains("aliased"), "{detail}"); + } + + /// Legacy WORKSPACE locks (importers:) have no captured fixtures — + /// refuse with the pnpm >= 9 upgrade path. + #[tokio::test] + async fn legacy_workspace_lock_refuses() { + let lock = "lockfileVersion: 5.4 + +importers: + + .: + specifiers: + left-pad: 1.3.0 + dependencies: + left-pad: 1.3.0 + +packages: + + /left-pad/1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + dev: false +"; + let fx = fixture_with(T_BEFORE_PKG, lock).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_lock_entry_unsupported"); + assert!(detail.contains("WORKSPACE"), "{detail}"); + assert!(detail.contains("pnpm >= 9"), "{detail}"); + assert_eq!(fx.read(PNPM_LOCK).await, lock, "refusal writes nothing"); + } + + /// CRLF and non-allowlisted lock versions refuse with the same codes + /// and byte-untouched files as the v9 backend. + #[tokio::test] + async fn crlf_and_foreign_versions_refuse() { + let crlf = T7_BEFORE_LOCK.replace('\n', "\r\n"); + let fx = fixture_with(T_BEFORE_PKG, &crlf).await; + expect_refused(fx.vendor(false).await, "vendor_lockfile_crlf_unsupported"); + assert_eq!(fx.read(PNPM_LOCK).await, crlf, "refusal writes nothing"); + + // pnpm 6's 5.3 is NOT allowlisted. + let old = T7_BEFORE_LOCK.replace("lockfileVersion: 5.4", "lockfileVersion: 5.3"); + let fx = fixture_with(T_BEFORE_PKG, &old).await; + let detail = expect_refused( + fx.vendor(false).await, + "vendor_lockfile_version_unsupported", + ); + assert!(detail.contains("5.3"), "{detail}"); + + // A 9.0 lock reaching the legacy backend directly is a router bug — + // still refused, never mis-spliced. + let fx = fixture_with( + T_BEFORE_PKG, + "lockfileVersion: '9.0'\n\nimporters:\n\n .: {}\n", + ) + .await; + expect_refused( + fx.vendor(false).await, + "vendor_lockfile_version_unsupported", + ); + } + + /// No packages entry for the target → the not-found refusal, nothing + /// written. + #[tokio::test] + async fn missing_lock_entry_refuses() { + let lock = "lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +dependencies: + ms: + specifier: 2.1.3 + version: 2.1.3 + +packages: + + /ms@2.1.3: + resolution: {integrity: sha512-abc==} + dev: false +"; + let fx = fixture_with(T_BEFORE_PKG, lock).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_lock_entry_not_found"); + assert!(detail.contains("left-pad@1.3.0"), "{detail}"); + assert!( + !fx.root().join(".socket/vendor").exists(), + "refusals write nothing" + ); + } + + // ── moved checkout / stale absolute specifier ───────────────────────── + + /// Re-vendoring a project whose lock still carries ANOTHER machine's + /// absolute specifier (a moved checkout) heals just that line to the + /// current root — the stale path parses as ours through the + /// `.socket/vendor/` anchor. + #[tokio::test] + async fn moved_checkout_revendor_heals_the_absolute_specifier() { + let fx = fixture_with(T_BEFORE_PKG, T7_BEFORE_LOCK).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + assert!(entry.is_some()); + + // Simulate the moved checkout: swap the live absolute root for a + // foreign machine's path. + let here = fx.canon_root_str(); + let lock = fx.read(PNPM_LOCK).await; + let foreign = lock.replace(&here, "/home/other/checkout"); + assert_ne!(lock, foreign, "fixture must embed the absolute root"); + tokio::fs::write(fx.root().join(PNPM_LOCK), &foreign) + .await + .unwrap(); + + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + assert!(entry.is_some(), "the heal is a real rewrite, recorded"); + assert_eq!( + fx.read(PNPM_LOCK).await, + lock, + "only the specifier line moves back to this machine's root" + ); + } + + // ── in-use probe ────────────────────────────────────────────────────── + + #[tokio::test] + async fn entry_in_use_probe_reads_the_packages_keys() { + let fx = fixture_with(T_BEFORE_PKG, T7_BEFORE_LOCK).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + + assert_eq!( + pnpm_legacy_entry_in_use(&entry, fx.root()).await, + Some(true) + ); + + // Dep removed + re-locked: unused (the overrides declaration alone + // never counts). + let relocked = "lockfileVersion: 5.4 + +overrides: + left-pad@1.3.0: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + +specifiers: + consumer: file:./consumer + +dependencies: + consumer: file:consumer + +packages: + + file:consumer: + resolution: {directory: consumer, type: directory} + name: consumer + version: 1.0.0 + dev: false +"; + tokio::fs::write(fx.root().join(PNPM_LOCK), relocked) + .await + .unwrap(); + assert_eq!( + pnpm_legacy_entry_in_use(&entry, fx.root()).await, + Some(false) + ); + + // Unsupported grammar / missing lock: undeterminable, fail-safe. + tokio::fs::write(fx.root().join(PNPM_LOCK), "lockfileVersion: '9.0'\n") + .await + .unwrap(); + assert_eq!(pnpm_legacy_entry_in_use(&entry, fx.root()).await, None); + tokio::fs::remove_file(fx.root().join(PNPM_LOCK)) + .await + .unwrap(); + assert_eq!(pnpm_legacy_entry_in_use(&entry, fx.root()).await, None); + } +} diff --git a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs index 0e999d4b..3fa8b3ad 100644 --- a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs @@ -513,6 +513,23 @@ pub async fn revert_yarn_berry( Ok(d) => d, Err(outcome) => return outcome, }; + // Nothing to replay (a `repair`-reconstructed entry): the artifact may + // only be removed when yarn.lock provably no longer resolves through it + // — otherwise refuse, fail-closed, instead of silently bricking + // installs. Runs before the dry-run return so a preview never + // advertises a revert the wet run refuses. + if entry.wiring.is_empty() { + if let Some(blocked) = super::npm_lock::guard_unwired_textual_revert( + project_root, + &entry.uuid, + &uuid_dir_rel, + &[YARN_LOCK], + ) + .await + { + return blocked; + } + } if dry_run { return RevertOutcome::ok(); } @@ -1798,6 +1815,75 @@ __metadata: assert!(!fx.root().parent().unwrap().join("x").exists()); } + // ── empty-wiring (reconstructed) revert guard ────────────────────────── + + /// Reshape a vendored entry into what `repair`'s no-ledger + /// reconstruction persists: same uuid/artifact, EMPTY wiring. With + /// nothing to replay, revert must refuse (fail-closed) while yarn.lock + /// still resolves through the artifact — dry-run preview included — + /// still remove a genuinely orphaned artifact, fail closed on an + /// unreadable lock, and proceed when no lock exists at all. + #[tokio::test] + async fn empty_wiring_revert_guards_against_bricking_installs() { + let fx = fixture().await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + entry.wiring.clear(); + let lock_vendored = tokio::fs::read(fx.lock_path()).await.unwrap(); + + // Still referenced: refuse, artifact and lock untouched. + for dry_run in [true, false] { + let outcome = revert_yarn_berry(&entry, fx.root(), dry_run).await; + assert!(!outcome.success, "dry_run={dry_run}: must refuse"); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_wiring_unknown_revert_blocked"), + "{:?}", + outcome.warnings + ); + assert!(fx.tgz_path().exists(), "artifact survives the refusal"); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + lock_vendored, + "lock untouched" + ); + } + + // Unreadable lock (not UTF-8): undeterminable, fail closed. + tokio::fs::write(fx.lock_path(), [0xff, 0xfe, b'x']) + .await + .unwrap(); + let outcome = revert_yarn_berry(&entry, fx.root(), false).await; + assert!(!outcome.success, "unreadable-lock revert must refuse"); + assert!(fx.tgz_path().exists()); + + // Re-locked away from the artifact (provably orphaned): removal + // proceeds, replaying nothing. + tokio::fs::write(fx.lock_path(), &fx.lock_bytes) + .await + .unwrap(); + let outcome = revert_yarn_berry(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(!fx.tgz_path().exists(), "orphaned artifact removed"); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + fx.lock_bytes, + "empty wiring replays nothing" + ); + + // No lock at all: nothing can reference the artifact — proceed. + let fx = fixture().await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + entry.wiring.clear(); + tokio::fs::remove_file(fx.lock_path()).await.unwrap(); + let outcome = revert_yarn_berry(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(!fx.tgz_path().exists(), "no lock, no reference"); + } + #[tokio::test] async fn revert_refuses_tampered_uuid_fail_closed() { let fx = fixture().await; diff --git a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs index 8a631361..386df631 100644 --- a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs @@ -290,6 +290,23 @@ pub async fn revert_yarn_classic( Ok(d) => d, Err(outcome) => return outcome, }; + // Nothing to replay (a `repair`-reconstructed entry): the artifact may + // only be removed when yarn.lock provably no longer resolves through it + // — otherwise refuse, fail-closed, instead of silently bricking + // installs. Runs before the dry-run return so a preview never + // advertises a revert the wet run refuses. + if entry.wiring.is_empty() { + if let Some(blocked) = super::npm_lock::guard_unwired_textual_revert( + project_root, + &entry.uuid, + &uuid_dir_rel, + &[YARN_LOCK], + ) + .await + { + return blocked; + } + } if dry_run { return RevertOutcome::ok(); } @@ -1459,6 +1476,75 @@ left-pad@^1.3.0: assert!(!fx.root().parent().unwrap().join("x").exists()); } + // ── empty-wiring (reconstructed) revert guard ────────────────────────── + + /// Reshape a vendored entry into what `repair`'s no-ledger + /// reconstruction persists: same uuid/artifact, EMPTY wiring. With + /// nothing to replay, revert must refuse (fail-closed) while yarn.lock + /// still resolves through the artifact — dry-run preview included — + /// still remove a genuinely orphaned artifact, fail closed on an + /// unreadable lock, and proceed when no lock exists at all. + #[tokio::test] + async fn empty_wiring_revert_guards_against_bricking_installs() { + let fx = fixture_with_lock(Y2_BEFORE).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + entry.wiring.clear(); + let lock_vendored = tokio::fs::read(fx.lock_path()).await.unwrap(); + + // Still referenced: refuse, artifact and lock untouched. + for dry_run in [true, false] { + let outcome = revert_yarn_classic(&entry, fx.root(), dry_run).await; + assert!(!outcome.success, "dry_run={dry_run}: must refuse"); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_wiring_unknown_revert_blocked"), + "{:?}", + outcome.warnings + ); + assert!(fx.tgz_path().exists(), "artifact survives the refusal"); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + lock_vendored, + "lock untouched" + ); + } + + // Unreadable lock (not UTF-8): undeterminable, fail closed. + tokio::fs::write(fx.lock_path(), [0xff, 0xfe, b'x']) + .await + .unwrap(); + let outcome = revert_yarn_classic(&entry, fx.root(), false).await; + assert!(!outcome.success, "unreadable-lock revert must refuse"); + assert!(fx.tgz_path().exists()); + + // Re-locked away from the artifact (provably orphaned): removal + // proceeds, replaying nothing. + tokio::fs::write(fx.lock_path(), &fx.lock_bytes) + .await + .unwrap(); + let outcome = revert_yarn_classic(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(!fx.tgz_path().exists(), "orphaned artifact removed"); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + fx.lock_bytes, + "empty wiring replays nothing" + ); + + // No lock at all: nothing can reference the artifact — proceed. + let fx = fixture_with_lock(Y2_BEFORE).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + entry.wiring.clear(); + tokio::fs::remove_file(fx.lock_path()).await.unwrap(); + let outcome = revert_yarn_classic(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(!fx.tgz_path().exists(), "no lock, no reference"); + } + #[tokio::test] async fn revert_refuses_tampered_uuid_fail_closed() { let fx = fixture_with_lock(Y2_BEFORE).await; diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 7733e1bd..e1815631 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -14,7 +14,7 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. | Ecosystem | agent (`--mode agent`) | vendored (`--mode vendored`) | hosted (`--mode hosted`) | |-----------|------------------------|------------------------------|--------------------------| -| npm (`npm`) — pnpm / yarn / berry / bun | ✅ any install layout; `setup` postinstall hook | ✅ five lockfile flavors: package-lock, yarn classic, yarn berry (node-modules linker; PnP refused), pnpm v9, bun `bun.lock` (binary `bun.lockb` refused with a `--save-text-lockfile` pointer). Rush monorepos refused (`vendor_rush_unsupported`) — see [Rush notes](#npm-rush-monorepos) | ✅ package-lock / npm-shrinkwrap, pnpm-lock.yaml (pnpm v9), yarn classic, yarn berry, bun — pnpm, berry, and bun carry constraints, see [npm hosted-mode notes](#npm-hosted-mode-notes) | +| npm (`npm`) — pnpm / yarn / berry / bun | ✅ any install layout; `setup` postinstall hook | ✅ six lockfile flavors: package-lock, yarn classic, yarn berry (node-modules linker; PnP refused), pnpm v9, pnpm legacy v5.4/v6.0 (`pnpm 7/8` — frozen installs are path-bound because those majors absolutize `file:` override specifiers; moved checkouts run one `pnpm install --offline --no-frozen-lockfile`, surfaced as `vendor_pnpm_legacy_absolute_specifier`), bun `bun.lock` (binary `bun.lockb` refused with a `--save-text-lockfile` pointer). Rush monorepos refused (`vendor_rush_unsupported`) — see [Rush notes](#npm-rush-monorepos) | ✅ package-lock / npm-shrinkwrap, pnpm-lock.yaml (pnpm v5.4/v6.0/v9 — every major since pnpm 7), yarn classic, yarn berry, bun — pnpm, berry, and bun carry constraints, see [npm hosted-mode notes](#npm-hosted-mode-notes) | | PyPI (`pypi`) — uv / poetry / pdm / pipenv / pip | ✅ `.pth` startup hook via `setup` | ✅ five lockfile flavors: uv, poetry, pdm, pipenv (lock rewired, but pipenv doesn't hash-check file entries — `vendor_integrity_unverified` warning; the committed wheel bytes are the protection), and requirements.txt (consumed by pip or `uv pip`) | ✅ requirements.txt + uv.lock. **poetry / pdm / pipenv locks are not rewritten** — use vendored | | Cargo (`cargo`) | ✅ in-place + `.cargo-checksum.json` rewrite (shared registry-cache caveat — see [Cargo: shared registry cache](#cargo-shared-registry-cache)) | ✅ `[patch.crates-io]` path entry | ✅ per-patch sparse registry (`[registries.socket-patch-]` + Cargo.lock source/checksum) | | RubyGems (`gem`) | ✅ Bundler plugin via `setup` | ✅ Gemfile + Gemfile.lock path pair (`Gemfile` spelling only — a `gems.rb` project cannot vendor yet) | ✅ per-dep `source` block — edits `gems.rb` + `gems.locked` when present (bundler prefers them over `Gemfile`; spellings that diverge beyond Socket's own edits fail closed with `redirect_gem_gemfile_spellings_diverge`); the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning) | @@ -35,12 +35,17 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. ## npm hosted-mode notes -- **pnpm** — lockfileVersion 9 (`pnpm >=9`). Older lock grammars carry `packages:` keys - the rewrite cannot repoint — v6 embeds resolved peers in the key itself - (`/name@1.0.0(peer@2.0.0)`) and v5.x is path-style (`/name/1.0.0`). A dep that - resolves through any such key is refused outright - (`redirect_pnpm_unsupported_lock_key` names the key and the lock), never partially - rewritten: regenerate the lock with pnpm ≥ 9 and re-run. +- **pnpm** — lockfileVersion 5.4 (`pnpm 7`), 6.0 (`pnpm 8`), and 9 (`pnpm >=9`) are all + rewritten. Legacy grammars carry per-instance `packages:` keys — v6 embeds resolved + peers in the key itself (`/name@1.0.0(peer@2.0.0)`), v5.x is path-style + (`/name/1.0.0`, peer-suffixed `/name/1.0.0_peer@2.0.0`) — and the rewrite splices + EVERY instance of the dep (each key owns its own `resolution:`), recording one + revert-ledger edit per instance; a partial rewrite is never possible. On 9.0 locks + the run also configures `trustLockfile: true` in `pnpm-workspace.yaml` (created or + merged, ledger-recorded, opt out with `--no-trust-lockfile-config`) so pnpm ≥ 11's + lockfile verification accepts the repointed tarballs with no flags and no CI + changes; pnpm ≤ 10 ignores the key. Legacy 5.4/6.0 locks skip the trust config — + pnpm 7/8 have no such verification. - **yarn berry** — the redirect edits the `yarn.lock` entry only (cacheKey `10c0` / yarn 4), and `.yarnrc.yml`'s `compressionLevel` must stay 0. The node-modules linker is e2e-covered; PnP is untested for hosted — the lock rewrite fires, but PnP's From 439566404fe8a6dbf57c77087a9ba3f4bd2dd98f Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 19 Aug 2026 11:03:32 -0400 Subject: [PATCH 3/5] fix(scan): prefer live sibling locks over refused legacy pnpm debris; embedded vex advisories ride the envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback, two fixes: 1. The probe-failure discovery fallback inventoried a root pnpm-lock.yaml on vendor_lockfile_version_unsupported without looking at siblings — a pnpm→yarn/npm migration leaving an old refused lock behind surfaced DEAD pnpm resolutions as the live dependency set. The fallback now probes sibling live locks in the flavor router's own precedence (bun.lock, yarn.lock classic/berry, npm-shrinkwrap/package-lock) first; a sibling with entries wins, a present-but-empty sibling suppresses the legacy read entirely (blind beats dead resolutions), and only a genuinely lone legacy lock is inventoried as before. RED-verified: pre-fix, stale-lock-beside-live-yarn returned the dead pnpm dep. Note: supported legacy versions (5.4/6.0) route to the pnpm-legacy flavor at the probe and are governed by the router's documented pnpm-beats-yarn precedence (vendor_multiple_lockfiles warning) — this fix covers the versions the probe refuses. 2. Embedded --vex advisories (product_not_iri, vendored_tree_out_of_sync) were machine-invisible under a host's --json: note_warning silences stderr there and only the standalone vex envelope copied them out. VexSummary gains an additive warnings field (skip-if-empty; same RunWarning shape as the standalone envelope) populated by the apply, scan, and vendor hosts. Pinned by in_process_vendor::vendor_json_vex_warnings_ride_in_envelope (product advisory + the tree-sync disclosure + no-false-positive control), RED-verified. Co-Authored-By: Claude Fable 5 --- crates/socket-patch-cli/src/commands/apply.rs | 4 + .../socket-patch-cli/src/commands/scan/mod.rs | 7 + .../socket-patch-cli/src/commands/vendor.rs | 4 + crates/socket-patch-cli/src/json_envelope.rs | 28 +++ .../tests/in_process_vendor.rs | 87 +++++++ .../src/vendor/lock_inventory.rs | 230 ++++++++++++++++-- 6 files changed, 345 insertions(+), 15 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index ee3b0a2e..c4623ebf 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -768,6 +768,10 @@ pub async fn run(args: ApplyArgs) -> i32 { .to_string(), statements: summary.statements, format: "openvex-0.2.0".to_string(), + // note_warning suppressed these on stderr under + // --json; the envelope copy is their only + // surviving channel. + warnings: summary.warnings.clone(), }); } Some(Err(e)) => { diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 586839da..fc1b694b 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -305,6 +305,13 @@ async fn embed_vex_into_json( "statements": summary.statements, "format": "openvex-0.2.0", }); + // Same additive `warnings` key the envelope's `VexSummary` + // carries (skip-if-empty): note_warning suppressed these on + // stderr under --json, so this is their only surviving channel. + if !summary.warnings.is_empty() { + result["vex"]["warnings"] = serde_json::to_value(&summary.warnings) + .expect("RunWarning is a plain string struct: serialization cannot fail"); + } 0 } Err(e) => { diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 8197aade..9786319a 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -430,6 +430,10 @@ pub async fn run(args: VendorArgs) -> i32 { path: vex_path.display().to_string(), statements: summary.statements, format: "openvex-0.2.0".to_string(), + // note_warning suppressed these on stderr under + // --json; the envelope copy is their only + // surviving channel. + warnings: summary.warnings, }); } Err(e) => { diff --git a/crates/socket-patch-cli/src/json_envelope.rs b/crates/socket-patch-cli/src/json_envelope.rs index 4977837e..76b55db7 100644 --- a/crates/socket-patch-cli/src/json_envelope.rs +++ b/crates/socket-patch-cli/src/json_envelope.rs @@ -102,6 +102,16 @@ pub struct VexSummary { pub statements: usize, /// Document format tag, e.g. `"openvex-0.2.0"`. pub format: String, + /// Run-level advisories raised during VEX generation (e.g. + /// `product_not_iri`, `vendored_tree_out_of_sync`). Same [`RunWarning`] + /// shape as the top-level `warnings[]`, but scoped to the embedded VEX + /// side-effect — under `--json` stderr is silenced, so this field is + /// the only channel these advisories reach a machine consumer on. + /// Empty (and omitted from JSON) when generation had nothing to + /// advise, so existing consumers see byte-identical output (additive- + /// only envelope contract). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, } impl Envelope { @@ -760,11 +770,29 @@ mod tests { path: "/tmp/openvex.json".into(), statements: 3, format: "openvex-0.2.0".into(), + warnings: Vec::new(), }); let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap(); assert_eq!(v["vex"]["path"], "/tmp/openvex.json"); assert_eq!(v["vex"]["statements"], 3); assert_eq!(v["vex"]["format"], "openvex-0.2.0"); + // `vex.warnings` is skip-if-empty: a warning-free generation keeps + // the pre-existing three-key shape byte-identical for consumers. + assert!( + !v["vex"].as_object().unwrap().contains_key("warnings"), + "empty vex.warnings must be omitted, got {:?}", + v["vex"] + ); + + // Once generation raised advisories, they ride inside `vex` with + // the same code/detail shape as the top-level `warnings[]`. + env.vex.as_mut().unwrap().warnings.push(RunWarning { + code: "product_not_iri".into(), + detail: "product is not an IRI".into(), + }); + let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap(); + assert_eq!(v["vex"]["warnings"][0]["code"], "product_not_iri"); + assert_eq!(v["vex"]["warnings"][0]["detail"], "product is not an IRI"); } #[test] diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 6dda0a88..1efbe304 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -1608,6 +1608,93 @@ async fn dry_run_vex_is_skipped_not_generated() { assert!(!fx.vendor_dir().exists(), "no artifacts staged"); } +/// Embedded-VEX run advisories must ride the envelope's `vex.warnings` +/// under `--json`: `note_warning` silences stderr there, so without this +/// key the advisory has no channel at all. The vendor host populates +/// `VexSummary.warnings` (the scan host's raw-json arm mirrors it); a +/// clean product pins the skip-if-empty contract — no `warnings` key. +#[tokio::test] +async fn vendor_json_vex_warnings_ride_in_envelope() { + // The stock fixture manifest has no vulnerability metadata, and VEX + // refuses to attest a metadata-less patch (`no_applicable_patches`) — + // inject one so the embedded generation actually runs. + fn add_vulnerability(root: &Path) { + let manifest_path = root.join(".socket/manifest.json"); + let mut m: Value = serde_json::from_str( + &std::fs::read_to_string(&manifest_path).expect("read fixture manifest"), + ) + .expect("parse fixture manifest"); + for (_, patch) in m["patches"].as_object_mut().expect("patches map") { + patch["vulnerabilities"] = serde_json::json!({ + "GHSA-embd-warn-test": { + "cves": ["CVE-2024-77777"], + "summary": "embedded-vex warning fixture", + "severity": "high", + "description": "d", + } + }); + } + std::fs::write( + &manifest_path, + serde_json::to_string_pretty(&m).expect("serialize manifest"), + ) + .expect("write fixture manifest"); + } + + let fx = npm_fixture(); + add_vulnerability(fx.root()); + let vex_path = fx.root().join("vendored.vex.json"); + let (code, env) = vendor_cli( + fx.root(), + &[ + "--vex", + vex_path.to_str().expect("vex path is UTF-8"), + "--vex-product", + "not an iri at all", + ], + ); + assert_eq!( + code, 0, + "a non-IRI product warns, never hard-rejects: {env}" + ); + let warnings = env["vex"]["warnings"] + .as_array() + .unwrap_or_else(|| panic!("vex.warnings must be present for a non-IRI product: {env}")); + assert!( + warnings.iter().any(|w| w["code"] == "product_not_iri"), + "vendor vex.warnings must carry product_not_iri, got {warnings:?}" + ); + + // Clean-product control on a fresh fixture: no product advisory — but + // the tree-sync disclosure legitimately fires (vendor rewires the + // lockfile only; the live node_modules keeps pre-vendor bytes until a + // reinstall), which doubles as a pin that the host folds EVERY + // advisory kind in, not just product_not_iri. The skip-if-empty + // contract (no key at all on a warning-free run) is pinned by the + // apply-side control in e2e_embedded_vex.rs. + let fx2 = npm_fixture(); + add_vulnerability(fx2.root()); + let vex2 = fx2.root().join("vendored2.vex.json"); + let (code2, env2) = vendor_cli( + fx2.root(), + &["--vex", vex2.to_str().expect("vex path is UTF-8")], + ); + assert_eq!(code2, 0, "clean embedded vex run must succeed: {env2}"); + let warnings2 = env2["vex"]["warnings"] + .as_array() + .unwrap_or_else(|| panic!("out-of-sync disclosure must ride the envelope: {env2}")); + assert!( + warnings2.iter().all(|w| w["code"] != "product_not_iri"), + "auto-detected product must not warn: {warnings2:?}" + ); + assert!( + warnings2 + .iter() + .any(|w| w["code"] == "vendored_tree_out_of_sync"), + "the pre-reinstall tree must surface the sync disclosure: {warnings2:?}" + ); +} + // ───────────────────────────────────────────────────────────────────── // 12. fail-closed --vendor-source=service refuses --offline // ───────────────────────────────────────────────────────────────────── diff --git a/crates/socket-patch-core/src/vendor/lock_inventory.rs b/crates/socket-patch-core/src/vendor/lock_inventory.rs index 0b9c187f..ac975e69 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory.rs @@ -98,8 +98,11 @@ impl LockfileEntry { /// Inventory the project's npm-family lockfile. Routes by /// [`detect_npm_lock_flavor`]; the two PNPM-SPECIFIC probe refusals -/// (legacy lockfileVersion, pnpm node-linker=pnp) fall back to reading a -/// root `pnpm-lock.yaml` directly, a `vendor_lockfile_missing` refusal +/// (unsupported lockfileVersion, pnpm node-linker=pnp) fall back to reading +/// a root `pnpm-lock.yaml` directly — unless a live sibling lock the +/// router would otherwise have chosen sits beside it (a pnpm→yarn/npm +/// migration leftover), in which case the SIBLING is inventoried instead +/// ([`inventory_live_sibling_lock`]). A `vendor_lockfile_missing` refusal /// falls back to the pnpm <=2-era `shrinkwrap.yaml` (same v5 grammar, /// older filename), and any probe failure falls back to Rush's common /// lock when `rush.json` is present. All other refusals (yarn-berry PnP @@ -110,16 +113,17 @@ pub(crate) async fn inventory_npm_lock( let (flavor, _warnings) = match detect_npm_lock_flavor(project_root).await { Ok(found) => found, Err((code, _detail)) => { - // The flavor probe passes only pnpm locks the WIRING backend - // supports (lockfileVersion 9.0) and refuses PnP layouts, but - // inventory is read-only discovery — a legacy v5.4/v6.0 (pnpm - // 7/8) or pnpm-PnP-linked lock still names the resolved set, so - // on the probe's two PNPM-SPECIFIC refusals a present root lock - // is read directly rather than leaving fresh clones of such - // projects blind. Only those two codes: on any other refusal - // (yarn-berry PnP marker, bun locks) a root pnpm-lock.yaml is - // stale debris from a pnpm→yarn/bun migration, and inventorying - // it would present dead resolutions as the live dependency set. + // The flavor probe passes only pnpm locks the WIRING backends + // support (lockfileVersion 5.4/6.0/9.0) and refuses PnP layouts, + // but inventory is read-only discovery — an out-of-family (pnpm + // <= 6 or future) or pnpm-PnP-linked lock still names the + // resolved set, so on the probe's two PNPM-SPECIFIC refusals a + // present root lock is read directly rather than leaving fresh + // clones of such projects blind. Only those two codes: on any + // other refusal (yarn-berry PnP marker, bun locks) a root + // pnpm-lock.yaml is stale debris from a pnpm→yarn/bun migration, + // and inventorying it would present dead resolutions as the + // live dependency set. // (`vendor_lockfile_version_unsupported` also covers the // unrecognizable-yarn.lock refusal, but the probe only sniffs // yarn.lock when no root pnpm-lock.yaml exists, so the direct @@ -128,9 +132,37 @@ pub(crate) async fn inventory_npm_lock( code, "vendor_lockfile_version_unsupported" | "vendor_pnpm_pnp_unsupported" ) { - let pnpm = inventory_pnpm_lock(project_root).await.unwrap_or_default(); - if !pnpm.is_empty() { - return Some((NpmLockFlavor::Pnpm, finalize_npm(pnpm))); + // The version refusal fires from the probe's pnpm step, + // which runs BEFORE its yarn/npm steps — so it says nothing + // about whether a LIVE sibling lock sits beside the refused + // pnpm lock (a pnpm→yarn/npm migration leaves exactly that + // shape behind). Prefer whichever sibling the router would + // have chosen had the pnpm lock not shadowed it; only a + // sibling-less project is a genuine old-pnpm project whose + // lock the fallback may surface. The PnP refusal needs no + // such guard: `pnpm_pnp_layout` requires an installed pnpm + // store and NO yarn.lock, so there is no migration + // ambiguity to resolve. + let sibling = if code == "vendor_lockfile_version_unsupported" { + inventory_live_sibling_lock(project_root).await + } else { + None + }; + match sibling { + Some((flavor, entries)) if !entries.is_empty() => { + return Some((flavor, finalize_npm(entries))); + } + // A sibling lock FILE exists but yields no entries + // (dep-less project, or a grammar we cannot read): the + // migration still happened, so the pnpm lock stays out — + // blind beats presenting dead resolutions as live. + Some(_) => {} + None => { + let pnpm = inventory_pnpm_lock(project_root).await.unwrap_or_default(); + if !pnpm.is_empty() { + return Some((NpmLockFlavor::Pnpm, finalize_npm(pnpm))); + } + } } } // pnpm 1/2 wrote the v5-era lock grammar under the name @@ -175,6 +207,58 @@ pub(crate) async fn inventory_npm_lock( Some((flavor, finalize_npm(raw))) } +/// The live sibling lock a version-refused root `pnpm-lock.yaml` may be +/// shadowing, or `None` when no sibling lock file exists at all. +/// +/// [`detect_npm_lock_flavor`] cannot be re-asked (it already refused on its +/// pnpm step), so this mirrors the rest of its precedence by hand — bun, +/// then yarn, then npm — on file EXISTENCE, and returns the first present +/// sibling's inventory (possibly empty: presence alone proves the pnpm lock +/// is migration debris, so the caller must not fall back to it). Raw +/// entries — the caller applies [`finalize_npm`]. +async fn inventory_live_sibling_lock(root: &Path) -> Option<(NpmLockFlavor, Vec)> { + let exists = |name: &str| { + let p = root.join(name); + async move { tokio::fs::metadata(&p).await.is_ok() } + }; + // bun.lock — router step 2. That step runs BEFORE the pnpm sniff, so + // when the version refusal fired no bun.lock can actually be present; + // probed anyway to keep this a literal transcription of the router's + // order. bun.lockb (the legacy binary lock) refuses in the router + // rather than routing — mirrored here by omission. + if exists("bun.lock").await { + return Some(( + NpmLockFlavor::Bun, + inventory_bun(root).await.unwrap_or_default(), + )); + } + // yarn.lock — router step 4, where classic vs berry is a content + // decision. Rather than re-deriving that head sniff, try both readers: + // each yields entries only for its own grammar (classic's `version "…"` + // fields vs berry's `resolution:` lines), so a non-empty result is the + // sniff's answer. Berry PnP needs no carve-out: a PnP marker would have + // refused at the router's step 1 with a code this fallback ignores. + if exists("yarn.lock").await { + let classic = inventory_yarn_classic(root).await.unwrap_or_default(); + if !classic.is_empty() { + return Some((NpmLockFlavor::YarnClassic, classic)); + } + return Some(( + NpmLockFlavor::YarnBerry, + inventory_yarn_berry(root).await.unwrap_or_default(), + )); + } + // npm — router step 5 (`inventory_package_lock` itself prefers the + // shrinkwrap when both exist, mirroring npm). + if exists("npm-shrinkwrap.json").await || exists("package-lock.json").await { + return Some(( + NpmLockFlavor::PackageLock, + inventory_package_lock(root).await.unwrap_or_default(), + )); + } + None +} + /// Match a manifest/API purl (possibly percent-encoded, possibly carrying /// qualifiers) against the inventory: components decode via /// [`crate::utils::purl::normalize_purl`], so `pkg:npm/%40scope/x@1` @@ -1980,6 +2064,122 @@ packages: ); } + /// A pnpm-lock.yaml whose lockfileVersion the probe refuses — pnpm 6 + /// wrote 5.3; only 5.4/6.0/9.0 route to a backend. This is the shape + /// that reaches the version-refusal discovery fallback, where a live + /// sibling lock may be sitting beside it after a migration. + const PNPM_LOCK_V53_STALE: &str = "lockfileVersion: 5.3 + +packages: + + /dead-pnpm-dep/1.0.0: + resolution: {integrity: sha512-dead==} +"; + + /// A pnpm→yarn migration leaves a version-refused pnpm-lock.yaml beside + /// the live yarn.lock. The probe checks pnpm-lock.yaml BEFORE yarn.lock, + /// so its refusal says nothing about the sibling — the fallback must + /// surface the LIVE yarn resolutions, not the dead pnpm ones. + #[tokio::test] + async fn stale_pnpm_lock_beside_live_yarn_classic_yields_yarn_entries() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V53_STALE).await; + write(tmp.path(), "yarn.lock", YARN_CLASSIC).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap(); + assert_eq!(flavor, NpmLockFlavor::YarnClassic); + assert_eq!(entry(&entries, "left-pad").version, "1.3.0"); + assert!( + !entries.iter().any(|e| e.name == "dead-pnpm-dep"), + "dead pnpm resolutions must not pose as the live set: {entries:?}" + ); + } + + /// Same migration hazard toward yarn berry (node-modules linker: no PnP + /// marker, so the pnpm version refusal is what fires). + #[tokio::test] + async fn stale_pnpm_lock_beside_live_yarn_berry_yields_berry_entries() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V53_STALE).await; + write(tmp.path(), "yarn.lock", YARN_BERRY).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap(); + assert_eq!(flavor, NpmLockFlavor::YarnBerry); + assert_eq!(entry(&entries, "left-pad").version, "1.3.0"); + assert!( + !entries.iter().any(|e| e.name == "dead-pnpm-dep"), + "dead pnpm resolutions must not pose as the live set: {entries:?}" + ); + } + + /// Same migration hazard toward npm: the live package-lock.json wins + /// over the version-refused pnpm lock. + #[tokio::test] + async fn stale_pnpm_lock_beside_live_package_lock_yields_npm_entries() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V53_STALE).await; + write(tmp.path(), "package-lock.json", PACKAGE_LOCK).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap(); + assert_eq!(flavor, NpmLockFlavor::PackageLock); + assert_eq!(entry(&entries, "left-pad").version, "1.3.0"); + assert!( + !entries.iter().any(|e| e.name == "dead-pnpm-dep"), + "dead pnpm resolutions must not pose as the live set: {entries:?}" + ); + } + + /// A version-refused pnpm lock ALONE is a genuine old-pnpm project (no + /// migration happened) — the discovery fallback must still read it. + #[tokio::test] + async fn unsupported_pnpm_lock_alone_is_still_inventoried() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V53_STALE).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap(); + assert_eq!(flavor, NpmLockFlavor::Pnpm); + assert_eq!(entry(&entries, "dead-pnpm-dep").version, "1.0.0"); + } + + /// pnpm→bun migration with the TEXT bun.lock: the router routes Bun at + /// its bun step, which runs BEFORE the pnpm sniff, so no refusal (and no + /// fallback) ever fires — bun's entries are the inventory. Pinned here + /// because it is the router-precedence twin of the sibling checks above. + #[tokio::test] + async fn stale_pnpm_lock_beside_bun_lock_routes_to_bun() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V53_STALE).await; + write(tmp.path(), "bun.lock", BUN_LOCK).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap(); + assert_eq!(flavor, NpmLockFlavor::Bun); + assert_eq!(entry(&entries, "left-pad").version, "1.3.0"); + assert!( + !entries.iter().any(|e| e.name == "dead-pnpm-dep"), + "dead pnpm resolutions must not pose as the live set: {entries:?}" + ); + } + + /// A live sibling lock FILE that yields no entries (here: an empty + /// package-lock, as a fresh dep-less `npm install` writes) still proves + /// the migration happened — the dead pnpm resolutions must stay out even + /// though there is nothing live to return. + #[tokio::test] + async fn stale_pnpm_lock_beside_empty_live_lock_yields_none() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V53_STALE).await; + write( + tmp.path(), + "package-lock.json", + r#"{ "lockfileVersion": 3, "packages": { "": {} } }"#, + ) + .await; + assert!( + inventory_npm_lock(tmp.path()).await.is_none(), + "an empty live sibling must not resurrect the dead pnpm resolutions" + ); + } + // ── shrinkwrap.yaml (pnpm 1/2) ────────────────────────────────────────── /// The exact grammar the 2026-08-18 legacy matrix captured from a real From d35c9bcb5d94e6ed7499209f55a13d214ebea63c Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 19 Aug 2026 11:04:34 -0400 Subject: [PATCH 4/5] fix(vex): carry the vex advisory engine the embedded hosts consume The previous commit's host hunks read VexWriteSummary.warnings, which is introduced by the vex diagnostics change (vex.rs note_warning plumbing + core verify.rs out-of-sync flag). Carrying those two files here keeps this branch self-contained; content is byte-identical to the sibling vex PR, so either merge order resolves cleanly. Co-Authored-By: Claude Fable 5 --- crates/socket-patch-cli/src/commands/vex.rs | 248 ++++++++++++++++++-- crates/socket-patch-core/src/vex/verify.rs | 84 +++++++ 2 files changed, 318 insertions(+), 14 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index 29041ed5..beda9b55 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -28,7 +28,16 @@ use socket_patch_core::vex::{ use crate::args::{apply_env_toggles, parse_bool_flag, GlobalArgs}; use crate::ecosystem_dispatch::find_manifest_package_paths; -use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent}; +use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent, RunWarning}; + +/// Routing tag for a patch omitted from VEX by the property-7 ecosystem +/// filter alone: the patch IS applied (byte-verified, or trusted under +/// `--no-verify`) and carries vulnerability metadata, but its ecosystem has +/// no install hook set up and is not declared `manual`. Distinct from the +/// verification tags (`hash_mismatch`, `package_not_found`, …) so a JSON +/// consumer can tell "not patched" from "patched but not persisted by any +/// hook" — before this tag existed the drop was machine-invisible. +const ECOSYSTEM_NOT_SETUP: &str = "ecosystem_not_setup"; #[derive(Args)] pub struct VexArgs { @@ -180,6 +189,12 @@ pub(crate) struct VexWriteSummary { /// The built document — returned so the standalone `vex` command can /// emit its per-subcomponent envelope without rebuilding. pub doc: Document, + /// Run-level advisories (non-IRI product override, vendored artifacts + /// whose live installed tree is out of sync). Already printed to stderr + /// in human mode by [`generate_vex`]; the standalone `vex --json` path + /// folds them into the envelope's `warnings[]` (which is the only + /// channel `--json` has — it silences stderr). + pub warnings: Vec, } /// Failure from [`generate_vex`], carrying a stable code + message the @@ -224,7 +239,7 @@ pub async fn run(args: VexArgs) -> i32 { match generate_vex_from_manifest_path(&args.common, ¶ms, &manifest_path).await { Ok(summary) => { if args.common.json { - emit_envelope_success(&summary.doc, &summary.failed); + emit_envelope_success(&summary); } else if let Some(path) = &args.output { if !args.common.silent { println!( @@ -306,6 +321,34 @@ async fn generate_vex( Err(reason) => return Err(fail(common, "product_undetected", reason).await), }; + let mut warnings: Vec = Vec::new(); + + // The help text promises "PURL/identifier", so an arbitrary string is + // accepted — but the OpenVEX spec types the product `@id` as an IRI, and + // strict consumers (vexctl et al.) may reject or mis-key a bare name. + // Warn (never hard-reject) when the EXPLICIT override carries no scheme; + // auto-detected products are always `pkg:` PURLs and need no check. + if let Some(p) = params + .product + .as_deref() + .map(str::trim) + .filter(|p| !p.is_empty()) + { + if !has_iri_scheme(p) { + note_warning( + &mut warnings, + common, + "product_not_iri", + format!( + "product override {p:?} (--product / --vex-product) is neither a PURL \ + (pkg:...) nor an absolute IRI; it is emitted verbatim as the OpenVEX \ + product @id, which the spec requires to be an IRI — strict consumers may \ + reject the document. Prefer pkg:/@." + ), + ); + } + } + // Partition manifest into applied / failed. let mut outcome = if params.no_verify { // Trust-the-manifest mode still needs the vendored classification: @@ -364,6 +407,25 @@ async fn generate_vex( } } + // Vendored disclosure: the committed artifact verified (the attestation + // stands — the committables are what the lockfile consumes) but the LIVE + // installed tree is present and running different bytes. Say so — a + // build that bypasses the vendor wiring is unpatched until the next + // package-manager install. + for purl in &outcome.vendored_out_of_sync { + note_warning( + &mut warnings, + common, + "vendored_tree_out_of_sync", + format!( + "{purl}: the installed tree does not match its vendored artifact; the \ + attestation is based on the committed .socket/vendor artifact (the lockfile \ + consumes it), but the live tree carries different bytes — re-run your \ + package manager's install to resync it." + ), + ); + } + // Property 7: attest a patch only for an ecosystem that is actually set up — // or explicitly declared `manual` in the manifest. Patches for an ecosystem // that is neither are dropped regardless of verification mode (so even @@ -387,20 +449,35 @@ async fn generate_vex( } } } - let before = outcome.applied.len(); + let mut setup_filtered: Vec = Vec::new(); outcome.applied.retain(|purl| { - vendored_set.contains(purl) + let keep = vendored_set.contains(purl) || redirected_set.contains(purl.as_str()) || Ecosystem::from_purl(purl) .map(|e| allowed.contains(&e)) - .unwrap_or(false) + .unwrap_or(false); + if !keep { + setup_filtered.push(purl.clone()); + } + keep }); - if outcome.applied.len() != before && !common.silent && !common.json { + if !setup_filtered.is_empty() && !common.silent && !common.json { eprintln!( "Note: omitting patches for ecosystems that are not set up (and not declared `manual` \ in .socket/manifest.json's `setup.manual`) from VEX." ); } + // The filter drops join the omission channel (`failed`) with their own + // routing tag so they surface as per-purl `skipped` events in the + // envelope — success and error paths alike. Before this they existed + // only as the human-mode note above, leaving `--json` consumers unable + // to distinguish "patched but no persistence hook" from "not patched". + outcome + .failed + .extend(setup_filtered.into_iter().map(|purl| FailedPatch { + purl, + reason: ECOSYSTEM_NOT_SETUP.to_string(), + })); if !outcome.failed.is_empty() && !common.silent && !common.json { for f in &outcome.failed { @@ -437,9 +514,33 @@ async fn generate_vex( None => { let (token, org) = crate::commands::list::telemetry_credentials(common); track_vex_failed("no_applicable_patches", token.as_deref(), org.as_deref()).await; + // When nothing attested and EVERY omission was the property-7 + // filter, say so: those patches ARE applied with vulnerability + // metadata, and the generic message below would read as "not + // patched" to a human. The code stays `no_applicable_patches` — + // it is the documented exit-1 routing tag consumers already + // branch on; the per-event `ecosystem_not_setup` errorCode is + // the machine-readable discriminator. + let all_setup_drops = outcome.applied.is_empty() + && !outcome.failed.is_empty() + && outcome + .failed + .iter() + .all(|f| f.reason == ECOSYSTEM_NOT_SETUP); + let message = if all_setup_drops { + format!( + "{} applied patch(es) with vulnerability metadata were omitted from VEX \ + because their ecosystems are not set up (no install hook) and not declared \ + `manual` in .socket/manifest.json's `setup.manual`. Run `socket-patch \ + setup`, or add the ecosystem to `setup.manual`, then re-run.", + outcome.failed.len() + ) + } else { + "No applied patches with vulnerability metadata to attest.".to_string() + }; return Err(VexGenError { code: "no_applicable_patches", - message: "No applied patches with vulnerability metadata to attest.".to_string(), + message, failed: outcome.failed, }); } @@ -459,7 +560,15 @@ async fn generate_vex( let wrote_to_file = match ¶ms.output { Some(path) => { if let Err(e) = tokio::fs::write(path, &serialized).await { - return Err(fail(common, "write_failed", e.to_string()).await); + // The raw io::Error ("No such file or directory (os error + // 2)") names neither the file nor the operation — useless + // in a CI log. Say what was being written and where. + return Err(fail( + common, + "write_failed", + format!("failed to write VEX document to {}: {e}", path.display()), + ) + .await); } true } @@ -483,17 +592,90 @@ async fn generate_vex( statements: doc.statements.len(), failed: outcome.failed, doc, + warnings, }) } +/// Record a run-level advisory the way `update`/`vendor` do: stderr +/// (`Warning: `) in human mode, and into `warnings` so the `--json` +/// envelope — which silences stderr — carries it in `warnings[]` instead. +/// Under `--silent` only the envelope copy survives (warnings are not +/// errors). +fn note_warning(warnings: &mut Vec, common: &GlobalArgs, code: &str, detail: String) { + if !common.silent && !common.json { + eprintln!("Warning: {detail}"); + } + warnings.push(RunWarning { + code: code.to_string(), + detail, + }); +} + +/// True when `s` opens with an RFC 3986/3987 scheme +/// (`ALPHA *(ALPHA / DIGIT / "+" / "-" / ".") ":"`). A purl passes the same +/// test (`pkg:` is a scheme), so one check covers both halves of the help +/// text's "PURL/identifier" promise. Deliberately shallow — the goal is to +/// catch bare names like `my-app`, not to validate full IRIs. +fn has_iri_scheme(s: &str) -> bool { + let Some((scheme, _)) = s.split_once(':') else { + return false; + }; + let mut chars = scheme.chars(); + matches!(chars.next(), Some(c) if c.is_ascii_alphabetic()) + && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) +} + /// Read the manifest at `manifest_path`, then [`generate_vex`]. Manifest /// read failures are wrapped as [`VexGenError`] so embedded callers /// (`apply`/`scan`) get a single error channel. Used by the embedded /// `--vex` paths, which always write to a file. +/// +/// Failure contract: a run that ends in error leaves NO OpenVEX document at +/// the output path — including a stale one from a previous run. Attestation +/// semantics demand it: a pipeline reusing one `--output`/`--vex` path must +/// not ship yesterday's `not_affected` document for a tree this run could +/// no longer attest. See [`remove_stale_vex_doc`] for the deletion guard. pub(crate) async fn generate_vex_from_manifest_path( common: &GlobalArgs, params: &VexBuildParams, manifest_path: &Path, +) -> Result { + let result = generate_vex_from_manifest_path_inner(common, params, manifest_path).await; + if result.is_err() { + remove_stale_vex_doc(params.output.as_deref()).await; + } + result +} + +/// Delete a PRIOR run's OpenVEX document at `output` after a failed run. +/// Only a file that is recognizably OpenVEX (JSON whose `@context` names +/// openvex.dev) is removed — the guard keeps a mistyped `--output` pointing +/// at an unrelated file from being destroyed by an unrelated failure. +/// Removal errors are swallowed: the non-zero exit is the contract, the +/// deletion is hygiene. +async fn remove_stale_vex_doc(output: Option<&Path>) { + let Some(path) = output else { return }; + let Ok(bytes) = tokio::fs::read(path).await else { + return; + }; + let is_openvex = serde_json::from_slice::(&bytes) + .ok() + .and_then(|v| { + v.get("@context") + .and_then(|c| c.as_str()) + .map(|c| c.contains("openvex.dev")) + }) + .unwrap_or(false); + if is_openvex { + let _ = tokio::fs::remove_file(path).await; + } +} + +/// [`generate_vex_from_manifest_path`] without the failure-cleanup wrapper. +async fn generate_vex_from_manifest_path_inner( + common: &GlobalArgs, + params: &VexBuildParams, + manifest_path: &Path, ) -> Result { let manifest_file = match read_manifest(manifest_path).await { Ok(m) => m, @@ -726,7 +908,7 @@ fn emit_envelope_error(args: &VexArgs, code: &str, message: &str, failures: &[Fa for f in failures { env.record( PatchEvent::new(PatchAction::Skipped, f.purl.clone()) - .with_reason(f.reason.clone(), "patch omitted from VEX"), + .with_reason(f.reason.clone(), omission_reason_message(&f.reason)), ); } env.mark_error(EnvelopeError::new(code, message.to_string())); @@ -739,9 +921,21 @@ fn emit_envelope_error(args: &VexArgs, code: &str, message: &str, failures: &[Fa } } -fn emit_envelope_success(doc: &Document, failures: &[FailedPatch]) { +/// Human `reason` string for an omission event; the routing tag rides +/// `errorCode`. The property-7 drop gets its own phrasing — that patch IS +/// applied and verified, which the generic "omitted" alone doesn't convey. +fn omission_reason_message(reason: &str) -> &'static str { + if reason == ECOSYSTEM_NOT_SETUP { + "applied patch omitted from VEX: its ecosystem has no install hook set up and is not \ + declared `manual` in setup.manual" + } else { + "patch omitted from VEX" + } +} + +fn emit_envelope_success(summary: &VexWriteSummary) { let mut env = Envelope::new(Command::Vex); - for st in &doc.statements { + for st in &summary.doc.statements { for prod in &st.products { for sub in &prod.subcomponents { env.record( @@ -756,15 +950,16 @@ fn emit_envelope_success(doc: &Document, failures: &[FailedPatch]) { } } } - for f in failures { + for f in &summary.failed { env.record( PatchEvent::new(PatchAction::Skipped, f.purl.clone()) - .with_reason(f.reason.clone(), "patch omitted from VEX"), + .with_reason(f.reason.clone(), omission_reason_message(&f.reason)), ); } - if !failures.is_empty() { + if !summary.failed.is_empty() { env.mark_partial_failure(); } + env.warnings = summary.warnings.clone(); println!("{}", env.to_pretty_json()); } @@ -851,6 +1046,31 @@ mod tests { assert!(!are_safe_redirect_coords("github.com/foo/bar", "")); } + /// The `--product` advisory keys off [`has_iri_scheme`]: PURLs and + /// anything scheme-shaped sail through silently; bare names (what the + /// probe fed in) warn. Pin the accept/reject sets so the check can't + /// drift into rejecting legal identifiers (a hard reject is explicitly + /// out of contract — help text says "PURL/identifier"). + #[test] + fn iri_scheme_check_accepts_purls_and_iris_rejects_bare_names() { + // Accepted (no warning): PURLs, URLs, URNs, exotic-but-legal schemes. + assert!(has_iri_scheme("pkg:npm/my-app@1.0.0")); + assert!(has_iri_scheme("pkg:golang/github.com/foo/bar@v1.2.3")); + assert!(has_iri_scheme("https://example.com/products/app")); + assert!(has_iri_scheme( + "urn:uuid:0f9be22a-4a56-4b74-8c9d-6d70c67a4b32" + )); + assert!(has_iri_scheme("git+ssh://git@github.com/foo/bar")); + // Rejected (warn): bare names, empty scheme, non-alpha scheme start, + // spaces before the colon. + assert!(!has_iri_scheme("my-app")); + assert!(!has_iri_scheme("my app 1.0")); + assert!(!has_iri_scheme("")); + assert!(!has_iri_scheme(":no-scheme")); + assert!(!has_iri_scheme("1pkg:starts-with-digit")); + assert!(!has_iri_scheme("bad scheme:rest")); + } + #[derive(Parser)] struct Wrap { #[command(subcommand)] diff --git a/crates/socket-patch-core/src/vex/verify.rs b/crates/socket-patch-core/src/vex/verify.rs index 57be60ba..576c49d1 100644 --- a/crates/socket-patch-core/src/vex/verify.rs +++ b/crates/socket-patch-core/src/vex/verify.rs @@ -40,6 +40,15 @@ pub struct VerifyOutcome { /// vendor artifact (`.socket/vendor/…`) rather than the installed /// tree. Every member is also present in `applied`. pub vendored: Vec, + /// The subset of `vendored` whose INSTALLED tree is present on disk + /// but does NOT hash to the record's `afterHash` (pristine-unpatched, + /// tampered, or a missing file). The attestation itself is unaffected — + /// the committed artifact + lock wiring is the product the lockfile + /// consumes — but callers must disclose the drift: a build that + /// bypasses the vendor wiring runs unpatched code until the package + /// manager re-installs. An ABSENT installed tree is the expected + /// post-vendor state and is never flagged. + pub vendored_out_of_sync: Vec, } /// Vendored-patch context for [`applied_patches_with_vendor`]. @@ -116,6 +125,18 @@ pub async fn applied_patches_with_vendor( out.applied.push(purl.clone()); if vendor_entry.is_some() { out.vendored.push(purl.clone()); + // Disclosure probe: with the vendor artifact healthy, + // also check whether the LIVE installed tree (when the + // crawler found one) carries the patch. Any mismatch is + // recorded in `vendored_out_of_sync` for the caller to + // warn about — it never changes the verdict, because + // there is deliberately no installed-tree fallback in + // either direction (see the precedence note above). + if let Some(pkg_path) = package_paths.get(purl) { + if verify_patch_record(pkg_path, record).await.is_err() { + out.vendored_out_of_sync.push(purl.clone()); + } + } } } Err(reason) => out.failed.push(FailedPatch { @@ -324,6 +345,8 @@ mod tests { let o = VerifyOutcome::default(); assert!(o.applied.is_empty()); assert!(o.failed.is_empty()); + assert!(o.vendored.is_empty()); + assert!(o.vendored_out_of_sync.is_empty()); } /// `FailedPatch` equality + clone for downstream consumers @@ -999,6 +1022,10 @@ mod tests { assert_eq!(out.applied, vec![purl.to_string()]); assert_eq!(out.vendored, vec![purl.to_string()]); assert!(out.failed.is_empty()); + assert!( + out.vendored_out_of_sync.is_empty(), + "an ABSENT installed tree is the expected post-vendor state — no drift flag" + ); } /// A manifest PURL matches a vendor entry recorded under a different map @@ -1104,6 +1131,63 @@ mod tests { ); assert_eq!(out.vendored, vec![purl.to_string()]); assert!(out.failed.is_empty()); + // Disclosure: the live tree is present and pristine-unpatched — the + // attestation stands (committed artifact is the product) but the + // drift must be reported so the CLI can advise a re-install. + assert_eq!(out.vendored_out_of_sync, vec![purl.to_string()]); + } + + /// Disclosure probe, tampered direction: the vendor artifact is healthy + /// (attest + vendored) while the installed tree is present with bytes + /// matching NEITHER `beforeHash` nor `afterHash`. The verdict stands but + /// the purl is flagged `vendored_out_of_sync` — exactly like the + /// pristine-unpatched case, since either way the live tree is running + /// different bytes than the attested artifact. + #[tokio::test] + async fn tampered_installed_tree_flagged_out_of_sync_but_attested() { + let root = tempfile::tempdir().unwrap(); + let purl = "pkg:cargo/serde@1.0.0"; + let rel = format!(".socket/vendor/cargo/{VUUID}/serde-1.0.0"); + let patched = b"patched-content"; + let hash = compute_git_sha256_from_bytes(patched); + + // Vendored copy: healthy. + let vdir = root.path().join(&rel); + tokio::fs::create_dir_all(&vdir).await.unwrap(); + tokio::fs::write(vdir.join("index.js"), patched) + .await + .unwrap(); + // Installed tree: tampered (neither before nor after content). + let installed = root.path().join("installed"); + tokio::fs::create_dir_all(&installed).await.unwrap(); + tokio::fs::write(installed.join("index.js"), b"tampered live bytes") + .await + .unwrap(); + + let mut rec = record_with_one_file(&hash); + rec.uuid = VUUID.to_string(); + let mut manifest = PatchManifest::new(); + manifest.patches.insert(purl.to_string(), rec); + + let mut entries = HashMap::new(); + entries.insert(purl.to_string(), vendor_entry(purl, &rel)); + let ctx = VendorContext { + project_root: root.path().to_path_buf(), + entries, + go_patches: HashMap::new(), + }; + let mut paths = HashMap::new(); + paths.insert(purl.to_string(), installed); + + let out = applied_patches_with_vendor(&manifest, &paths, Some(&ctx)).await; + assert_eq!( + out.applied, + vec![purl.to_string()], + "a tampered LIVE tree must not block the committed-artifact attestation" + ); + assert_eq!(out.vendored, vec![purl.to_string()]); + assert!(out.failed.is_empty()); + assert_eq!(out.vendored_out_of_sync, vec![purl.to_string()]); } /// Precedence, fail-closed direction: a TAMPERED vendor artifact fails From c32d9a9b617740c1863d9065177a5ec74b2d7749 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 19 Aug 2026 12:16:21 -0400 Subject: [PATCH 5/5] test(vendor): align the pnpm conversion capstone with the #206 takeover pre-revert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hosted→vendored conversion test pinned the pre-#206 semantics: vendor over a live hosted redirect fired the vendor_supersedes_redirect warning once and embedded the HOSTED splice as the wiring original. Main's #206 changed the contract — vendor now PRE-REVERTS the redirect (vendor_takeover_reverted_redirect), records the PRISTINE registry fragments as its originals, and --revert restores the registry lock. Rewritten as the pnpm twin of mode_migration_npm.rs's assertions: takeover advisory fires, supersede warning never does, no hosted residue in lock or vendor ledger, revert round-trips to the pristine registry lock. Co-Authored-By: Claude Fable 5 --- .../tests/in_process_vendor.rs | 104 +++++++++++------- 1 file changed, 67 insertions(+), 37 deletions(-) diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 58803a9b..0e2d1d72 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -2062,16 +2062,22 @@ async fn scan_vendor_gem_detached_writes_no_manifest_and_reverts() { // The full migration a real project performs: `scan --mode hosted` first // (wiremock API, v9 pnpm root lock — the shapes of // `in_process_redirect_pnpm.rs`), then `vendor` over the hosted-redirected -// lock. Pins the npm-family takeover reconciliation: +// lock. Pins the npm-family takeover pre-revert (the pnpm twin of +// `mode_migration_npm.rs`): // -// * the redirect ledger loses the converted purl's `records` entry AND its +// * vendor PRE-REVERTS the live hosted redirect (surfaced as the +// `vendor_takeover_reverted_redirect` advisory) before rewiring, so the +// redirect ledger loses the converted purl's `records` entry AND its // `redirect_pnpm_resolution` edits (stale halves fed VEX/updates and -// re-fired the takeover warning forever pre-fix), -// * the `vendor_supersedes_redirect` warning fires exactly ONCE — on the -// run that reconciles — and a re-vendor (`already_vendored`) is silent, -// * `vendor --revert` still restores the HOSTED-spliced lock byte-exactly -// (the hosted fragment is embedded as the vendor wiring `original`, so -// dropping the redirect ledger halves loses no revert data). +// re-fired the takeover warning forever pre-fix) — the +// `vendor_supersedes_redirect` warning can never fire, on the takeover +// run or any later one, +// * the vendor wiring `original` embeds the PRISTINE registry fragment — +// not the grant-tokenized hosted splice — and a re-vendor +// (`already_vendored`) is silent, +// * `vendor --revert` therefore restores the REGISTRY lock byte-exactly +// (pre-#206 it restored an expiring hosted URL with no CLI path back to +// registry state). mod hosted_to_vendor_conversion { use super::*; use serial_test::serial; @@ -2277,20 +2283,20 @@ snapshots: #[tokio::test] #[serial] - async fn hosted_then_vendor_reconciles_ledger_warns_once_and_reverts_bytes() { + async fn hosted_then_vendor_takeover_pre_reverts_redirect_and_round_trips() { let server = MockServer::start().await; mock_hosted_api(&server).await; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); write_pnpm_project(root); + let pristine_lock = std::fs::read(root.join("pnpm-lock.yaml")).unwrap(); // 1. Hosted redirect: the lock's resolution is spliced to the hosted // tarball and the redirect ledger claims the purl. let code = scan_run(hosted_args(root, server.uri())).await; assert_eq!(code, 0, "scan --mode hosted must succeed"); - let hosted_lock = std::fs::read(root.join("pnpm-lock.yaml")).unwrap(); - let hosted_lock_text = String::from_utf8(hosted_lock.clone()).unwrap(); + let hosted_lock_text = std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(); assert!( hosted_lock_text.contains(&format!("tarball: {HOSTED_URL}")), "hosted splice missing:\n{hosted_lock_text}" @@ -2304,6 +2310,9 @@ snapshots: ); // 2. Vendor over the hosted-redirected lock (offline, staged blob). + // The takeover PRE-REVERTS the hosted edits first — surfaced as + // the `vendor_takeover_reverted_redirect` advisory — then vendors + // from the clean registry baseline. seed_manifest_and_blob(root); let (code, env1) = vendor_cli(root, &[]); assert_eq!( @@ -2311,25 +2320,34 @@ snapshots: "vendor over the hosted lock must succeed: {env1:#}" ); find_event(&env1, "applied", None); + find_event(&env1, "skipped", Some("vendor_takeover_reverted_redirect")); - // The takeover warning fired exactly once — on the reconciling run — - // and reports the reconciliation as DONE, not as advice to re-run. - let warns = takeover_warnings(&env1); - assert_eq!( - warns.len(), - 1, - "vendor_supersedes_redirect must fire exactly once: {env1:#}" + // The pre-revert leaves nothing to supersede, so the + // vendor_supersedes_redirect warning must not fire — not on this run + // (pre-#206 it fired here) and not on any later one. + assert!( + takeover_warnings(&env1).is_empty(), + "the pre-revert must preempt vendor_supersedes_redirect: {env1:#}" + ); + + // The lock is FULLY vendored: local wiring present, no hosted + // residue. + let vendored_lock_text = + std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(); + assert!( + !vendored_lock_text.contains(HOSTED_URL), + "the hosted splice must be gone from the vendored lock:\n{vendored_lock_text}" ); assert!( - warns[0].contains("reconciled automatically"), - "the warning must state the reconciliation happened: {}", - warns[0] + vendored_lock_text.contains(".socket/vendor/"), + "the vendored wiring must be present:\n{vendored_lock_text}" ); - // The redirect ledger no longer carries the purl's halves: its - // `records` entry and its `redirect_pnpm_resolution` edits are gone - // (a residual non-package edit like the workspace-trust one may - // remain — it is the hosted flow's own config surface). + // The redirect ledger no longer carries the purl's halves: the + // takeover dropped its `records` entry and its + // `redirect_pnpm_resolution` edits (a residual non-package edit like + // the workspace-trust one may remain — it is the hosted flow's own + // config surface). match std::fs::read_to_string(&ledger_path) { Ok(text) => { let after: Value = serde_json::from_str(&text).unwrap(); @@ -2357,22 +2375,27 @@ snapshots: Err(e) => panic!("unreadable redirect ledger: {e}"), } - // The vendor ledger's wiring `original` embeds the HOSTED-spliced - // fragment — the revert data the dropped ledger halves would - // otherwise have been the last copy of. + // The vendor ledger's wiring `original` embeds the PRISTINE registry + // fragment the pre-revert restored — never the grant-tokenized + // hosted splice (which would make `--revert` restore an expiring + // hosted URL with no CLI path back to registry state). let state: Value = serde_json::from_str( &std::fs::read_to_string(root.join(".socket/vendor/state.json")).unwrap(), ) .unwrap(); + let wiring = state["entries"][CONV_PURL]["wiring"].to_string(); assert!( - state["entries"][CONV_PURL]["wiring"] - .to_string() - .contains(HOSTED_URL), - "vendor wiring must embed the hosted-spliced original: {state:#}" + wiring.contains(UPSTREAM_SHA512), + "vendor wiring must embed the pristine registry original: {state:#}" + ); + assert!( + !wiring.contains(HOSTED_URL), + "vendor wiring must NOT record the hosted fragment: {state:#}" ); - // 3. Re-vendor: an `already_vendored` no-op with NO takeover warning - // (pre-fix the stale ledger re-fired it on every run). + // 3. Re-vendor: an `already_vendored` no-op with NO takeover event + // and NO supersede warning (pre-fix the stale ledger re-fired the + // warning on every run). let (code, env2) = vendor_cli(root, &[]); assert_eq!(code, 0, "re-vendor must succeed: {env2:#}"); find_event(&env2, "skipped", Some("already_vendored")); @@ -2380,14 +2403,21 @@ snapshots: takeover_warnings(&env2).is_empty(), "a reconciled ledger must not re-fire the warning: {env2:#}" ); + assert!( + events(&env2) + .iter() + .all(|e| e["errorCode"] != "vendor_takeover_reverted_redirect"), + "a reconciled ledger must not re-fire the takeover: {env2:#}" + ); - // 4. `vendor --revert` restores the HOSTED-spliced lock byte-exactly. + // 4. `vendor --revert` restores the REGISTRY lock byte-exactly — the + // pre-redirect resolution, not the hosted splice. let (code, renv) = vendor_cli(root, &["--revert"]); assert_eq!(code, 0, "revert must succeed: {renv:#}"); assert_eq!( std::fs::read(root.join("pnpm-lock.yaml")).unwrap(), - hosted_lock, - "revert must byte-restore the hosted-spliced lock" + pristine_lock, + "revert must byte-restore the pristine registry lock" ); } }