Skip to content

Commit b19c968

Browse files
Fix pnpm hosted redirects across legacy and peer lock entries (#240)
* Fix pnpm hosted lock rewrites and partial confirmation * Keep pnpm refusal regression focused on malformed peer keys * Accept Bundler 1 gem layout in installer regression test
1 parent 745e8dc commit b19c968

25 files changed

Lines changed: 1470 additions & 426 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
name: pnpm hosted compatibility
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches: [main]
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
build:
14+
runs-on: ubuntu-latest
15+
timeout-minutes: 20
16+
steps:
17+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
18+
with:
19+
persist-credentials: false
20+
- name: Compile CLI and pnpm integration test once
21+
run: |
22+
cargo test --locked -p socket-patch-cli --test e2e_redirect_pnpm_build --no-run --message-format=json > target-build.json
23+
python3 - <<'PY'
24+
import json, pathlib, shutil
25+
dest = pathlib.Path('target/pnpm-e2e')
26+
dest.mkdir(parents=True, exist_ok=True)
27+
shutil.copy2('target/debug/socket-patch', dest / 'socket-patch')
28+
for line in pathlib.Path('target-build.json').read_text().splitlines():
29+
item = json.loads(line)
30+
if item.get('target', {}).get('name') == 'e2e_redirect_pnpm_build' and item.get('executable'):
31+
shutil.copy2(item['executable'], dest / 'pnpm-e2e')
32+
assert (dest / 'pnpm-e2e').is_file()
33+
PY
34+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
35+
with:
36+
name: pnpm-e2e
37+
path: target/pnpm-e2e/
38+
if-no-files-found: error
39+
retention-days: 7
40+
41+
install-proof:
42+
needs: build
43+
runs-on: ubuntu-latest
44+
timeout-minutes: 10
45+
strategy:
46+
fail-fast: false
47+
matrix:
48+
include:
49+
# 1.0.0 must explicitly refuse its non-durable shrinkwrap format.
50+
- {pnpm: '1.0.0', node: '10.24.1'}
51+
- {pnpm: '1.43.1', node: '10.24.1'}
52+
- {pnpm: '2.0.0', node: '10.24.1'}
53+
- {pnpm: '2.25.7', node: '10.24.1'}
54+
- {pnpm: '3.0.0', node: '10.24.1'}
55+
- {pnpm: '3.8.1', node: '10.24.1'}
56+
- {pnpm: '4.0.0', node: '16.20.2'}
57+
- {pnpm: '4.14.4', node: '16.20.2'}
58+
- {pnpm: '5.0.0', node: '16.20.2'}
59+
- {pnpm: '5.18.11', node: '16.20.2'}
60+
- {pnpm: '6.0.0', node: '16.20.2'}
61+
- {pnpm: '6.35.1', node: '16.20.2'}
62+
- {pnpm: '7.0.0', node: '16.20.2'}
63+
- {pnpm: '7.33.7', node: '16.20.2'}
64+
- {pnpm: '8.0.0', node: '16.20.2'}
65+
- {pnpm: '8.15.9', node: '16.20.2'}
66+
- {pnpm: '9.0.0', node: '24.11.1'}
67+
- {pnpm: '9.15.9', node: '24.11.1'}
68+
- {pnpm: '10.0.0', node: '24.11.1'}
69+
- {pnpm: '10.33.0', node: '24.11.1'}
70+
- {pnpm: '10.34.5', node: '24.11.1'}
71+
- {pnpm: '11.0.0', node: '24.11.1'}
72+
- {pnpm: '11.27.0', node: '24.11.1'}
73+
- {pnpm: '12.0.0', node: '24.11.1'}
74+
- {pnpm: '12.4.2', node: '24.11.1'}
75+
steps:
76+
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
77+
with:
78+
name: pnpm-e2e
79+
path: bin
80+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
81+
with:
82+
node-version: '24.11.1'
83+
- name: Install the pinned package manager
84+
env:
85+
PNPM_TEST_VERSION: ${{ matrix.pnpm }}
86+
run: |
87+
npm install --prefix "$RUNNER_TEMP/pnpm-tool" --no-audit --no-fund "pnpm@$PNPM_TEST_VERSION"
88+
echo "SOCKET_PATCH_PNPM_E2E_BIN=$RUNNER_TEMP/pnpm-tool/node_modules/.bin/pnpm" >> "$GITHUB_ENV"
89+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
90+
with:
91+
node-version: ${{ matrix.node }}
92+
- name: Require installation, verified VEX, rollback and integrity rejection
93+
env:
94+
SOCKET_PATCH_PNPM_E2E_VERSION: ${{ matrix.pnpm }}
95+
SOCKET_PATCH_PNPM_E2E_REQUIRED: '1'
96+
SOCKET_NO_CONFIG: '1'
97+
SOCKET_NO_UPDATE_CHECK: '1'
98+
run: |
99+
chmod +x bin/socket-patch bin/pnpm-e2e
100+
export SOCKET_PATCH_PNPM_E2E_SOCKET_BIN="$PWD/bin/socket-patch"
101+
bin/pnpm-e2e pnpm_pinned_matrix --ignored --nocapture

crates/socket-patch-cli/CLI_CONTRACT.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,15 @@ Beyond the globals above, each subcommand defines a small set of local arguments
8686
| `repair` | `--download-only` | `SOCKET_DOWNLOAD_ONLY` | Repair-specific cleanup mode (mutually exclusive with `--offline`; combining them is a usage error, exit 2) |
8787
| `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) |
8888

89-
**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.
89+
**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.
90+
91+
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.
92+
93+
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.
94+
95+
`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.
96+
97+
**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.
9098

9199
`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.
92100

@@ -112,7 +120,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments
112120

113121
`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/<uuid>` 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 <purl>` 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.
114122

115-
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/<name>/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 `<version>-socket.<hex8>` by rewriting the literal `<version>` (`redirect_maven_dep_version`) or adding a `<dependencyManagement>` 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).
123+
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/<name>/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 `<version>-socket.<hex8>` by rewriting the literal `<version>` (`redirect_maven_dep_version`) or adding a `<dependencyManagement>` 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).
116124

117125
**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 <gem>` 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/<leaf>.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.
118126

0 commit comments

Comments
 (0)