diff --git a/.github/workflows/pnpm-compatibility.yml b/.github/workflows/pnpm-compatibility.yml new file mode 100644 index 00000000..8d14e489 --- /dev/null +++ b/.github/workflows/pnpm-compatibility.yml @@ -0,0 +1,101 @@ +name: pnpm hosted compatibility + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Compile CLI and pnpm integration test once + run: | + cargo test --locked -p socket-patch-cli --test e2e_redirect_pnpm_build --no-run --message-format=json > target-build.json + python3 - <<'PY' + import json, pathlib, shutil + dest = pathlib.Path('target/pnpm-e2e') + dest.mkdir(parents=True, exist_ok=True) + shutil.copy2('target/debug/socket-patch', dest / 'socket-patch') + for line in pathlib.Path('target-build.json').read_text().splitlines(): + item = json.loads(line) + if item.get('target', {}).get('name') == 'e2e_redirect_pnpm_build' and item.get('executable'): + shutil.copy2(item['executable'], dest / 'pnpm-e2e') + assert (dest / 'pnpm-e2e').is_file() + PY + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: pnpm-e2e + path: target/pnpm-e2e/ + if-no-files-found: error + retention-days: 7 + + install-proof: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + # 1.0.0 must explicitly refuse its non-durable shrinkwrap format. + - {pnpm: '1.0.0', node: '10.24.1'} + - {pnpm: '1.43.1', node: '10.24.1'} + - {pnpm: '2.0.0', node: '10.24.1'} + - {pnpm: '2.25.7', node: '10.24.1'} + - {pnpm: '3.0.0', node: '10.24.1'} + - {pnpm: '3.8.1', node: '10.24.1'} + - {pnpm: '4.0.0', node: '16.20.2'} + - {pnpm: '4.14.4', node: '16.20.2'} + - {pnpm: '5.0.0', node: '16.20.2'} + - {pnpm: '5.18.11', node: '16.20.2'} + - {pnpm: '6.0.0', node: '16.20.2'} + - {pnpm: '6.35.1', node: '16.20.2'} + - {pnpm: '7.0.0', node: '16.20.2'} + - {pnpm: '7.33.7', node: '16.20.2'} + - {pnpm: '8.0.0', node: '16.20.2'} + - {pnpm: '8.15.9', node: '16.20.2'} + - {pnpm: '9.0.0', node: '24.11.1'} + - {pnpm: '9.15.9', node: '24.11.1'} + - {pnpm: '10.0.0', node: '24.11.1'} + - {pnpm: '10.33.0', node: '24.11.1'} + - {pnpm: '10.34.5', node: '24.11.1'} + - {pnpm: '11.0.0', node: '24.11.1'} + - {pnpm: '11.27.0', node: '24.11.1'} + - {pnpm: '12.0.0', node: '24.11.1'} + - {pnpm: '12.4.2', node: '24.11.1'} + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: pnpm-e2e + path: bin + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '24.11.1' + - name: Install the pinned package manager + env: + PNPM_TEST_VERSION: ${{ matrix.pnpm }} + run: | + npm install --prefix "$RUNNER_TEMP/pnpm-tool" --no-audit --no-fund "pnpm@$PNPM_TEST_VERSION" + echo "SOCKET_PATCH_PNPM_E2E_BIN=$RUNNER_TEMP/pnpm-tool/node_modules/.bin/pnpm" >> "$GITHUB_ENV" + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ matrix.node }} + - name: Require installation, verified VEX, rollback and integrity rejection + env: + SOCKET_PATCH_PNPM_E2E_VERSION: ${{ matrix.pnpm }} + SOCKET_PATCH_PNPM_E2E_REQUIRED: '1' + SOCKET_NO_CONFIG: '1' + SOCKET_NO_UPDATE_CHECK: '1' + run: | + chmod +x bin/socket-patch bin/pnpm-e2e + export SOCKET_PATCH_PNPM_E2E_SOCKET_BIN="$PWD/bin/socket-patch" + bin/pnpm-e2e pnpm_pinned_matrix --ignored --nocapture diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 838b6ee8..b0a730a1 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -86,7 +86,15 @@ 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. +**pnpm hosted-mode contract**: `scan --mode hosted` handles block and flow resolutions in legacy `shrinkwrap.yaml` and lockfileVersion 5.x, 6.0, and 9.0. The [pinned compatibility matrix](../../docs/testing/pnpm-compatibility.md) samples pnpm majors 1–12. Early shrinkwrapVersion 3 without a positive minor version is refused with `redirect_pnpm_legacy_lockfile_unsupported`: pnpm 1.0.0 discards hosted URLs even on frozen installs. Upgrade to a tested release (1.43.1 or newer) and regenerate the lock, or use agent mode. + +Each matching package instance is spliced, including scoped, quoted and nested-peer keys, with one `redirect_pnpm_resolution` revert-ledger edit per changed instance. LF/CRLF and unrelated lock bytes are preserved. Unsupported matching instances refuse that dependency across the lockfile set; an already-hosted URL elsewhere cannot confirm a partial rewrite. + +For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLockfile: true` (created with a root-only `packages:` scaffold, or appended while preserving user bytes). pnpm >=11 requires this to accept hosted URLs; it disables registry re-verification for the whole lock, while sha512 tarball integrity remains enforced. The write is ledger-recorded as `redirect_pnpm_workspace_trust`, respects `--dry-run`, skips legacy locks and Rush repos, preserves explicit user settings, and is disabled by `--no-trust-lockfile-config`. The `redirect_pnpm_trust_lockfile` warning explains manual configuration when required and clean reinstall guidance for all pnpm versions. Existing installs and warm stores can retain upstream files; use a clean install tree and empty store, then verify installed files with `socket-patch vex`. Neither a successful install nor a local VEX export guarantees hosted SBOM recognition or changes dashboard alert actions/counts. + +`redirect_pnpm_no_lockfile` names pnpm when installer markers exist without a lock; `redirect_pnpm_entry_vendored` identifies a vendored entry instead of reporting it missing. Supported `shrinkwrap.yaml` files are writable lockfiles, not read-only markers. + +**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 + the `updates` array + the `redirectState` state block below). No effect outside `--json` mode — the non-JSON path always prompts the user interactively. @@ -112,7 +120,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments `scan --mode hosted` (== `--redirect`) swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL, or — for golang — the `patch.socket.dev/gopatch/` module path) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither recorded nor attested. Re-runs over already-rewritten output record zero new edits. JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. -The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate and `bun.lock`), `requirements.txt` / `uv.lock`, `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 1 or 2 — bun 1.3/1.4 share one emitted grammar; a binary `bun.lockb` with no text lock is auto-migrated to text via `bun install --save-text-lockfile --frozen-lockfile --lockfile-only` before the read, recorded as a `removed` FileEdit; `redirect_bun_lockb_would_migrate` on `--dry-run`, `redirect_bun_lockb_unsupported` when the migration is unavailable). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). +The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate and `bun.lock`), `requirements.txt` / `uv.lock`, `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 1 or 2 — bun 1.3/1.4 share one emitted grammar; a binary `bun.lockb` with no text lock is auto-migrated to text via `bun install --save-text-lockfile --frozen-lockfile --lockfile-only` before the read, recorded as a `removed` FileEdit; `redirect_bun_lockb_would_migrate` on `--dry-run`, `redirect_bun_lockb_unsupported` when the migration is unavailable). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **Gem stale-install guard (additive warning — the canonical narrative; other mentions point here)**: the gem hosted rewrite is pure Gemfile/lock text, so a gem ALREADY materialized under the project's bundle paths keeps its upstream bytes — the next `bundle install` prints `Using ` and never refetches, on **every** bundler major (live-verified 2026-08-19 on 1.17.3 / 2.7.2 / 4.0.18: bundler 4's CHECKSUMS verify at download time only, and nothing is downloaded; `bundle install --force`/`--redownload` re-install from the stale cached `.gem` instead of re-fetching — bundler 1 silently, bundler 4 with an exit-37 checksum refusal that still leaves the upstream bytes installed; the **verified** remedy is removing the installed dir + cache `.gem` + `specifications` entry, then `bundle install`). After the rewrite, a hosted run therefore probes the installed-gem discovery paths (the same ruby-crawler discovery `apply` uses, honoring `--global`/`--global-prefix` like scan's own discovery) for each confirmed gem redirect and judges the materialization against the patch record's `afterHash` file map. Judgment rules: records are found **by uuid** — this run's fetched records first, then the redirect ledger's persisted ones, so a transiently failed `/patches/view` fetch cannot retire the warning (it re-fires on every re-scan until the stale materialization is gone); a materialization with every file at `afterHash` is already patched and never warns (an agent→hosted migration stays quiet by construction), and when several confirmed variant purls resolve to one installed dir, ANY of them judging it patched keeps it quiet; staleness needs **positive evidence** — at least one record file whose bytes were actually read and hash to neither state's expectation — so missing or unreadable files never produce a warning. Warnings emit `redirect_gem_stale_install` (JSON `redirect.warnings[]` + a code-tagged stderr line) in three flavors: a PROJECT-LOCAL dir gets the verified delete-list remedy (installed dir, cache `.gem`, `specifications` entry — plus the project's committed `vendor/cache/.gem` when present and not proven to be the patched artifact, since bundler installs from `vendor/cache` in preference to fetching); a SHARED gem-env home gets a caveat that the home is shared machine-wide and prefers migrating the project to a local bundle path over deleting shared files; and a committed `vendor/cache` archive whose sha256 differs from the patched artifact's warns standalone even with no installed dir at all (a fresh checkout with a committed stale cache re-materializes the upstream bytes forever). A stale-flagged purl is additionally **excluded from the same run's `--vex` `assume_applied` set** — the envelope must never attest a CVE its own warning says is live; the purl falls back to normal installed-tree verification (a patched install still attests, a stale one is omitted). The probe is read-only (nothing is deleted) and skipped on `--dry-run` — deliberately explicit, since nothing was rewritten but the ledger fallback could otherwise judge an already-redirected project. Exit code and `status` are unchanged (warning-only, the hosted-refusal posture); a same-run `--vex` may still fail on "nothing to attest" per the embedded-VEX contract. diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 7884d184..2060cac0 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -17,12 +17,7 @@ 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. + // pnpm <=2 uses the same package identities under the old filename. "shrinkwrap.yaml", "node_modules/.modules.yaml", "yarn.lock", @@ -170,10 +165,9 @@ fn pnpm_trust_manual_guidance(server: &str) -> String { /// 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 \ + "The pnpm lockfile was repointed at {server}. This is a legacy \ + lock read by pnpm 1–8, which have no \ + lockfile trust policy: 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 \ @@ -1508,7 +1502,7 @@ pub(crate) async fn run_redirect_selected( std::path::Path::new(key) .file_name() .and_then(|n| n.to_str()) - == Some("pnpm-lock.yaml") + .is_some_and(|name| matches!(name, "pnpm-lock.yaml" | "shrinkwrap.yaml")) }) .map(|(_, content)| content) .collect(); @@ -1576,9 +1570,12 @@ pub(crate) async fn run_redirect_selected( // 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 all_locks_legacy = pnpm_lock_texts.iter().all(|text| { + pnpm_lock_version_major(text).is_some_and(|major| major < 9) + || text + .lines() + .any(|line| line.starts_with("shrinkwrapVersion:")) + }); let detail = if all_locks_legacy { pnpm_trust_legacy_detail(&server) } else if !root_lock_v9 || common.no_trust_lockfile_config { @@ -1637,7 +1634,16 @@ pub(crate) async fn run_redirect_selected( }; pnpm_warnings.push(serde_json::json!({ "code": "redirect_pnpm_trust_lockfile", - "detail": detail, + "detail": format!( + "{}. After a lock-only change, existing node_modules or a warm pnpm store \ + can still contain upstream files. For a reliable reinstall, use a clean \ + node_modules tree and an empty store with \ + `pnpm install --frozen-lockfile --store-dir ` \ + (pnpm 1–4 accepts the option `--store`). Do not rely on `--force`: some \ + versions re-resolve the upstream artifact. Run `socket-patch vex` after \ + installation to verify the patched files.", + detail.trim_end_matches('.') + ), })); } } @@ -1669,6 +1675,9 @@ pub(crate) async fn run_redirect_selected( .iter() .filter( |(purl, uuid, artifact_url, index_url, suffixed_version, go_module_path)| { + if rewrite.refused_pnpm_uuids.contains(uuid) { + return false; + } // Cargo is transactional: the rewriter reports exactly which // patch uuids FULLY landed (manifest pin + lock + registry // block). Substring presence must never confirm a cargo dep — @@ -2302,8 +2311,7 @@ mod tests { !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("pnpm 1–8"), "{legacy}"); assert!(legacy.contains("no trust step"), "{legacy}"); // The vulnerable-reinstall caution survives the split: regenerating // the lock still silently discards the redirect. diff --git a/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs index d971cdf2..25ed8c9e 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs @@ -26,21 +26,19 @@ //! 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). +//! The required CI matrix provisions exact pnpm versions across majors 1–12 +//! and runs `pnpm_pinned_matrix_*` with setup failures treated as failures. +//! It covers warm-cache verification, clean reinstall, fresh frozen install, +//! ordinary install, lock-only discovery, rollback and tamper rejection. A +//! second fixture covers scoped aliases and peer variants in workspaces on +//! pnpm >=6. The older named corepack capstones remain opt-in conveniences. //! //! 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 +//! rejects the rewritten lock otherwise. Legacy locks need no trust setting +//! or flag. The auto-config 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 @@ -57,7 +55,7 @@ //! byte-preserved. use std::path::{Path, PathBuf}; -use std::process::{Command, Output, Stdio}; +use std::process::{Command, Output}; use sha2::{Digest, Sha512}; use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; @@ -98,28 +96,43 @@ const PATCHED_SHA512: &str = "sha512-PATCHEDpatchedPATCHEDpatched0123456789=="; // ── self-contained helpers ──────────────────────────────────────────── fn binary() -> PathBuf { - PathBuf::from(env!("CARGO_BIN_EXE_socket-patch")) + std::env::var_os("SOCKET_PATCH_PNPM_E2E_SOCKET_BIN") + .map(PathBuf::from) + .unwrap_or_else(|| 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 pnpm_command(pm: &str) -> Command { + if let Some(bin) = std::env::var_os("SOCKET_PATCH_PNPM_E2E_BIN") { + Command::new(bin) + } else { + let mut cmd = Command::new("corepack"); + cmd.arg(pm); + cmd + } +} + 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"); + let probe = tempfile::tempdir().unwrap(); + let mut cmd = pnpm_command(pm); + cmd.arg("--version").current_dir(probe.path()); cache_env::isolate(&mut cmd); - cmd.stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) + cmd.env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0"); + let output = cmd.output(); + let ok = output.as_ref().is_ok_and(|o| o.status.success()); + if std::env::var_os("SOCKET_PATCH_PNPM_E2E_REQUIRED").is_some() { + assert!(ok, "required pnpm toolchain unavailable: {output:?}"); + if let Ok(output) = output { + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + pm.strip_prefix("pnpm@").unwrap(), + "matrix must run the pinned version" + ); + } + } + ok } /// Remove ambient `SOCKET_*` / `PNPM_*` / `npm_config_*` vars. @@ -148,8 +161,38 @@ fn scrub_socket_env(cmd: &mut Command) { } fn corepack(cwd: &Path, pm: &str, args: &[&str]) -> Output { - let mut cmd = Command::new("corepack"); - cmd.arg(pm).args(args).current_dir(cwd); + let mut cmd = pnpm_command(pm); + let legacy = pm + .strip_prefix("pnpm@") + .and_then(|v| v.split('.').next()) + .and_then(|v| v.parse::().ok()) + .is_some_and(|major| major <= 4); + // pnpm <=4 accepts `store`; 1–3 silently ignore `store-dir` and early 4 + // rejects it. Ignoring the option lets a warm store fake cold coverage. + let args: Vec = args + .iter() + .map(|arg| { + if legacy { + arg.replacen("--store-dir=", "--store=", 1) + } else { + arg.to_string() + } + }) + .collect(); + cmd.args(&args).current_dir(cwd); + if args.first().is_some_and(|arg| arg == "install") + && pm + .strip_prefix("pnpm@") + .and_then(|v| v.split('.').next()) + .and_then(|v| v.parse::().ok()) + .is_some_and(|major| (6..=11).contains(&major)) + { + cmd.args([ + "--fetch-retries=0", + "--fetch-retry-mintimeout=100", + "--fetch-retry-maxtimeout=500", + ]); + } scrub_socket_env(&mut cmd); // After the scrub: it strips ambient `PNPM_*` / `npm_config_*`, which // would otherwise take the sandbox values back out again. @@ -317,14 +360,25 @@ async fn mount_api_mocks( sri: &str, before_hash: &str, after_hash: &str, +) { + mount_target_api_mocks(server, hosted_url, sri, before_hash, after_hash, PURL).await; +} + +async fn mount_target_api_mocks( + server: &MockServer, + hosted_url: &str, + sri: &str, + before_hash: &str, + after_hash: &str, + purl: &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, + "purl": purl, "patches": [{ - "uuid": UUID, "purl": PURL, "tier": "free", + "uuid": UUID, "purl": purl, "tier": "free", "cveIds": [], "ghsaIds": [], "severity": "high", "title": "pnpm redirect capstone fixture" }] @@ -339,7 +393,7 @@ async fn mount_api_mocks( ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [{ - "uuid": UUID, "purl": PURL, + "uuid": UUID, "purl": purl, "publishedAt": "2026-01-01T00:00:00Z", "description": "x", "license": "MIT", "tier": "free", "vulnerabilities": {} @@ -355,7 +409,7 @@ async fn mount_api_mocks( UUID: { "status": "granted", "url": hosted_url, - "purl": PURL, + "purl": purl, "artifacts": [{ "kind": "tarball", "url": hosted_url, @@ -371,7 +425,7 @@ async fn mount_api_mocks( .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "uuid": UUID, - "purl": PURL, + "purl": purl, "publishedAt": "2026-01-01T00:00:00Z", "files": { "package/index.js": { @@ -412,6 +466,8 @@ struct PnpmRedirectFixture { proj: PathBuf, patched: Vec, _server: MockServer, + lock_name: String, + lock_before: String, } /// Steps 1–3 of the module doc against the REAL `corepack `: fixture @@ -451,9 +507,14 @@ async fn redirect_scanned_pnpm_project( let install = corepack( &proj, pm, - &["install", "--store-dir", store.to_str().unwrap()], + &["install", &format!("--store-dir={}", store.display())], ); if !install.status.success() { + assert!( + std::env::var_os("SOCKET_PATCH_PNPM_E2E_REQUIRED").is_none(), + "required {pm} fixture install failed: {:?}", + install + ); println!( "SKIP e2e_redirect_pnpm_build ({tag}): fixture `{pm} install` failed \ (registry unreachable?):\n{}", @@ -495,7 +556,12 @@ async fn redirect_scanned_pnpm_project( .await; mount_tarball_route(&server, served).await; - let lock_path = proj.join("pnpm-lock.yaml"); + let lock_name = if proj.join("pnpm-lock.yaml").exists() { + "pnpm-lock.yaml" + } else { + "shrinkwrap.yaml" + }; + let lock_path = proj.join(lock_name); 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 @@ -506,7 +572,7 @@ async fn redirect_scanned_pnpm_project( // 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:")) + .find(|l| l.contains("integrity:")) .expect("pristine lock must carry an inline resolution") .to_string(); @@ -521,6 +587,22 @@ async fn redirect_scanned_pnpm_project( "{driver:?} --mode hosted failed ({tag}).\nstdout:\n{stdout}\nstderr:\n{stderr}" ); let env = parse_envelope(&stdout); + if pm == "pnpm@1.0.0" { + assert_eq!( + env["redirect"]["redirected"], 0, + "unsafe legacy lock must be refused: {env}" + ); + assert!( + warning_codes(&env).contains(&"redirect_pnpm_legacy_lockfile_unsupported".to_string()) + ); + assert_eq!(std::fs::read_to_string(&lock_path).unwrap(), lock_before); + assert!(!proj.join(".socket/vendor/redirect-state.json").exists()); + println!( + "EXPECTED REFUSAL: pnpm 1.0.0 discards hosted URLs; lock unchanged, no patch confirmed" + ); + return None; + } + assert_eq!(env["status"], "success", "envelope: {env}"); assert_eq!( env["redirect"]["redirected"], 1, @@ -535,7 +617,7 @@ async fn redirect_scanned_pnpm_project( let expected_rewrites = if auto_trust { serde_json::json!(["pnpm-lock.yaml", "pnpm-workspace.yaml"]) } else { - serde_json::json!(["pnpm-lock.yaml"]) + serde_json::json!([lock_name]) }; assert_eq!( env["redirect"]["rewrittenFiles"], expected_rewrites, @@ -577,7 +659,7 @@ async fn redirect_scanned_pnpm_project( reject the flag); got: {trust_detail}" ); assert!( - trust_detail.contains("pnpm 7/8") && trust_detail.contains("installs work unchanged"), + trust_detail.contains("pnpm") && trust_detail.contains("no trust step"), "the legacy-lock warning must say installs work unchanged on pnpm 7/8; \ got: {trust_detail}" ); @@ -609,9 +691,8 @@ async fn redirect_scanned_pnpm_project( // (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}}}" - )), + lock_after.contains(&format!("integrity: {sri}")) + && lock_after.contains(&format!("tarball: {hosted_url}")), "resolution must be spliced to the patched sri + hosted tarball; got:\n{lock_after}" ); assert!( @@ -633,7 +714,7 @@ async fn redirect_scanned_pnpm_project( assert!( edits.iter().any(|e| e["kind"] == "redirect_pnpm_resolution" && e["key"] == format!("{DEP}@{DEP_VERSION}") - && e["path"] == "pnpm-lock.yaml"), + && e["path"] == lock_name), "the ledger must record the redirect_pnpm_resolution edit: {ledger}" ); let trust_edits: Vec<&serde_json::Value> = edits @@ -714,6 +795,8 @@ async fn redirect_scanned_pnpm_project( proj, patched, _server: server, + lock_name: lock_name.to_string(), + lock_before, }) } @@ -735,7 +818,7 @@ fn fresh_checkout_install( 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(); + std::fs::copy(fx.proj.join(&fx.lock_name), fresh.join(&fx.lock_name)).unwrap(); if with_workspace_yaml { std::fs::copy( fx.proj.join("pnpm-workspace.yaml"), @@ -758,12 +841,8 @@ fn fresh_checkout_install( ) .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(), - ]; + let store_flag = format!("--store-dir={}", fresh_store.display()); + let mut args = vec!["install", "--frozen-lockfile", &store_flag]; args.extend_from_slice(extra_args); let out = corepack(&fresh, pm, &args); (fresh, out) @@ -1030,6 +1109,131 @@ async fn pnpm8_v6_lock_redirect_fresh_checkout_frozen_install_lands_patched_byte // ── synthetic legs (hermetic — no pnpm binary, never ignored) ───────── +/// Required CI matrix: unlike the opportunistic capstones above, a missing +/// toolchain or failed fixture is a failure. The job provisions each pnpm +/// major with a compatible Node version and passes its absolute executable. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +#[ignore = "requires the pinned pnpm matrix toolchain"] +async fn pnpm_pinned_matrix_install_verify_revert_and_tamper() { + let version = std::env::var("SOCKET_PATCH_PNPM_E2E_VERSION") + .expect("set SOCKET_PATCH_PNPM_E2E_VERSION to the exact pnpm version"); + let pm = format!("pnpm@{version}"); + let fx = redirect_scanned_pnpm_project(&pm, &version, false, false, HostedDriver::Scan).await; + if version == "1.0.0" { + assert!(fx.is_none(), "the unsafe legacy format must be refused"); + return; + } + let fx = fx.expect("required matrix fixture must not skip"); + let warm_store = format!("--store-dir={}", fx.tmp.path().join("pnpm-store").display()); + let warm = corepack( + &fx.proj, + &pm, + &["install", "--frozen-lockfile", &warm_store], + ); + assert!(warm.status.success(), "warm install failed: {warm:?}"); + let installed = std::fs::read(fx.proj.join("node_modules").join(DEP).join("index.js")).unwrap(); + let (vex_code, _, _) = run_socket( + &fx.proj, + &["vex", "--offline", "--product", "pkg:npm/consumer@0.0.0"], + ); + assert_eq!( + vex_code == 0, + installed == fx.patched, + "a successful pnpm install must not cause VEX to attest stale files" + ); + // A lock-only edit does not invalidate every pnpm major's warm cache. + // The cross-version recovery is a clean tree AND a new empty store; + // --force alone is not sufficient (and pnpm 12 re-resolves upstream). + std::fs::remove_dir_all(fx.proj.join("node_modules")).unwrap(); + let clean_store = format!( + "--store-dir={}", + fx.tmp.path().join("clean-store").display() + ); + let clean = corepack( + &fx.proj, + &pm, + &["install", "--frozen-lockfile", &clean_store], + ); + assert_marker_landed( + &fx.proj, + &fx.patched, + &clean, + &format!("{version} clean reinstall"), + ); + let with_workspace = fx.proj.join("pnpm-workspace.yaml").exists(); + let (fresh, install) = fresh_checkout_install(&fx, &pm, "matrix", &[], with_workspace); + assert_marker_landed(&fresh, &fx.patched, &install, &version); + + // Local evidence of remediation: the default VEX path verifies installed + // hashes. This deliberately makes no assertion about dashboard alerts. + let (code, stdout, stderr) = run_socket( + &fresh, + &["vex", "--offline", "--product", "pkg:npm/consumer@0.0.0"], + ); + assert_eq!(code, 0, "verified VEX failed: {stdout}\n{stderr}"); + let vex: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(vex["statements"][0]["status"], "not_affected", "{vex}"); + assert_eq!(vex["statements"][0]["vulnerability"]["name"], GHSA, "{vex}"); + + // An ordinary install must also preserve the patch. Use a new store so + // a warm cache cannot disguise an upstream re-resolution. + let store_flag = format!( + "--store-dir={}", + fx.tmp.path().join("ordinary-store").display() + ); + std::fs::remove_dir_all(fresh.join("node_modules")).unwrap(); + let ordinary = corepack(&fresh, &pm, &["install", &store_flag]); + assert_marker_landed(&fresh, &fx.patched, &ordinary, &version); + + // Revert committed wiring without an installed tree: no in-place patch + // reversal or blob fetching can hide a lock/trust-setting rollback bug. + std::fs::remove_dir_all(fx.proj.join("node_modules")).unwrap(); + let (code, stdout, stderr) = + run_socket(&fx.proj, &["rollback", "--offline", "--yes", "--json"]); + assert_eq!(code, 0, "rollback failed: {stdout}\n{stderr}"); + assert_eq!( + std::fs::read_to_string(fx.proj.join(&fx.lock_name)).unwrap(), + fx.lock_before + ); + // The same project must be discoverable from just its lockfile in a + // fresh checkout, including the legacy shrinkwrap filename. + let (code, stdout, stderr) = run_hosted(HostedDriver::Scan, &fx.proj, &fx._server.uri(), &[]); + assert_eq!(code, 0, "lock-only scan failed: {stdout}\n{stderr}"); + assert_eq!(parse_envelope(&stdout)["redirect"]["redirected"], 1); + + // Every major must reject a hosted tarball whose bytes disagree with its + // lockfile pin, even when pnpm >=11 uses trustLockfile. + let tampered = redirect_scanned_pnpm_project(&pm, &version, true, false, HostedDriver::GetUuid) + .await + .expect("required tamper fixture must not skip"); + let with_workspace = tampered.proj.join("pnpm-workspace.yaml").exists(); + let (fresh, install) = fresh_checkout_install(&tampered, &pm, "tampered", &[], with_workspace); + assert!( + !install.status.success(), + "{version} accepted a tampered tarball" + ); + let output = format!( + "{}{}", + String::from_utf8_lossy(&install.stdout), + String::from_utf8_lossy(&install.stderr) + ); + assert!( + ["integrity", "checksum"] + .iter() + .any(|word| output.to_ascii_lowercase().contains(word)), + "unexpected failure: {output}" + ); + assert!( + !fresh + .join("node_modules") + .join(DEP) + .join("index.js") + .exists(), + "tampered bytes were linked" + ); +} + /// 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). @@ -1243,7 +1447,7 @@ async fn pnpm_v6_plain_lock_key_rewrite_stays_supported() { reject the flag as unknown); got: {v6_detail}" ); assert!( - v6_detail.contains("pnpm 7/8") && v6_detail.contains("installs work unchanged"), + v6_detail.contains("pnpm 1–8") && v6_detail.contains("no trust step"), "the legacy-lock warning must say installs work unchanged on pnpm 7/8; \ got: {v6_detail}" ); @@ -1292,3 +1496,187 @@ async fn pnpm_v6_plain_lock_key_rewrite_stays_supported() { "a v6-lock scan must record no workspace trust edit: {ledger}" ); } + +/// Real pnpm workspace graph with a scoped target, an npm alias, two peer +/// contexts and peers-of-peers. Registry/API/tarballs are local so this test +/// validates graph handling without depending on upstream package metadata. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +#[ignore = "requires the pinned pnpm matrix toolchain"] +async fn pnpm_pinned_matrix_workspace_peer_instances() { + let version = std::env::var("SOCKET_PATCH_PNPM_E2E_VERSION").unwrap(); + let major: u32 = version.split('.').next().unwrap().parse().unwrap(); + if major < 6 { + // The required single-package test above covers these older majors; + // this fixture exercises the three modern workspace lock grammars. + return; + } + let pm = format!("pnpm@{version}"); + assert!(has_corepack_pm(&pm)); + const TARGET: &str = "@fixture/left-pad"; + const TARGET_PURL: &str = "pkg:npm/@fixture/left-pad@1.3.0"; + let server = MockServer::start().await; + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("workspace"); + std::fs::create_dir_all(&proj).unwrap(); + let original = b"module.exports = 'original';\n"; + let patched = [MARKER.as_bytes(), original.as_slice()].concat(); + let mut patched_tarball = Vec::new(); + for (name, versions, peers) in [ + ( + TARGET, + vec!["1.3.0"], + serde_json::json!({"e2e-middle": "*", "e2e-leaf": "*"}), + ), + ( + "e2e-middle", + vec!["1.0.0"], + serde_json::json!({"e2e-leaf": "*"}), + ), + ("e2e-leaf", vec!["1.0.0", "2.0.0"], serde_json::json!({})), + ] { + let mut metadata = serde_json::json!({"name":name,"dist-tags":{"latest": versions.last().unwrap()},"versions":{}}); + for version in versions { + let package = tmp.path().join("pack"); + std::fs::create_dir_all(&package).unwrap(); + let manifest = serde_json::json!({"name":name,"version":version,"main":"index.js","peerDependencies":peers}); + std::fs::write(package.join("package.json"), manifest.to_string()).unwrap(); + std::fs::write(package.join("index.js"), original).unwrap(); + let tgz = make_tgz_from_installed(&package, original); + if name == TARGET { + patched_tarball = make_tgz_from_installed(&package, &patched); + } + let route = format!( + "/{name}/-/{}-{version}.tgz", + name.rsplit('/').next().unwrap() + ); + let mut record = manifest; + record["dist"] = serde_json::json!({"tarball":format!("{}{route}",server.uri()),"integrity":format!("sha512-{}",sha512_sri_b64(&tgz))}); + metadata["versions"][version] = record; + Mock::given(method("GET")) + .and(path(&route)) + .respond_with(ResponseTemplate::new(200).set_body_bytes(tgz)) + .mount(&server) + .await; + } + let route = if name == TARGET { + "(?i)^/(?:@|%40)fixture(?:/|%2f)left-pad$".to_string() + } else { + format!("^/{name}$") + }; + Mock::given(method("GET")) + .and(path_regex(route)) + .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) + .mount(&server) + .await; + } + std::fs::write( + proj.join("package.json"), + r#"{"name":"workspace-fixture","version":"1.0.0","private":true}"#, + ) + .unwrap(); + let registry = format!("{}/", server.uri()); + std::fs::write( + proj.join(".npmrc"), + format!("registry={registry}\nfetch-retries=0\n"), + ) + .unwrap(); + std::fs::write( + proj.join("pnpm-workspace.yaml"), + format!("packages:\n - 'packages/*'\nregistry: '{registry}'\nminimumReleaseAge: 0\n"), + ) + .unwrap(); + for (app, leaf, alias) in [("a", "1.0.0", false), ("b", "2.0.0", true)] { + let dir = proj.join("packages").join(app); + std::fs::create_dir_all(&dir).unwrap(); + let name = if alias { "alias" } else { TARGET }; + let spec = if alias { + "npm:@fixture/left-pad@1.3.0" + } else { + "1.3.0" + }; + std::fs::write(dir.join("package.json"), serde_json::json!({"name":app,"version":"1.0.0","private":true,"dependencies":{name:spec,"e2e-middle":"1.0.0","e2e-leaf":leaf}}).to_string()).unwrap(); + } + let store = format!("--store-dir={}", tmp.path().join("initial-store").display()); + let install = corepack(&proj, &pm, &["install", &store]); + assert!(install.status.success(), "workspace fixture: {install:?}"); + let lock_before = std::fs::read_to_string(proj.join("pnpm-lock.yaml")).unwrap(); + let url = hosted_url_for(&server.uri()); + let sri = format!("sha512-{}", sha512_sri_b64(&patched_tarball)); + mount_target_api_mocks( + &server, + &url, + &sri, + &compute_git_sha256_from_bytes(original), + &compute_git_sha256_from_bytes(&patched), + TARGET_PURL, + ) + .await; + mount_tarball_route(&server, patched_tarball).await; + let (code, stdout, stderr) = run_hosted(HostedDriver::Scan, &proj, &server.uri(), &[]); + assert_eq!(code, 0, "workspace redirect: {stdout}\n{stderr}"); + assert_eq!( + parse_envelope(&stdout)["redirect"]["redirected"], + 1, + "{stdout}" + ); + let lock_after = std::fs::read_to_string(proj.join("pnpm-lock.yaml")).unwrap(); + // Both peer contexts must be represented before the rewrite; v9 factors + // their common resolution into packages and keeps contexts in snapshots. + assert!(lock_before.contains("e2e-leaf@1.0.0") || lock_before.contains("e2e-leaf/1.0.0")); + assert!(lock_before.contains("e2e-leaf@2.0.0") || lock_before.contains("e2e-leaf/2.0.0")); + if major < 9 { + assert_eq!( + lock_after.matches(&url).count(), + 2, + "both legacy peer resolutions: {lock_after}" + ); + } + let fresh = tmp.path().join("fresh-workspace"); + std::fs::create_dir_all(&fresh).unwrap(); + for file in [ + "package.json", + "pnpm-workspace.yaml", + "pnpm-lock.yaml", + ".npmrc", + ] { + std::fs::copy(proj.join(file), fresh.join(file)).unwrap(); + } + // Copy only manifests, never node_modules or a warm store. + for app in ["a", "b"] { + let dir = fresh.join("packages").join(app); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::copy( + proj.join("packages").join(app).join("package.json"), + dir.join("package.json"), + ) + .unwrap(); + } + let store = format!("--store-dir={}", tmp.path().join("fresh-store").display()); + let install = corepack(&fresh, &pm, &["install", "--frozen-lockfile", &store]); + assert!( + install.status.success(), + "workspace frozen install: {install:?}" + ); + for (app, target) in [("a", TARGET), ("b", "alias")] { + let bytes = std::fs::read( + fresh + .join("packages") + .join(app) + .join("node_modules") + .join(target) + .join("index.js"), + ) + .unwrap(); + assert_eq!( + bytes, patched, + "{version}: {app}/{target} must install the patched peer instance" + ); + } + let (code, stdout, stderr) = run_hosted(HostedDriver::Scan, &proj, &server.uri(), &[]); + assert_eq!(code, 0, "workspace rerun: {stdout}\n{stderr}"); + assert_eq!( + parse_envelope(&stdout)["redirect"]["rewrittenFiles"], + serde_json::json!([]) + ); +} diff --git a/crates/socket-patch-cli/tests/in_process_alternate_installers.rs b/crates/socket-patch-cli/tests/in_process_alternate_installers.rs index 6d1995d6..cab047b0 100644 --- a/crates/socket-patch-cli/tests/in_process_alternate_installers.rs +++ b/crates/socket-patch-cli/tests/in_process_alternate_installers.rs @@ -753,18 +753,17 @@ gem 'colorize', '1.1.0' ); return; } - // Find the gem directory. - let mut lib_file = None; - let bundle_root = tmp.path().join("vendor/bundle/ruby"); - if let Ok(entries) = std::fs::read_dir(&bundle_root) { - for entry in entries.flatten() { - let candidate = entry.path().join("gems/colorize-1.1.0/lib/colorize.rb"); - if candidate.exists() { - lib_file = Some(candidate); - break; - } - } - } + // Bundler 1 treats BUNDLE_PATH as the gem home itself; newer versions + // append ruby/. Both are real vendored layouts of the same fixture. + let bundle_root = tmp.path().join("vendor/bundle"); + let direct = bundle_root.join("gems/colorize-1.1.0/lib/colorize.rb"); + let lib_file = direct.is_file().then_some(direct).or_else(|| { + std::fs::read_dir(bundle_root.join("ruby")) + .ok()? + .flatten() + .map(|entry| entry.path().join("gems/colorize-1.1.0/lib/colorize.rb")) + .find(|candidate| candidate.is_file()) + }); // bundle install reported success, so the gem and its lib file MUST be // present under the vendored bundle. A miss here is a real regression // (changed vendor layout / gem-discovery break), not a skip. diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index 400a73c7..f034c7f6 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -1798,18 +1798,17 @@ 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: +/// malformed peer key with an unclosed parenthesis 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), +/// the unsupported 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() { +async fn redirect_pnpm_malformed_peer_residual_refuses_dep_not_confirmed() { let server = MockServer::start().await; mock_discovery(&server).await; mock_reference(&server).await; @@ -1843,13 +1842,13 @@ packages: resolution: {{integrity: sha512-UPSTREAMupstream==}} dev: false - /{NAME}@{VERSION}(react@18.2.0(scheduler@0.23.2)): + /{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"); + .expect("write the mixed plain + malformed-peer pnpm lock"); let env = run_redirect_subprocess(tmp.path(), &server.uri()); assert_eq!( 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 bcc6ce7e..28e3ba2c 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -672,18 +672,12 @@ fn run_hosted_json(cwd: &Path, api_url: &str) -> (Option, serde_json::Value (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. +/// Legacy shrinkwrap projects support both installed discovery and the +/// lock-only supplement. Hosted mode edits their block resolutions without +/// changing unrelated packages or introducing npm-specific diagnostics. #[tokio::test] #[serial] -async fn hosted_legacy_shrinkwrap_project_diagnoses_pnpm_not_npm() { +async fn hosted_legacy_shrinkwrap_project_redirects_and_discovers_uninstalled_packages() { let server = MockServer::start().await; mock_discovery(&server).await; mock_reference(&server).await; @@ -737,26 +731,12 @@ specifiers: 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}" + doc["redirect"]["redirected"], 1, + "legacy package must redirect: {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}"); + assert!(!doc.to_string().contains("redirect_npm_no_lockfile")); + assert!(!doc.to_string().contains("redirect_pnpm_legacy_lockfile")); // The lockfile-only supplement reads shrinkwrap.yaml: the uninstalled // `/legacy-only-dep/2.0.0` entry surfaces (it was 0 before the fix). @@ -765,11 +745,12 @@ specifiers: "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)" + let rewritten = std::fs::read_to_string(root.join("shrinkwrap.yaml")).unwrap(); + assert!( + rewritten.contains(&format!("tarball: {HOSTED_URL}")), + "{rewritten}" ); + assert!(rewritten.contains("integrity: sha512-LEGACYONLYlegacyonly==")); } /// (d) hosted over a VENDORED pnpm lock (the mode-conversion matrix's projB @@ -872,3 +853,37 @@ snapshots: "a zero-redirect run must not write a redirect ledger" ); } + +/// A URL in one instance must not confirm the whole package while another +/// peer instance remains upstream. This also prevents an unverified --vex +/// attestation from being created for an incomplete rewrite. +#[tokio::test] +#[serial] +async fn hosted_partial_pnpm_redirect_is_not_confirmed_by_url_presence() { + 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 = format!("lockfileVersion: '6.0'\npackages:\n /{NAME}@{VERSION}:\n resolution: {{integrity: {PATCHED_SHA512}, tarball: {HOSTED_URL}}}\n /{NAME}@{VERSION}(unbalanced:\n resolution: {{integrity: {UPSTREAM_SHA512}}}\n"); + std::fs::write(tmp.path().join("pnpm-lock.yaml"), &lock).unwrap(); + let (code, doc) = run_hosted_json(tmp.path(), &server.uri()); + assert_eq!(code, Some(0), "{doc}"); + assert_eq!( + doc["redirect"]["redirected"], 0, + "incomplete package must not be confirmed: {doc}" + ); + assert!(doc["redirect"]["warnings"] + .as_array() + .unwrap() + .iter() + .any(|w| w["code"] == "redirect_pnpm_unsupported_lock_key")); + assert_eq!( + std::fs::read_to_string(tmp.path().join("pnpm-lock.yaml")).unwrap(), + lock + ); + assert!(!tmp + .path() + .join(".socket/vendor/redirect-state.json") + .exists()); +} diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 0b4952a9..d291c7b9 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -24,6 +24,7 @@ use crate::crawlers::composer_crawler::normalize_version; use crate::vendor::yarn_berry_lock::yarnrc_compression_level; pub mod golang_local; +mod pnpm; mod replay; mod requirements; mod state; @@ -170,6 +171,9 @@ pub struct RewriteResult { /// presence in rewritten files (a `[registries.…]` config block alone /// pins nothing). pub confirmed_cargo_uuids: std::collections::BTreeSet, + /// An incomplete pnpm rewrite must not be confirmed by finding its URL + /// in another instance, a comment, or another lockfile. + pub refused_pnpm_uuids: std::collections::BTreeSet, } /// Combined name as it appears in registry coordinates / lock keys. @@ -256,28 +260,13 @@ fn rewrite_npm_lock( || k == "bun.lockb" || k == "pnpm-lock.yaml" || k.ends_with("/pnpm-lock.yaml") + || k == "shrinkwrap.yaml" + || k.ends_with("/shrinkwrap.yaml") }); if !sibling_lock_present { - // 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") { + // Without a lock, the installer-state marker still identifies + // pnpm so the diagnostic names the right package manager. + let warning = if files.contains_key("node_modules/.modules.yaml") { RewriteWarning { code: "redirect_pnpm_no_lockfile".into(), detail: "pnpm project (node_modules/.modules.yaml present) but \ @@ -1466,79 +1455,24 @@ 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. +/// Audit every matching package instance after planning edits. A malformed +/// resolution or unsupported suffix refuses this dependency across all locks; +/// snapshots and other versions do not participate in resolution. 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 + pnpm::entries(content) + .into_iter() + .filter_map(|entry| { + pnpm::suffix(entry.key, fname, version)?; + let rewritten = + pnpm::resolution(&entry).is_some_and(|r| r.tarball() == Some(artifact_url)); + (!rewritten).then(|| entry.key.to_string()) + }) + .collect() } fn rewrite_pnpm_lock( @@ -1553,38 +1487,40 @@ fn rewrite_pnpm_lock( // iterates keys sorted, so goldens are stable across every lock in the set. let lock_keys: Vec<&String> = files .keys() - .filter(|k| k.as_str() == "pnpm-lock.yaml" || k.ends_with("/pnpm-lock.yaml")) + .filter(|k| { + matches!( + k.rsplit('/').next(), + Some("pnpm-lock.yaml" | "shrinkwrap.yaml") + ) + }) .collect(); if npm.is_empty() || lock_keys.is_empty() { return; } - // Work on an editable copy of each lock so a single dep can be rewritten - // in whichever locks contain it. A CRLF lock can never match the rewrite - // grammar (its pattern anchors on `):\n`, but every CRLF line puts a `\r` - // byte before the `\n`), so the miss used to surface per-dep as a - // misleading `redirect_pnpm_entry_not_found`. Name the real cause instead - // and skip the lock (fail-closed, as before). - let mut contents: Vec<(&String, String, bool)> = Vec::new(); - for k in &lock_keys { - let content = files[*k].clone(); - if content.contains("\r\n") { + let mut contents: Vec<(&String, String, bool)> = lock_keys + .iter() + .map(|k| (*k, files[*k].clone(), false)) + .collect(); + for dep in &npm { + let fname = full_name(dep); + let unsafe_locks: Vec<_> = contents + .iter() + .filter(|(_, content, _)| { + pnpm::unsupported_early_shrinkwrap(content) + && pnpm::entries(content) + .iter() + .any(|e| pnpm::suffix(e.key, &fname, &dep.version).is_some()) + }) + .map(|(path, _, _)| path.as_str()) + .collect(); + if !unsafe_locks.is_empty() { + result.refused_pnpm_uuids.insert(dep.patch_uuid.clone()); result.warnings.push(RewriteWarning { - code: "redirect_pnpm_crlf_unsupported".into(), - detail: format!( - "{k} has CRLF (Windows) line endings; the redirect's \ - byte-surgical rewrite only supports LF — normalize the \ - file to LF line endings and re-run" - ), + code: "redirect_pnpm_legacy_lockfile_unsupported".into(), + detail: format!("{} uses early pnpm 1 shrinkwrapVersion 3 without a supported minor version. Those installers discard hosted tarball URLs; {fname}@{} was left unchanged in every lock. Upgrade to a tested pnpm release (1.43.1 or newer) and regenerate the lock, or use `scan --mode agent` for installed-file patching.", unsafe_locks.join(", "), dep.version), }); continue; } - contents.push((*k, content, false)); - } - if contents.is_empty() { - return; - } - for dep in &npm { - let fname = full_name(dep); let Some(sha512) = dep.integrity.sha512.clone() else { result.warnings.push(RewriteWarning { code: "redirect_pnpm_missing_sha512".into(), @@ -1592,34 +1528,8 @@ fn rewrite_pnpm_lock( }); continue; }; - // 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); - let pat = String::from(r"(?m)(^ {2}('") - + &key - + r"'|/?" - + &key - + r"(?:\([^)\n]*\))*|/" - + ®ex::escape(&fname) - + "/" - + ®ex::escape(&dep.version) - + r"(?:_[^:\n]*)?):\n(?: {4,}.*\n)*? {4,}resolution: )\{([^}\n]*)\}"; - let re = - Regex::new(&pat).expect("resolution regex from the escaped name@version key is valid"); + // Every peer instance must be redirected, including nested peer + // contexts and the block resolutions emitted by pnpm 1–5. let mut matched_any = false; // Per-lock rewrites are PLANNED first and committed only after the // residual gate below proves no instance of this dep escaped the @@ -1634,67 +1544,33 @@ fn rewrite_pnpm_lock( // 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()); - } + for entry in pnpm::entries(content) { + let Some(suffix) = pnpm::suffix(entry.key, &fname, &dep.version) else { + continue; + }; + if !pnpm::supported_suffix(suffix) { + continue; } - let rebuilt = format!("{{{}}}", fields.join(", ")); - // Already redirected (re-run): no edit, no ledger growth. + let Some(resolution) = pnpm::resolution(&entry) else { + continue; + }; + matched_any = true; + let original = &content[resolution.range.clone()]; + let rebuilt = resolution.rewrite(&sha512, &dep.artifact_url); 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}"))); + splices.push((resolution.range, rebuilt.clone())); 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)), + key: Some(format!("{fname}@{}{suffix}", dep.version)), + original: Some(Value::String(original.to_string())), new: Some(Value::String(rebuilt)), }); } - // Splice by byte range (captures_iter yields non-overlapping - // matches in order) — a string replace could hit the wrong + // Splice by byte range (package blocks are disjoint and ordered) — a string replace could hit the wrong // instance when two entries share identical surrounding bytes. let candidate: Option = if splices.is_empty() { None @@ -1712,7 +1588,7 @@ fn rewrite_pnpm_lock( // 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 + // parse (e.g. an unbalanced peer suffix) — 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. @@ -1736,6 +1612,7 @@ fn rewrite_pnpm_lock( // committed in one lock while another still resolves the dep // upstream would confirm the dep set-wide. if !residuals.is_empty() { + result.refused_pnpm_uuids.insert(dep.patch_uuid.clone()); for (lock_key, keys) in &residuals { result.warnings.push(RewriteWarning { code: "redirect_pnpm_unsupported_lock_key".into(), @@ -1813,7 +1690,7 @@ fn rewrite_pnpm_lock( } else { result.warnings.push(RewriteWarning { code: "redirect_pnpm_entry_not_found".into(), - detail: format!("no inline resolution for {fname}@{}", dep.version), + detail: format!("no resolution for {fname}@{}", dep.version), }); } } @@ -9849,18 +9726,8 @@ packages: ); } - /// 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() { + fn pnpm_v6_nested_paren_peer_key_rewrites_every_instance() { let lock = "lockfileVersion: '6.0' dependencies: @@ -9885,36 +9752,16 @@ packages: 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(), - "a partial rewrite must not ship: 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("/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 residual refusal must not double-report as not-found: {:?}", - r.warnings - ); + let rewritten = &r.files["pnpm-lock.yaml"]; + assert_eq!(rewritten.matches("tarball: http://patch.test/").count(), 2); + assert!(rewritten.contains("/left-pad@1.3.0(react@18.2.0(scheduler@0.23.2)):")); + assert_eq!(r.edits.len(), 2); + assert!(r.warnings.is_empty(), "{:?}", 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 + /// an unbalanced 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. @@ -9940,7 +9787,7 @@ snapshots: packages: - /left-pad@1.3.0(react@18.2.0(scheduler@0.23.2)): + /left-pad@1.3.0(react@18.2.0(scheduler@0.23.2): resolution: {integrity: sha512-UPSTREAM==} dev: false "; @@ -10611,14 +10458,8 @@ 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. + /// Legacy shrinkwrap files are rewritten; a marker without a lock still + /// produces a pnpm-specific missing-lock diagnostic. #[test] fn no_lockfile_warning_is_pnpm_flavored_when_pnpm_markers_present() { let ovr = npm_override( @@ -10629,14 +10470,14 @@ snapshots: ); // shrinkwrap.yaml present (with or without node_modules — a fresh - // clone has only the committed lock): name the legacy lock. + // clone has only the committed lock): rewrite its block resolution. 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" + shrinkwrapMinorVersion: 6\nshrinkwrapVersion: 3\n" .to_string(), ); if with_marker { @@ -10646,24 +10487,9 @@ snapshots: ); } 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 - ); + assert!(r.files["shrinkwrap.yaml"] + .contains("tarball: http://patch.test/left-pad-1.3.0.tgz")); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); } // pnpm marker only (lock deleted / never committed): pnpm-flavored @@ -10829,11 +10655,9 @@ packages: ); } - /// A CRLF pnpm lock gets a dedicated CRLF warning instead of the - /// misleading per-dep `redirect_pnpm_entry_not_found` (the entry exists; - /// only the LF-anchored grammar cannot see it). Fail-closed either way. + /// CRLF locks preserve their newline style through hosted rewriting. #[test] - fn pnpm_crlf_lock_gets_dedicated_crlf_warning() { + fn pnpm_crlf_lock_is_rewritten_without_changing_line_endings() { let ovr = npm_override( "left-pad", "1.3.0", @@ -10847,19 +10671,10 @@ packages: ); let mut r = RewriteResult::default(); rewrite_pnpm_lock(&files, std::slice::from_ref(&ovr), &mut r); - assert!(r.files.is_empty(), "CRLF lock must not be rewritten"); - assert_eq!( - warning_codes(&r), - vec!["redirect_pnpm_crlf_unsupported"], - "the refusal must name CRLF, not entry-not-found: {:?}", - r.warnings - ); - assert!( - r.warnings[0].detail.contains("pnpm-lock.yaml") - && r.warnings[0].detail.contains("CRLF"), - "detail must name the file and the line endings: {}", - r.warnings[0].detail - ); + let out = &r.files["pnpm-lock.yaml"]; + assert!(out.contains("tarball: http://patch.test/left-pad-1.3.0.tgz")); + assert!(!out.replace("\r\n", "").contains('\n')); + assert!(r.warnings.is_empty()); } // ── golang ─────────────────────────────────────────────────────────────── diff --git a/crates/socket-patch-core/src/patch/redirect/pnpm.rs b/crates/socket-patch-core/src/patch/redirect/pnpm.rs new file mode 100644 index 00000000..59eb61a7 --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/pnpm.rs @@ -0,0 +1,260 @@ +//! Read pnpm's package blocks without reserializing unrelated YAML. The old +//! shrinkwrap and lockfile 5.1/5.2 formats use block resolutions; newer locks +//! use flow mappings. Peer suffixes are identities, not part of the version. + +use std::ops::Range; + +/// Early pnpm 1 writes shrinkwrapVersion 3 without a minor version and +/// unconditionally drops registry tarball URLs on install. Its frozen flag +/// cannot preserve this redirect (verified with pnpm 1.0.0). +pub(super) fn unsupported_early_shrinkwrap(content: &str) -> bool { + let version = content + .lines() + .find_map(|line| line.strip_prefix("shrinkwrapVersion:")); + let minor = content + .lines() + .find_map(|line| line.strip_prefix("shrinkwrapMinorVersion:")); + version.is_some_and(|v| v.trim().trim_matches(['\'', '"']) == "3") + && !minor.is_some_and(|v| v.trim().parse::().is_ok_and(|v| v > 0)) +} + +pub(super) struct Entry<'a> { + pub key: &'a str, + pub body: &'a str, + pub offset: usize, +} + +pub(super) fn entries(content: &str) -> Vec> { + let mut out = Vec::new(); + let mut in_packages = false; + let mut current: Option<(&str, usize)> = None; + let mut offset = 0; + for line in content.split_inclusive('\n') { + let text = line.trim_end_matches(['\r', '\n']); + let top_level = !text.is_empty() && !text.starts_with([' ', '#']); + let key = text.strip_prefix(" ").filter(|s| !s.starts_with(' ')); + let key = key.and_then(|s| s.strip_suffix(':')); + if top_level || key.is_some() { + if let Some((key, start)) = current.take() { + out.push(Entry { + key, + body: &content[start..offset], + offset: start, + }); + } + } + if top_level { + in_packages = text == "packages:"; + } else if in_packages { + if let Some(key) = key { + current = Some((key, offset + line.len())); + } + } + offset += line.len(); + } + if let Some((key, start)) = current { + out.push(Entry { + key, + body: &content[start..], + offset: start, + }); + } + out +} + +fn unquote(s: &str) -> &str { + s.strip_prefix('\'') + .and_then(|s| s.strip_suffix('\'')) + .or_else(|| s.strip_prefix('"').and_then(|s| s.strip_suffix('"'))) + .unwrap_or(s) +} + +/// Loose identity match, also used to refuse unsupported suffixes atomically. +pub(super) fn suffix<'a>(key: &'a str, name: &str, version: &str) -> Option<&'a str> { + let key = unquote(key); + let key = key.strip_prefix('/').unwrap_or(key); + let suffix = key + .strip_prefix(&format!("{name}@{version}")) + .or_else(|| key.strip_prefix(&format!("{name}/{version}")))?; + if suffix + .chars() + .next() + .is_some_and(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+')) + { + return None; + } + Some(suffix) +} + +pub(super) fn supported_suffix(suffix: &str) -> bool { + if suffix.is_empty() || suffix.starts_with('_') { + return true; + } + let mut depth = 0usize; + for c in suffix.chars() { + match c { + '(' => depth += 1, + ')' if depth > 0 => depth -= 1, + ')' => return false, + _ if depth == 0 => return false, + _ => {} + } + } + depth == 0 +} + +pub(super) struct Resolution<'a> { + pub range: Range, + pub fields: Vec<(&'a str, &'a str)>, + block: bool, + newline: &'static str, +} + +impl Resolution<'_> { + pub fn tarball(&self) -> Option<&str> { + self.fields + .iter() + .find(|(k, _)| *k == "tarball") + .map(|(_, v)| unquote(v)) + } + + pub fn rewrite(&self, integrity: &str, url: &str) -> String { + // JSON strings are also YAML scalars. Keep the usual URL/SRI spelling + // byte-compatible, but quote flow delimiters and whitespace. + let scalar = |s: &str| { + if s.chars() + .any(|c| c.is_whitespace() || matches!(c, ',' | '[' | ']' | '{' | '}' | '\'' | '"')) + { + serde_json::to_string(s).expect("string serializes") + } else { + s.to_string() + } + }; + let mut fields = vec![ + format!("integrity: {}", scalar(integrity)), + format!("tarball: {}", scalar(url)), + ]; + fields.extend( + self.fields + .iter() + .filter(|(k, _)| !matches!(*k, "integrity" | "tarball")) + .map(|(k, v)| format!("{k}: {v}")), + ); + if self.block { + format!( + "{} {}", + self.newline, + fields.join(&format!("{} ", self.newline)) + ) + } else { + format!("{{{}}}", fields.join(", ")) + } + } +} + +/// Flat string mapping only. Aliases, nested values, duplicate keys and +/// malformed mappings are refused, never guessed or partially replaced. +pub(super) fn resolution<'a>(entry: &Entry<'a>) -> Option> { + let mut offset = entry.offset; + let mut lines = entry.body.split_inclusive('\n').peekable(); + while let Some(line) = lines.next() { + let text = line.trim_end_matches(['\r', '\n']); + let start = offset; + offset += line.len(); + let Some(value) = text.strip_prefix(" resolution:") else { + continue; + }; + // A second resolution key is invalid YAML, so do not bless it. + if lines + .clone() + .any(|line| line.starts_with(" resolution:")) + { + return None; + } + let block = value.trim().is_empty(); + let mut parts = Vec::new(); + let range; + if block { + let begin = start + " resolution:".len(); + let mut end = begin; + while let Some(child) = lines.peek() { + let Some(field) = child.strip_prefix(" ").filter(|s| !s.starts_with(' ')) + else { + break; + }; + parts.push(field.trim_end_matches(['\r', '\n'])); + end = offset + child.trim_end_matches(['\r', '\n']).len(); + offset += child.len(); + lines.next(); + } + // A blank/comment cannot hide another mapping field. Otherwise + // a later tarball key could override the one we just inserted. + if lines + .clone() + .find(|line| !line.trim().is_empty() && !line.trim_start().starts_with('#')) + .is_some_and(|line| line.starts_with(" ")) + { + return None; + } + range = begin..end; + } else { + let value = value.trim(); + let inner = value.strip_prefix('{')?.strip_suffix('}')?; + let mut quote = None; + let mut escaped = false; + let mut begin = 0; + for (i, c) in inner.char_indices() { + if escaped { + escaped = false; + continue; + } + if c == '\\' && quote == Some('"') { + escaped = true; + continue; + } + if let Some(q) = quote { + if c == q { + quote = None; + } + } else if matches!(c, '\'' | '"') { + quote = Some(c); + } else if c == ',' { + parts.push(inner[begin..i].trim()); + begin = i + 1; + } else if matches!(c, '{' | '}' | '[' | ']') { + return None; + } + } + if quote.is_some() { + return None; + } + parts.push(inner[begin..].trim()); + let begin = start + text.find('{')?; + range = begin..begin + value.len(); + } + let mut fields = Vec::new(); + for part in parts { + let (key, value) = part.split_once(':')?; + let value = value.trim(); + if key.is_empty() + || !key.bytes().all(|c| c.is_ascii_alphanumeric()) + || value.is_empty() + || value.starts_with(['&', '*', '!', '{', '[']) + || fields.iter().any(|(k, _)| *k == key) + { + return None; + } + fields.push((key, value)); + } + if fields.is_empty() { + return None; + } + return Some(Resolution { + range, + fields, + block, + newline: if line.ends_with("\r\n") { "\r\n" } else { "\n" }, + }); + } + None +} diff --git a/crates/socket-patch-core/tests/fixtures/pnpm-hosted/1.43.1/shrinkwrap.yaml b/crates/socket-patch-core/tests/fixtures/pnpm-hosted/1.43.1/shrinkwrap.yaml new file mode 100644 index 00000000..fb332063 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/pnpm-hosted/1.43.1/shrinkwrap.yaml @@ -0,0 +1,13 @@ +dependencies: + left-pad: 1.3.0 +packages: + /left-pad/1.3.0: + deprecated: use String.prototype.padStart() + dev: false + resolution: + integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA== +registry: 'https://registry.npmjs.org/' +shrinkwrapMinorVersion: 6 +shrinkwrapVersion: 3 +specifiers: + left-pad: 1.3.0 diff --git a/crates/socket-patch-core/tests/fixtures/pnpm-hosted/10.33.0/pnpm-lock.yaml b/crates/socket-patch-core/tests/fixtures/pnpm-hosted/10.33.0/pnpm-lock.yaml new file mode 100644 index 00000000..952f2955 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/pnpm-hosted/10.33.0/pnpm-lock.yaml @@ -0,0 +1,23 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + 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() + +snapshots: + + left-pad@1.3.0: {} diff --git a/crates/socket-patch-core/tests/fixtures/pnpm-hosted/11.27.0/pnpm-lock.yaml b/crates/socket-patch-core/tests/fixtures/pnpm-hosted/11.27.0/pnpm-lock.yaml new file mode 100644 index 00000000..952f2955 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/pnpm-hosted/11.27.0/pnpm-lock.yaml @@ -0,0 +1,23 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + 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() + +snapshots: + + left-pad@1.3.0: {} diff --git a/crates/socket-patch-core/tests/fixtures/pnpm-hosted/12.4.2/pnpm-lock.yaml b/crates/socket-patch-core/tests/fixtures/pnpm-hosted/12.4.2/pnpm-lock.yaml new file mode 100644 index 00000000..952f2955 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/pnpm-hosted/12.4.2/pnpm-lock.yaml @@ -0,0 +1,23 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + 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() + +snapshots: + + left-pad@1.3.0: {} diff --git a/crates/socket-patch-core/tests/fixtures/pnpm-hosted/2.25.7/shrinkwrap.yaml b/crates/socket-patch-core/tests/fixtures/pnpm-hosted/2.25.7/shrinkwrap.yaml new file mode 100644 index 00000000..20a4127c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/pnpm-hosted/2.25.7/shrinkwrap.yaml @@ -0,0 +1,13 @@ +dependencies: + left-pad: 1.3.0 +packages: + /left-pad/1.3.0: + deprecated: use String.prototype.padStart() + dev: false + resolution: + integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA== +registry: 'https://registry.npmjs.org/' +shrinkwrapMinorVersion: 9 +shrinkwrapVersion: 3 +specifiers: + left-pad: 1.3.0 diff --git a/crates/socket-patch-core/tests/fixtures/pnpm-hosted/3.8.1/pnpm-lock.yaml b/crates/socket-patch-core/tests/fixtures/pnpm-hosted/3.8.1/pnpm-lock.yaml new file mode 100644 index 00000000..6de57615 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/pnpm-hosted/3.8.1/pnpm-lock.yaml @@ -0,0 +1,11 @@ +dependencies: + left-pad: 1.3.0 +lockfileVersion: 5.1 +packages: + /left-pad/1.3.0: + deprecated: use String.prototype.padStart() + dev: false + resolution: + integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA== +specifiers: + left-pad: 1.3.0 diff --git a/crates/socket-patch-core/tests/fixtures/pnpm-hosted/4.14.4/pnpm-lock.yaml b/crates/socket-patch-core/tests/fixtures/pnpm-hosted/4.14.4/pnpm-lock.yaml new file mode 100644 index 00000000..6de57615 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/pnpm-hosted/4.14.4/pnpm-lock.yaml @@ -0,0 +1,11 @@ +dependencies: + left-pad: 1.3.0 +lockfileVersion: 5.1 +packages: + /left-pad/1.3.0: + deprecated: use String.prototype.padStart() + dev: false + resolution: + integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA== +specifiers: + left-pad: 1.3.0 diff --git a/crates/socket-patch-core/tests/fixtures/pnpm-hosted/5.18.11/pnpm-lock.yaml b/crates/socket-patch-core/tests/fixtures/pnpm-hosted/5.18.11/pnpm-lock.yaml new file mode 100644 index 00000000..172f0be2 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/pnpm-hosted/5.18.11/pnpm-lock.yaml @@ -0,0 +1,11 @@ +dependencies: + left-pad: 1.3.0 +lockfileVersion: 5.2 +packages: + /left-pad/1.3.0: + deprecated: use String.prototype.padStart() + dev: false + resolution: + integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA== +specifiers: + left-pad: 1.3.0 diff --git a/crates/socket-patch-core/tests/fixtures/pnpm-hosted/6.35.1/pnpm-lock.yaml b/crates/socket-patch-core/tests/fixtures/pnpm-hosted/6.35.1/pnpm-lock.yaml new file mode 100644 index 00000000..91c0c25b --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/pnpm-hosted/6.35.1/pnpm-lock.yaml @@ -0,0 +1,14 @@ +lockfileVersion: 5.3 + +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 diff --git a/crates/socket-patch-core/tests/fixtures/pnpm-hosted/7.33.7/pnpm-lock.yaml b/crates/socket-patch-core/tests/fixtures/pnpm-hosted/7.33.7/pnpm-lock.yaml new file mode 100644 index 00000000..d13bf187 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/pnpm-hosted/7.33.7/pnpm-lock.yaml @@ -0,0 +1,14 @@ +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 diff --git a/crates/socket-patch-core/tests/fixtures/pnpm-hosted/8.15.9/pnpm-lock.yaml b/crates/socket-patch-core/tests/fixtures/pnpm-hosted/8.15.9/pnpm-lock.yaml new file mode 100644 index 00000000..0b59d85e --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/pnpm-hosted/8.15.9/pnpm-lock.yaml @@ -0,0 +1,17 @@ +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 diff --git a/crates/socket-patch-core/tests/fixtures/pnpm-hosted/9.15.9/pnpm-lock.yaml b/crates/socket-patch-core/tests/fixtures/pnpm-hosted/9.15.9/pnpm-lock.yaml new file mode 100644 index 00000000..952f2955 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/pnpm-hosted/9.15.9/pnpm-lock.yaml @@ -0,0 +1,23 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + 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() + +snapshots: + + left-pad@1.3.0: {} diff --git a/crates/socket-patch-core/tests/pnpm_hosted.rs b/crates/socket-patch-core/tests/pnpm_hosted.rs new file mode 100644 index 00000000..00f58eea --- /dev/null +++ b/crates/socket-patch-core/tests/pnpm_hosted.rs @@ -0,0 +1,153 @@ +//! Captured package-manager output and grammar boundary regressions. Real +//! install/hash/VEX/rollback proofs live in e2e_redirect_pnpm_build. +use socket_patch_core::patch::redirect::{rewrite_registry_redirect, DepOverride, Integrity}; +use std::collections::BTreeMap; + +fn dep(name: &str) -> DepOverride { + let (namespace, name) = name + .rsplit_once('/') + .map_or((None, name), |(ns, n)| (Some(ns.to_string()), n)); + DepOverride { + ecosystem: "npm".into(), + name: name.into(), + namespace, + version: "1.3.0".into(), + token: "token".into(), + patch_uuid: "patch-id".into(), + artifact_url: "https://patch.example/left-pad-1.3.0.tgz".into(), + berry_zip_url: None, + registry_override: None, + integrity: Integrity { + sha512: Some("sha512-PATCHED==".into()), + ..Default::default() + }, + } +} + +fn roundtrip(path: &str, lock: &str, target: &DepOverride, instances: usize) { + let files = BTreeMap::from([(path.to_string(), lock.to_string())]); + let first = rewrite_registry_redirect(&files, std::slice::from_ref(target)); + assert!(first.warnings.is_empty(), "{path}: {:?}", first.warnings); + assert_eq!(first.edits.len(), instances, "{path}"); + let output = &first.files[path]; + assert_eq!(output.matches(&target.artifact_url).count(), instances); + // Every recorded inverse must restore exactly the captured upstream bytes. + let mut restored = output.clone(); + for edit in first.edits.iter().rev() { + restored = restored.replacen( + edit.new.as_ref().unwrap().as_str().unwrap(), + edit.original.as_ref().unwrap().as_str().unwrap(), + 1, + ); + } + assert_eq!(restored, lock, "{path}: lossless rollback fragments"); + let again = rewrite_registry_redirect(&first.files, std::slice::from_ref(target)); + assert!( + again.files.is_empty() && again.edits.is_empty(), + "{path}: rerun must be stable" + ); + assert!(again.warnings.is_empty(), "{path}: {:?}", again.warnings); +} + +#[test] +fn captured_locks_from_every_pnpm_major_roundtrip_in_lf_and_crlf() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/pnpm-hosted"); + let mut cases = 0; + for dir in std::fs::read_dir(root).unwrap() { + let dir = dir.unwrap().path(); + if !dir.is_dir() { + continue; + } + for file in std::fs::read_dir(dir).unwrap() { + let path = file.unwrap().path(); + let lock = std::fs::read_to_string(&path).unwrap(); + let name = path.file_name().unwrap().to_str().unwrap(); + for lock in [&lock, &lock.replace('\n', "\r\n")] { + roundtrip(name, lock, &dep("left-pad"), 1); + } + cases += 1; + } + } + assert_eq!(cases, 12, "every pinned major needs a real captured lock"); +} + +#[test] +fn scoped_peers_aliases_workspaces_and_bystanders_preserve_the_graph() { + for key in [ + "/@scope/left-pad/1.3.0_peer@2.0.0", + "/@scope/left-pad@1.3.0(peer@2.0.0(child@3.0.0))", + "@scope/left-pad@1.3.0", + ] { + for quote in ["", "'", "\""] { + if key.starts_with('@') && quote.is_empty() { + continue; + } + let lock = format!("lockfileVersion: '6.0'\nimporters:\n packages/app:\n dependencies:\n alias:\n specifier: npm:@scope/left-pad@1.3.0\n version: {key}\npackages:\n {quote}{key}{quote}:\n resolution: {{integrity: sha512-UPSTREAM==}}\n dependencies:\n child: 3.0.0\n /left-pad@1.3.0:\n resolution: {{integrity: sha512-BYSTANDER==}}\n /@scope/left-pad@1.3.0-beta.1:\n resolution: {{integrity: sha512-BYSTANDER==}}\nsnapshots:\n '@scope/left-pad@1.3.0(peer@2.0.0)': {{}}\n"); + roundtrip("pnpm-lock.yaml", &lock, &dep("@scope/left-pad"), 1); + } + } +} + +#[test] +fn malformed_resolution_refuses_the_dependency_across_all_locks() { + for bad in [ + "*shared", + "{integrity: one, integrity: two}", + "{integrity: one, tarball: two, tarball: three}", + "{integrity: one, extra: [nested]}", + "\n integrity: one\n\n tarball: https://unpatched.example/file.tgz", + "\n integrity: one\n nested:\n field: value", + ] { + let files = BTreeMap::from([ + ( + "pnpm-lock.yaml".into(), + "packages:\n left-pad@1.3.0:\n resolution: {integrity: sha512-UPSTREAM==}\n" + .into(), + ), + ( + "common/config/rush/pnpm-lock.yaml".into(), + format!("packages:\n /left-pad@1.3.0(peer@2.0.0):\n resolution: {bad}\n"), + ), + ]); + let result = rewrite_registry_redirect(&files, &[dep("left-pad")]); + assert!( + result.files.is_empty() && result.edits.is_empty(), + "{bad}: partial rewrite" + ); + assert!(result + .warnings + .iter() + .any(|w| w.code == "redirect_pnpm_unsupported_lock_key")); + } +} + +#[test] +fn quoted_flow_values_keep_commas_and_extra_fields() { + let lock = "packages:\n left-pad@1.3.0:\n resolution: {integrity: sha512-UPSTREAM==, tarball: 'https://registry.example/a,b.tgz', note: 'keep,a,b'}\n"; + let mut target = dep("left-pad"); + target.artifact_url = "https://patch.example/a,b.tgz".into(); + roundtrip("pnpm-lock.yaml", lock, &target, 1); +} + +#[test] +fn mixed_newlines_preserve_unrelated_bytes() { + let lock = "# comment\npackages:\r\n left-pad@1.3.0:\r\n resolution:\r\n integrity: sha512-UPSTREAM==\r\n\nsnapshots:\n left-pad@1.3.0: {}\n"; + roundtrip("pnpm-lock.yaml", lock, &dep("left-pad"), 1); +} + +#[test] +fn early_pnpm1_refuses_non_durable_shrinkwrap_across_all_locks() { + let old = "shrinkwrapVersion: 3\npackages:\n /left-pad/1.3.0:\n resolution:\n integrity: sha1-UPSTREAM=\n"; + let newer = "lockfileVersion: '9.0'\npackages:\n left-pad@1.3.0:\n resolution: {integrity: sha512-UPSTREAM==}\n"; + let files = BTreeMap::from([ + ("shrinkwrap.yaml".into(), old.into()), + ("pnpm-lock.yaml".into(), newer.into()), + ]); + let result = rewrite_registry_redirect(&files, &[dep("left-pad")]); + assert!(result.files.is_empty() && result.edits.is_empty()); + assert!(result.refused_pnpm_uuids.contains("patch-id")); + assert_eq!( + result.warnings[0].code, + "redirect_pnpm_legacy_lockfile_unsupported" + ); +} diff --git a/docs/ecosystems.md b/docs/ecosystems.md index a6673632..17291b25 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 | ✅ 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) | +| 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 and legacy shrinkwrap.yaml (pnpm majors 1–12; block and flow resolutions), 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` | ✅ uv project/script locks, PEP 751 `pylock.toml` / `pylock..toml`, 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. Native uv vendoring requires uv ≥ 0.2.35 (the `[[package]]` lock grammar); hosted mode covers native `uv.lock` from uv 0.1.45 (the first release whose `uv lock` writes one) and requirements from uv 0.0.5; see [uv compatibility](testing/uv-compatibility.md). | ✅ requirements.txt including hash continuations, uv project/script locks, and PEP 751 locks. Version/source ambiguity is refused; see [uv compatibility](testing/uv-compatibility.md). **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` — needs bundler ≥ 2.2 (1.x cannot load `plugin ... path:` directives; `setup` refuses below the floor and `setup --check` red-flags a wired 1.x project) | ✅ 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); a stale pre-redirect materialization that `bundle install` would reuse instead of refetching is flagged `redirect_gem_stale_install` with a prescriptive remedy (see CLI_CONTRACT.md's "Gem stale-install guard") | @@ -35,17 +35,25 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. ## npm hosted-mode notes -- **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. +- **pnpm** — hosted rewriting supports legacy `shrinkwrap.yaml` (pnpm 1/2), + lockfileVersion 5.x (pnpm 3–7), 6.0 (pnpm 8), and 9.0 (pnpm 9–12). + Early pnpm 1 locks with shrinkwrapVersion 3 and no positive minor version + are refused because those installers discard hosted URLs; the tested + pnpm 1 floor is 1.43.1. Upgrade and regenerate that lock, or use agent mode. + Block and flow resolutions, scoped names, aliases, nested peer contexts, + workspaces, nested Rush locks, and LF/CRLF line endings are handled. Every + matching package instance is rewritten; an unsupported instance prevents + confirming that dependency across the lockfile set. Rollback preserves the + original resolution fragments. + For root 9.0 locks, the CLI configures `trustLockfile: true` in + `pnpm-workspace.yaml` unless opted out with `--no-trust-lockfile-config` or + explicitly disabled by the project. pnpm >=11 needs this for hosted URLs. + This skips registry re-verification for the whole lock; tarball integrity + remains enforced. pnpm <=10 does not need the setting. + **Reinstall after redirecting:** a successful warm-cache install can retain + upstream bytes. Use a clean install tree and an empty store; `--force` is not + a reliable substitute. Run `socket-patch vex` after installation to verify + the patched files. See the [compatibility matrix and workflow](testing/pnpm-compatibility.md). - **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 diff --git a/docs/testing/hosted-production-e2e.md b/docs/testing/hosted-production-e2e.md index f6a1f694..b0817063 100644 --- a/docs/testing/hosted-production-e2e.md +++ b/docs/testing/hosted-production-e2e.md @@ -143,27 +143,21 @@ answers 200 with an empty body. Unreachable today — bundler only falls back to the Dependency fetcher when the compact index is unavailable — but it would resurface as a confusing Marshal error if the compact index ever broke again. -### 2. `pnpm` — pnpm 11 rejects hosted lockfiles by default (CLI UX gap) - -pnpm 11 added a lockfile supply-chain policy that compares every entry's tarball -URL against the registry's published metadata. Hosted mode deliberately rewrites -that URL, so the policy rejects the lockfile: - -``` -[ERR_PNPM_TARBALL_URL_MISMATCH] minimist@1.2.2 has a tarball URL -(https://patch.socket.dev/...) that does not match the registry's published -metadata (https://registry.npmjs.org/minimist/-/minimist-1.2.2.tgz) -``` - -`pnpm install --trust-lockfile` is pnpm's documented opt-out and works (verified: -the patched artifact installs cleanly). Neither `--trust-policy-exclude` nor -`--no-verify-store-integrity` helps — this is a distinct check. - -**Fix belongs in the CLI**: `scan --mode hosted` should emit a `redirect_pnpm_*` -warning naming `--trust-lockfile` when it rewrites a `pnpm-lock.yaml`, the way it -already warns for `redirect_gem_no_checksums_section` and -`redirect_rush_repo_state_stale`. The suite currently retries with the flag and -reports the gap loudly. +### 2. `pnpm` — trust configuration and cache handling + +The CLI now configures `trustLockfile: true` for root lockfileVersion 9 projects +unless the project explicitly disables it or the user passes +`--no-trust-lockfile-config`. This accepts hosted tarball URLs on pnpm >=11; +it disables registry re-verification for the entire lock while retaining +per-artifact integrity checks. The historical missing-warning gap is closed. + +An installed tree or warm store can still retain upstream bytes. Use a clean +install tree and an empty store, then verify with `socket-patch vex`. +`--force` alone is not a portable recovery. The required +[pnpm compatibility matrix](pnpm-compatibility.md) tests this distinction, +integrity rejection, peer instances, and rollback across pnpm majors 1–12. +The production pnpm test proves installation from the public hosted service; +it does not test the SBOM backend, dashboard badges, policies, or alert counts. ### 3. `uv.lock` — the `sdist` entry is rewritten to a wheel URL (CLI, minor) diff --git a/docs/testing/pnpm-compatibility.md b/docs/testing/pnpm-compatibility.md new file mode 100644 index 00000000..d4dd3507 --- /dev/null +++ b/docs/testing/pnpm-compatibility.md @@ -0,0 +1,100 @@ +# pnpm hosted compatibility + +Hosted mode changes the locked artifact URL and integrity while keeping the +package's name and version. Installing that artifact, verifying its files, +recognizing it in an SBOM, and changing an alert's policy/count are separate +operations. This repository tests the first two; these tests do not assert +Socket dashboard detection or alert resolution. + +## Required matrix + +`.github/workflows/pnpm-compatibility.yml` runs on pull requests and pushes to +`main`. It compiles the CLI/test once, installs the exact package-manager +versions below, and treats missing tools and failed fixture installs as errors. +The matrix needs no Socket API token. The package managers are real; patch +discovery and tarball endpoints are local test servers. + +| pnpm versions | Node | Lock format | +| --- | --- | --- | +| 1.0.0 | 10.24.1 | Expected refusal: shrinkwrapVersion 3 without a minor version discards hosted URLs | +| 1.43.1, 2.0.0, 2.25.7 | 10.24.1 | shrinkwrapVersion 3 with a minor version, `shrinkwrap.yaml`, block resolutions | +| 3.0.0, 3.8.1 | 10.24.1 | 5.1, block resolutions | +| 4.0.0, 4.14.4, 5.0.0, 5.18.11 | 16.20.2 | 5.1 / 5.2, block resolutions | +| 6.0.0, 6.35.1 | 16.20.2 | 5.3 | +| 7.0.0, 7.33.7 | 16.20.2 | 5.4 | +| 8.0.0, 8.15.9 | 16.20.2 | 6.0, per-peer package resolutions | +| 9.0.0, 9.15.9 | 24.11.1 | 9.0, packages/snapshots split | +| 10.0.0, 10.33.0, 10.34.5 | 24.11.1 | 9.0 | +| 11.0.0, 11.27.0, 12.0.0, 12.4.2 | 24.11.1 | 9.0, hosted URLs require lockfile trust | + +This samples each released major from 1 through 12 and important major +boundaries; it is not an exhaustive test of every historical patch release or +future release. Pre-lockfile releases cannot use hosted lockfile redirection. +Vendored mode has a separate compatibility contract in `docs/ecosystems.md`. + +The 24 supported versions exercise scan and explicit-UUID hosted entry points, idempotency, +warm installs, verified VEX, a clean reinstall, a fresh frozen install with a +dead registry and empty store, an ordinary install, byte-exact rollback, +lock-only discovery, and rejection of tampered hosted tarballs. pnpm >=6 also +gets a local-registry workspace fixture with a scoped package, an npm alias, +two peer contexts, and peer dependencies of peers. Legacy workspace behavior +before pnpm 6 is not covered by that fixture. + +The remaining row, pnpm 1.0.0, verifies an explicit refusal with the lockfile +unchanged and no successful redirect recorded. Its installer removes the +hosted URL even on a frozen install. For early shrinkwrapVersion 3 locks with +no positive minor version, upgrade to a tested release (1.43.1 or newer) and +regenerate the lock before redirecting, or use installed-file agent mode. + +Captured locks under `crates/socket-patch-core/tests/fixtures/pnpm-hosted/` +keep grammar coverage in ordinary offline tests. Additional tests cover +LF/CRLF, nested peer suffixes, quoted keys and URLs, unrelated versions, +malformed mappings, and refusal across multiple locks. A CLI regression test +ensures a hosted URL in one instance cannot confirm a partially patched package. + +## Reliable installation + +`scan --mode hosted` changes lockfiles; it does not replace already installed +files. Several pnpm versions reuse the original files from an existing install +or store even when installation succeeds. `--force` alone is not portable and +can re-resolve upstream artifacts on pnpm 12. + +For CI, install the **patched branch** in a clean checkout without any +`node_modules` directories, using a new empty store for the post-patch install: + +```sh +pnpm install --frozen-lockfile --store-dir "$RUNNER_TEMP/socket-patch-store-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" +socket-patch vex --output socket-patches.openvex.json +``` + +The store path must be empty and must not be restored from a cache. On pnpm +1–4 use `--store`; pnpm 1–3 can silently ignore `--store-dir`, and early pnpm 4 +rejects it. For a workspace, the clean checkout requirement includes +member `node_modules` trees. Keep the redirected lockfile and any generated +`pnpm-workspace.yaml`; do not delete or regenerate the lock to work around an +installation error. VEX uses installed-file verification by default; exporting +VEX does not automatically upload it or change Socket alert counts. + +## Run locally + +```sh +cargo test -p socket-patch-core --test pnpm_hosted --test redirect_golden +cargo test -p socket-patch-cli --test in_process_redirect_pnpm + +# Provision the chosen pnpm executable and a compatible Node on PATH first. +SOCKET_PATCH_PNPM_E2E_BIN=/absolute/path/to/pnpm \ +SOCKET_PATCH_PNPM_E2E_VERSION=10.33.0 \ +SOCKET_PATCH_PNPM_E2E_REQUIRED=1 \ +cargo test -p socket-patch-cli --test e2e_redirect_pnpm_build \ + pnpm_pinned_matrix -- --ignored --nocapture + +# Separate production-service proof, with pnpm on PATH; no API token needed. +SOCKET_PATCH_HOSTED_E2E_STRICT=1 \ +cargo test -p socket-patch-cli --test e2e_hosted_production \ + pnpm_hosted_install_proof -- --ignored --nocapture +``` + +The required test verifies the reported pnpm version. In particular, an +unavailable legacy runtime must fail setup rather than remove a matrix row +from coverage. Hosted tests isolate package-manager caches and allocate +independent empty stores for each installation proof.