diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4f8067ea..cce660612 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1397,7 +1397,7 @@ jobs: # The composer capstones shell out to a real composer; `composer:` # pins the release line (1, 2.2 LTS, 2) so the composer.lock grammar # the edits assert stays stable across runners. - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: '8.2' tools: composer:${{ matrix.composer }} diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 84dc7475c..ac4f87bdc 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -140,7 +140,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Throttling: bounded retry, then a reported failure.** Every patch-API JSON call (the batch query, the per-package patch lists, patch views and VEX record fetches, hosted package references) retries an HTTP `429` or `503` answer up to 3 times (`SOCKET_API_MAX_RETRIES=`, `0`-`10`; `0` = no retry). The wait honors `Retry-After` (delta-seconds or HTTP-date); a `Retry-After` over 30 s is not waited out — the answer is final at once — and one under the jittered first backoff step (`0`, a past date) waits that step instead. Without one it backs off 0.5 s / 1 s / 2 s (each step up to 8 s, with jitter in its upper half). All retries in one run share a 60 s wall-clock window that opens with the run's first retry: a retry whose wait would end after it closes is refused and the answer is final. Parallel requests wait in parallel, so each still gets its retries while the run adds at most about 60 s. Nothing else is retried (401/403 still drive the proxy fallback on the first answer; the public proxy's permanent `503 "Patch API is not configured"` is never retried on any path — the batch query still degrades to the per-package path at once, and a per-package lookup or patch view answering it is the same non-throttle failure it always was, so the legacy per-package path still skips that package), and a retried answer folds exactly where the first attempt's would have, so output is identical to an unthrottled run's. A request still throttled after that is a failure in the channel its siblings use: a failed batch is the human `Warning: API batch of failed: ` line and, under `--json`, a run-level `warnings[]` entry `{code: "api_batch_failed", detail: "API batch of failed: "}` (additive; `status` stays `success`, exit 0 — the other batches' packages are reported); a failed per-package patch-list query in the agent / hosted / vendored flows is the human `Warning: could not fetch details for : ` line and, under `--json`, `{code: "patch_details_failed", detail: "could not fetch details for : "}`. When every batch (or every patch-list query) fails, the existing all-failed error envelope and exit 1 apply. The error names the exhausted retry: `Rate limit exceeded (HTTP 429, gave up after 3 retries). Please try again later.` / `API request failed with status 503: (gave up after 3 retries)` (or `(Retry-After s exceeds the 30 s retry cap)` / `(the run's 60 s retry window has closed)`); with retries off it is the pre-retry text. On the token-less legacy per-package proxy path (a proxy without `POST /patch/batch`), a package still throttled (429 / over-capacity 503) after its retries fails its whole batch query, so every package in that batch goes unchecked and is reported through the batch-failure channel above (an unresolvable PURL, or a "not configured" 503, is still skipped individually). Pinned by `tests/scan_api_retry_e2e.rs` and the core crate's `tests/api_retry_e2e.rs`. -**Lockfile supplement (v3.4)**: `scan` discovery is no longer limited to installed trees. The project's lockfiles (`package-lock.json`/`npm-shrinkwrap.json`, `pnpm-lock.yaml` v9, `yarn.lock` classic + berry, `bun.lock`, `vlt-lock.json` (registry nodes, Socket-hosted pins included; vendored `file` nodes are left to the vendor ledger), `Cargo.lock`, `go.sum`, `composer.lock`, `Gemfile.lock`, `uv.lock`/`poetry.lock`/pinned `requirements.txt`) are inventoried and dependencies with NO installed copy join discovery — counts, the API lookup, the table (flagged ` [NOT INSTALLED]`, plus a stderr note), and the prune "scanned" set (a wiped node_modules no longer prunes lockfile-listed entries). JSON gains a top-level `lockfileOnlyPackages` count and an additive `notInstalled: true` on matching `packages[]` entries. `--apply` partitions lockfile-only patches out BEFORE download (calm `skipped`/`package_not_installed` records — never an error exit, never a manifest write); `--vendor` passes them through to the vendor engine's server download. Vendored-ledger entries likewise stay discoverable on a fresh clone (the committed artifact is the dependency). Global scans (`--global`) get no supplement. **Rush monorepos** (no root lockfile, `rush.json` present): the npm-lock inventory falls back to the Rush source-of-truth locks — `common/config/rush/pnpm-lock.yaml` plus every `common/config/subspaces/*/pnpm-lock.yaml` (`read_dir`-sorted, repo-relative paths preserved) — so a Rush repo's dependencies still join discovery. **Plug'n'Play layouts are an explicit refusal, not an empty inventory**: a `.pnp.*` loader means the npm packages are structurally unreachable in EVERY mode (under yarn PnP the installed-tree crawl is empty too — no `node_modules/`), so `scan` surfaces an additive top-level `warnings[]` array (`{code, detail}` objects, omitted when empty) carrying `yarn_pnp_unsupported` (same code as apply's refusal; remedy `yarn patch `) or `pnpm_pnp_unsupported` (pnpm's `node-linker=pnp` twin; pnpm remedies), plus a stderr `Warning: …` line on the human path. Exit code and `status` are deliberately unchanged (exit 0 / `success` — the same posture as hosted refusals, which exit 0 with `redirected: 0`); the warning is the machine-readable signal that nothing was checked. Pinned by `tests/e2e_safety_yarn_pnp.rs`. +**Lockfile supplement (v3.4)**: `scan` discovery is no longer limited to installed trees. The project's lockfiles (`package-lock.json`/`npm-shrinkwrap.json`, `pnpm-lock.yaml` v9, `yarn.lock` classic + berry, `bun.lock`, `vlt-lock.json` (registry nodes, Socket-hosted pins included; vendored `file` nodes are left to the vendor ledger), `Cargo.lock`, `go.sum`, `composer.lock`, `Gemfile.lock`, `uv.lock`/`poetry.lock`/pinned `requirements.txt`) are inventoried and dependencies with NO installed copy join discovery — counts, the API lookup, the table (flagged ` [NOT INSTALLED]`, plus a stderr note), and the prune "scanned" set (a wiped node_modules no longer prunes lockfile-listed entries). JSON gains a top-level `lockfileOnlyPackages` count and an additive `notInstalled: true` on matching `packages[]` entries. `--apply` partitions lockfile-only patches out BEFORE download (calm `skipped`/`package_not_installed` records — never an error exit, never a manifest write); `--vendor` passes them through to the vendor engine's server download. Vendored-ledger entries likewise stay discoverable on a fresh clone (the committed artifact is the dependency). Global scans (`--global`) get no supplement. **Rush monorepos** (no root lockfile, `rush.json` present): the npm-lock inventory falls back to the Rush source-of-truth locks — `common/config/rush/pnpm-lock.yaml` plus every `common/config/subspaces/*/pnpm-lock.yaml` (`read_dir`-sorted, repo-relative paths preserved) — so a Rush repo's dependencies still join discovery. **Plug'n'Play layouts are an explicit refusal, not an empty inventory**: a `.pnp.*` loader means the npm packages are structurally unreachable in EVERY mode (under yarn PnP the installed-tree crawl is empty too — no `node_modules/`), so `scan` surfaces an additive top-level `warnings[]` array (`{code, detail}` objects, omitted when empty) carrying `yarn_pnp_unsupported` (same code as apply's refusal; remedy `yarn patch `) or `pnpm_pnp_unsupported` (pnpm's `node-linker=pnp` twin; pnpm remedies), plus a stderr `Warning: …` line on the human path. Exit code and `status` are deliberately unchanged (exit 0 / `success` — the same posture as hosted refusals, which exit 0 with `redirected: 0`); the warning is the machine-readable signal that nothing was checked. Pinned by `tests/e2e_safety_yarn_pnp.rs`. **A Bundler lock socket-patch cannot read is likewise a warning, not an empty inventory**: when the project holds gem files but bundler loads no `Gemfile.lock` / `gems.locked` socket-patch reads (`BUNDLE_GEMFILE` or bundler 4's `BUNDLE_LOCKFILE` naming another file, or a `Gemfile` + `gems.rb` twin, whose loaded pair depends on the bundler major that runs), `warnings[]` carries the additive `gem_lock_unsupported` (detail names the setting or the twin) and its lockfile-only gems are not discovered. Same exit-0 / `success` posture. **Server artifact acquisition (v5.0)**: vendoring downloads the patched artifact for the selected UUID, including on a fresh checkout with no installed package. The CLI no longer downloads pristine packages, stages patch blobs, applies patches to vendor copies, or constructs archives. Backend lock and package-identity checks still run before acquisition; git and custom-registry Cargo sources are refused with `vendor_source_unsupported`. Healthy committed artifacts are reused offline. A changed UUID downloads a fresh server artifact; an unhealthy artifact at the recorded UUID uses the exact redownload procedure below, except that a directory copy whose ledger entry has no file inventory (vendored before v5.0) is rebuilt by its backend from a fresh verified download, and a missing or stale Bun workspace mirror over a healthy canonical tarball is rewritten from that tarball by the backend; those cases report the backend's own failure codes. @@ -161,7 +161,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Vendored entries and the rest of the CLI.** Because nothing is in the manifest, vendored patches are invisible to `apply` (nothing to apply in place) but fully visible to `list` (listed from the ledger, labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode, exit 0 on a vendored-only project), `vex` (attested from the embedded records while a lockfile still wires the artifact — see "Manifest-less VEX"), `repair` (health-checked and rebuilt from the ledger), and `scan --prune` (lockfile-driven reconcile). They are exempt from standalone `vendor`'s manifest reconcile (`reconcile_dropped` never touches `detached` entries) and exit via `remove ` (which reverts them), `vendor --revert`, or `rollback`, whose vendored leg reverts every in-scope ledger entry (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). -`scan --mode hosted` 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) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. Gradle is confirmed the same way (`confirmed_gradle_uuids`): only when the final files hold the owned script, the index row, the live apply line in every build's settings file and the suffixed version in every lock entry of the GA (see [Gradle builds](#gradle-builds-v50)). A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). A vendored PyPI package (requirements.txt, Poetry, Pipenv, uv, Hatch, PDM, pylock) is taken over the same way: its vendored wiring is restored to the recorded registry entry, its ledger entry and wheel are removed, and only then is it redirected. The Python rewriters treat any non-registry source as user-authored, so without the revert they refused socket-patch's own vendored source and left the project vendored. A takeover revert that leaves vendored wiring in place is refused with `redirect_vendored_revert_failed`. That covers a drift-skipped record (`vendor_lock_entry_drifted`) and a reverted file that still references the artifact (`vendor_revert_residual_reference`). The ledger entry and artifact are kept, and the package stays vendored and skipped. `--dry-run` predicts the same refusal from the same signals instead of previewing `redirect_would_revert_vendored`. The hosted requirements.txt rewriter only rewrites an existing pin in the root `requirements.txt`, so a vendored requirements.txt package whose wiring is a pin in a `-r` include or a `(transitive)` line vendored mode appended is refused BEFORE its revert, wet and `--dry-run` alike, with `redirect_requirements_takeover_unreachable` (`redirect.warnings[]`, and `redirect.skipped[].reason`). Its wiring, ledger entry and wheel are kept, so it stays vendored and patched (exit 0). The uv and Poetry rewriters are gated the same way, from the ledger entry and the lock on disk: a vendored uv package whose recorded pre-vendor `uv.lock` entry is at another version than the patch (vendored uv pins the entry down to the patch's version; the revert brings the lock's own version back, and hosted mode only pins the version the lock resolves) is refused with `redirect_uv_takeover_version_unreachable`, and a vendored Poetry package on a Poetry 0.x lock (which hosted mode refuses outright) is refused with `redirect_poetry_lock_unsupported`. A taken-over package whose wiring was reverted but that was then not pinned to hosted now installs the unpatched registry release in both modes. Causes include a refused lock, unavailable hosted wheel metadata, or a vendored ledger update that failed after the revert (refused with `redirect_vendored_revert_failed`). It is reported as `redirect_takeover_unpatched` with `status: "partial_failure"` and exit 1, never as success. That warning also prints under `--silent`. Human output prints no `Migrated …` progress line for the package and no "keep the hosted patches" next steps. Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, patches, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). `patches` (additive, v5.0) is the per-purl outcome of every selected patch, sorted by purl: `{purl, uuid, action}` with `action` `pinned` (`would_pin` under `--dry-run`; `redirected` counts these), `skipped` (`errorCode` = the `skipped[]` reason, `error` = its detail when it has one), or `unpinned` (`errorCode: redirect_unconfirmed` — the patch was granted but no lockfile entry pinning it could be rewritten; the human output's `Not hosted : …` line). An `unpinned` or `skipped` row does not change `status` or the exit code (the hosted exit policy is an open decision, #704). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — `BUNDLE_GEMFILE:` in the bundler app config, which outranks the environment variable as in `Bundler::Settings`, else the environment variable — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_gem_mirror_overrides_source` (gem: Bundler's all-source, exact patch-source or patch-hostname mirror can route the per-dep `source` block to an unpatched upstream gem. Intake reads the app config (`BUNDLE_APP_CONFIG`, where a set-but-empty value selects `/config`, honoring `BUNDLE_IGNORE_CONFIG`) and all `BUNDLE_MIRROR__...` variables visible to the scan; app config overrides the environment per encoded key, then `mirror.all` takes precedence over exact source, which takes precedence over hostname. URI matching follows Bundler's whole-URI case folding, default-port/trailing-slash normalization and single slash key alias, not URL prefixes. An exact-source fallback-timeout key without a mirror URL shadows the hostname mirror and fetches that source directly; a configured URL is conservatively refused even if a timeout could bypass an unreachable mirror at install time. Like `redirect_gem_bundle_gemfile_unsupported`, the gate leaves the Gemfile pair byte-identical and confirms no gem redirect. On an embedded `scan --vex`, rediscovered older hosted gem pins may attest only from verified installed bytes: a missing tree is not excused by the lockfile, and `--vex-no-verify` omits those hosted gems with `mirror_overrides_source` rather than trusting their intercepted source. Agent/vendored evidence, unrelated ecosystems and standalone VEX behavior are unchanged. Details identify the setting form and its app/environment origin without printing mirror values or source URLs, which may contain credentials. Remove the applicable all/source/hostname setting (including any slash alias) from that origin and reuse its existing mirror URL under `mirror.https://rubygems.org` to clear the refusal; an environment setting must be unset in the scan/install environment. User-global Bundler config and mirrors set only in a later install environment are not inspected; keep those mirrors scoped to the upstream source too), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also additive: `redirect_gem_version_not_locked` (gem: no `GEM` section of the lock lists the crawled `name (version)`, for example a version another project installed into the shared gem home; the gem is skipped with nothing written, so the user's declared constraint and the locked version are never overwritten). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. 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. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). The root `package.json`, which the rewrite re-renders to add `resolutions`, gets the same gate: a mixed one is refused untouched with the same code — the decision vendored mode takes with `vendor_yarn_berry_mixed_line_endings`, from the same shared berry gate set. This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). +`scan --mode hosted` 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) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. Gradle is confirmed the same way (`confirmed_gradle_uuids`): only when the final files hold the owned script, the index row, the live apply line in every build's settings file and the suffixed version in every lock entry of the GA (see [Gradle builds](#gradle-builds-v50)). A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). A vendored PyPI package (requirements.txt, Poetry, Pipenv, uv, Hatch, PDM, pylock) is taken over the same way: its vendored wiring is restored to the recorded registry entry, its ledger entry and wheel are removed, and only then is it redirected. The Python rewriters treat any non-registry source as user-authored, so without the revert they refused socket-patch's own vendored source and left the project vendored. A takeover revert that leaves vendored wiring in place is refused with `redirect_vendored_revert_failed`. That covers a drift-skipped record (`vendor_lock_entry_drifted`) and a reverted file that still references the artifact (`vendor_revert_residual_reference`). The ledger entry and artifact are kept, and the package stays vendored and skipped. `--dry-run` predicts the same refusal from the same signals instead of previewing `redirect_would_revert_vendored`. The hosted requirements.txt rewriter only rewrites an existing pin in the root `requirements.txt`, so a vendored requirements.txt package whose wiring is a pin in a `-r` include or a `(transitive)` line vendored mode appended is refused BEFORE its revert, wet and `--dry-run` alike, with `redirect_requirements_takeover_unreachable` (`redirect.warnings[]`, and `redirect.skipped[].reason`). Its wiring, ledger entry and wheel are kept, so it stays vendored and patched (exit 0). The uv and Poetry rewriters are gated the same way, from the ledger entry and the lock on disk: a vendored uv package whose recorded pre-vendor `uv.lock` entry is at another version than the patch (vendored uv pins the entry down to the patch's version; the revert brings the lock's own version back, and hosted mode only pins the version the lock resolves) is refused with `redirect_uv_takeover_version_unreachable`, and a vendored Poetry package on a Poetry 0.x lock (which hosted mode refuses outright) is refused with `redirect_poetry_lock_unsupported`. A taken-over package whose wiring was reverted but that was then not pinned to hosted now installs the unpatched registry release in both modes. Causes include a refused lock, unavailable hosted wheel metadata, or a vendored ledger update that failed after the revert (refused with `redirect_vendored_revert_failed`). It is reported as `redirect_takeover_unpatched` with `status: "partial_failure"` and exit 1, never as success. That warning also prints under `--silent`. Human output prints no `Migrated …` progress line for the package and no "keep the hosted patches" next steps. Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, patches, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). `patches` (additive, v5.0) is the per-purl outcome of every selected patch, sorted by purl: `{purl, uuid, action}` with `action` `pinned` (`would_pin` under `--dry-run`; `redirected` counts these), `skipped` (`errorCode` = the `skipped[]` reason, `error` = its detail when it has one), or `unpinned` (`errorCode: redirect_unconfirmed` — the patch was granted but no lockfile entry pinning it could be rewritten; the human output's `Not hosted : …` line). An `unpinned` or `skipped` row does not change `status` or the exit code (the hosted exit policy is an open decision, #704). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — `BUNDLE_GEMFILE:` in the bundler app config, which outranks the environment variable as in `Bundler::Settings`, else the environment variable — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_gem_bundle_lockfile_unsupported` (gem: bundler 4's custom lockfile — the `BUNDLE_LOCKFILE` environment variable, else `BUNDLE_LOCKFILE:` in the bundler app config, else in the global config — names a lock other than the loaded pair's own `Gemfile.lock` / `gems.locked`, so no gem is redirected or attested rather than pinning a lock bundler ignores), `redirect_gem_twin_manifest_ambiguous` (gem: a `Gemfile` + `gems.rb` twin under default discovery; bundler 1.x loads the `Gemfile` and bundler ≥ 2 loads `gems.rb`, and a lock's `BUNDLED WITH` records which bundler wrote it, not which one installs it, so neither pair is wired or attested — remove the unused spelling or set `BUNDLE_GEMFILE` to the one in use), `redirect_gem_mirror_overrides_source` (gem: Bundler's all-source, exact patch-source or patch-hostname mirror can route the per-dep `source` block to an unpatched upstream gem. Intake reads the app config (`BUNDLE_APP_CONFIG`, where a set-but-empty value selects `/config`, honoring `BUNDLE_IGNORE_CONFIG`) and all `BUNDLE_MIRROR__...` variables visible to the scan; app config overrides the environment per encoded key, then `mirror.all` takes precedence over exact source, which takes precedence over hostname. URI matching follows Bundler's whole-URI case folding, default-port/trailing-slash normalization and single slash key alias, not URL prefixes. An exact-source fallback-timeout key without a mirror URL shadows the hostname mirror and fetches that source directly; a configured URL is conservatively refused even if a timeout could bypass an unreachable mirror at install time. Like `redirect_gem_bundle_gemfile_unsupported`, the gate leaves the Gemfile pair byte-identical and confirms no gem redirect. On an embedded `scan --vex`, rediscovered older hosted gem pins may attest only from verified installed bytes: a missing tree is not excused by the lockfile, and `--vex-no-verify` omits those hosted gems with `mirror_overrides_source` rather than trusting their intercepted source. Agent/vendored evidence, unrelated ecosystems and standalone VEX behavior are unchanged. Details identify the setting form and its app/environment origin without printing mirror values or source URLs, which may contain credentials. Remove the applicable all/source/hostname setting (including any slash alias) from that origin and reuse its existing mirror URL under `mirror.https://rubygems.org` to clear the refusal; an environment setting must be unset in the scan/install environment. User-global Bundler config and mirrors set only in a later install environment are not inspected; keep those mirrors scoped to the upstream source too), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also additive: `redirect_gem_version_not_locked` (gem: no `GEM` section of the lock lists the crawled `name (version)`, for example a version another project installed into the shared gem home; the gem is skipped with nothing written, so the user's declared constraint and the locked version are never overwritten). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. 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. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). The root `package.json`, which the rewrite re-renders to add `resolutions`, gets the same gate: a mixed one is refused untouched with the same code — the decision vendored mode takes with `vendor_yarn_berry_mixed_line_endings`, from the same shared berry gate set. This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). 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, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `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; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `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, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files), and the sbt build files (`socket-patch.sbt`, `socket-patch-vendor.sbt`, `build.sbt`, `project/build.properties`, `.sbtopts`, `.jvmopts`; `build.sbt.lock` and the Mill / scala-cli build files `build.mill`, `build.mill.yaml`, `build.sc`, `.mill-version`, `project.scala` for their presence only) — read, never edited; `socket-patch.sbt` is the only sbt file hosted mode writes (see **Hosted sbt** below). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic (a yarn 2+ install migrates a v1 `yarn.lock` and drops its pins, so a run whose v1 lock carries a hosted pin warns `redirect_yarn_classic_berry_migration_risk` — the hosted twin of the vendored `yarn_classic_berry_migration_risk` — unless the root `package.json`, read as advisory input, declares `"packageManager": "yarn@1…"`), **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **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`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); 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). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). @@ -1164,7 +1164,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified "command": "scan" | "apply" | "vex" | "vendor" | "rollback" | "get" | "list" | "remove" | "repair", "status": "success" | "partialFailure" | "error" | "noManifest" | "paidRequired" | "notFound", "dryRun": false, - "events": [ , ... ], + "events": [ , ... ], "summary": { "discovered": 0, "downloaded": 0, @@ -1437,7 +1437,7 @@ rely on these keys. "vulnerabilities": [ { "id": "GHSA-xvch-5gv4-984h", // GHSA/CVE/etc — the canonical advisory ID - "cves": ["CVE-2024-12345"], + "cves": ["CVE-2024-12345"], "severity": "high", "summary": "Prototype Pollution", "description": "merge() does not check Object.prototype" diff --git a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs index 2074770d3..be684867e 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs @@ -457,6 +457,17 @@ enum Driver { /// environment, so bundler still loads `Gemfile.next` and the run must /// still redirect and attest nothing. ScanVexDualBootEnvGemfile, + /// [`Driver::ScanVex`] on a bundler 4 project whose `.bundle/config` + /// sets `lockfile custom.lock` beside a leftover `Gemfile.lock` (#749): + /// bundler reads `custom.lock`, which the rewriter never pins, so the + /// run must redirect nothing and attest nothing. Bundler >= 4 only; the + /// fixture asserts the contract itself and yields `None`. + ScanVexCustomLockfile, + /// [`Driver::ScanVex`] on a `Gemfile` + `gems.rb` twin (#751): bundler + /// 1.x loads the `Gemfile` and >= 2 loads `gems.rb`, and the scan cannot + /// see which runs, so it must redirect and attest nothing and leave all + /// four files byte-identical. Every bundler line. + ScanVexTwin, /// [`Driver::ScanVex`] on a Gemfile that declares the gem inside a /// `group :development do … end` block (#775): hosted mode wraps it in /// a source block inside the group, but vendored mode cannot edit an @@ -508,6 +519,8 @@ impl Driver { Driver::ScanVexDualBootEnvGemfile => { "scan --mode hosted (config Gemfile.next, env BUNDLE_GEMFILE=Gemfile)" } + Driver::ScanVexCustomLockfile => "scan --mode hosted (lockfile custom.lock)", + Driver::ScanVexTwin => "scan --mode hosted (Gemfile + gems.rb twin)", Driver::ScanVexGroupBlock => "scan --mode hosted (gem in a group block)", Driver::ScanVexSemicolonJoinedDeclaration => { "scan --mode hosted (two `;`-joined gem declarations)" @@ -622,6 +635,20 @@ async fn redirect_scanned_project( let bundler = bundler_e2e::gate("e2e_redirect_gem_build", tag, floor, &|c| { cache_env::isolate(c); })?; + // Drivers that only mean something on one bundler line. + let only = match driver { + Driver::ScanVexCustomLockfile if !bundler.at_least(4, 0) => { + Some("custom lockfiles need bundler >= 4") + } + _ => None, + }; + if let Some(why) = only { + println!( + "SKIP e2e_redirect_gem_build ({tag}): bundler {}: {why}", + bundler.version + ); + return None; + } let tmp = tempfile::tempdir().unwrap(); let (gemfile_name, lock_name) = spelling.pair(); @@ -975,6 +1002,27 @@ async fn redirect_scanned_project( String::from_utf8_lossy(&cfg.stderr) ); } + let custom_lockfile = driver == Driver::ScanVexCustomLockfile; + if custom_lockfile { + // `bundle config set --local lockfile custom.lock`; the default + // lock stays behind as a leftover bundler 4 ignores. + std::fs::copy(proj.join(lock_name), proj.join("custom.lock")).unwrap(); + let args = bundler.config_local_args("lockfile", "custom.lock"); + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + let cfg = bundle(&proj, &args); + assert!( + cfg.status.success(), + "bundle config set --local lockfile failed:\n{}", + String::from_utf8_lossy(&cfg.stderr) + ); + } + let twin = driver == Driver::ScanVexTwin; + if twin { + // Identical twins: which pair installs depends only on the bundler + // that runs. + std::fs::copy(proj.join(gemfile_name), proj.join("gems.rb")).unwrap(); + std::fs::copy(proj.join(lock_name), proj.join("gems.locked")).unwrap(); + } // Synthetic credentials must never appear in the scan's automatic // diagnostics. The loopback mirror itself serves the unpatched gem. let mirror = format!("{}/upstream/", server.uri()).replacen( @@ -1007,6 +1055,8 @@ async fn redirect_scanned_project( | Driver::ScanVexMirrorSource | Driver::ScanVexMirrorSourceEnv | Driver::ScanVexMirrorAllEnv + | Driver::ScanVexCustomLockfile + | Driver::ScanVexTwin | Driver::ScanVexDualBoot | Driver::ScanVexDualBootEnvGemfile | Driver::ScanVexDuplicateDeclaration @@ -1082,6 +1132,35 @@ async fn redirect_scanned_project( assert_dual_boot_redirects_nothing(&env, &proj, &pristine_gemfile, &pristine_lock); return None; } + if custom_lockfile { + assert_custom_lockfile_redirects_nothing( + &bundler, + "redirect_gem_bundle_lockfile_unsupported", + (code, &stdout, &stderr), + &proj, + &[ + ("Gemfile", &pristine_gemfile), + ("Gemfile.lock", &pristine_lock), + ("custom.lock", &pristine_lock), + ], + ); + return None; + } + if twin { + assert_custom_lockfile_redirects_nothing( + &bundler, + "redirect_gem_twin_manifest_ambiguous", + (code, &stdout, &stderr), + &proj, + &[ + ("Gemfile", &pristine_gemfile), + ("Gemfile.lock", &pristine_lock), + ("gems.rb", &pristine_gemfile), + ("gems.locked", &pristine_lock), + ], + ); + return None; + } if let Some(warning) = match driver { Driver::ScanVexDuplicateDeclaration => Some("redirect_gem_declared_more_than_once"), Driver::ScanVexEvalGemfile => Some("redirect_gem_declaration_not_visible"), @@ -1194,6 +1273,8 @@ async fn redirect_scanned_project( } Driver::ScanVexDualBoot | Driver::ScanVexDualBootEnvGemfile + | Driver::ScanVexCustomLockfile + | Driver::ScanVexTwin | Driver::ScanVexDuplicateDeclaration | Driver::ScanVexEvalGemfile | Driver::ScanVexMirrorAll @@ -1306,6 +1387,56 @@ fn assert_unwirable_declaration_redirects_nothing( ); } +/// The contract of a hosted scan that must refuse every gem: #749's +/// `custom.lock` named in `.bundle/config` (bundler 4), or #751's +/// `Gemfile` + `gems.rb` twin. The scan reports `refusal`, redirects and +/// attests nothing, leaves every one of `files` byte-identical, and bundler +/// still installs the untouched project frozen. +fn assert_custom_lockfile_redirects_nothing( + bundler: &bundler_e2e::Bundler, + refusal: &str, + (code, stdout, stderr): (i32, &str, &str), + proj: &Path, + files: &[(&str, &[u8])], +) { + let env: serde_json::Value = serde_json::from_str(stdout) + .unwrap_or_else(|e| panic!("not JSON: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}")); + let warning_codes: Vec<&str> = env["redirect"]["warnings"] + .as_array() + .map(|a| a.iter().filter_map(|w| w["code"].as_str()).collect()) + .unwrap_or_default(); + assert!( + warning_codes.contains(&refusal), + "the {refusal} refusal must be reported: {env}" + ); + assert_ne!(code, 0, "nothing was patched or attested: {env}"); + assert_eq!( + env["redirect"]["redirected"], 0, + "nothing redirected: {env}" + ); + assert!( + env["vex"]["statements"].as_u64().unwrap_or(0) == 0, + "no in-run attestation for a lock that was never pinned: {env}" + ); + for (file, want) in files { + assert_eq!( + std::fs::read(proj.join(file)).unwrap(), + *want, + "{file} must be byte-untouched" + ); + } + let args = bundler.config_local_args("frozen", "true"); + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + assert!(bundle(proj, &args).status.success()); + let install = bundle(proj, &["install"]); + assert!( + install.status.success(), + "bundler {} must still install the untouched project frozen:\n{}", + bundler.version, + String::from_utf8_lossy(&install.stderr) + ); +} + /// #390's contract on a `BUNDLE_GEMFILE: Gemfile.next` project: the hosted /// scan names the setting, rewrites neither the `Gemfile` pair (which /// bundler ignores) nor `Gemfile.next`, and its in-run VEX attests nothing. @@ -2065,6 +2196,49 @@ async fn gem_hosted_bundle_gemfile_dual_boot_redirects_nothing() { assert!(fx.is_none(), "the dual-boot driver asserts in place"); } +/// #749: bundler 4's `bundle config set --local lockfile custom.lock` +/// makes bundler read `custom.lock`. The hosted scan used to wire the +/// Gemfile (and the ignored leftover `Gemfile.lock`), report success and +/// attest, while every frozen install then failed; it must redirect and +/// attest nothing. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "host capstone: shells out to a real ruby/gem/bundler (>= 4.0 for this arm); \ + the unpinned `test` job skips it, an e2e job with a pinned toolchain runs it via --ignored"] +async fn gem_hosted_bundler4_custom_lockfile_redirects_nothing() { + let fx = redirect_scanned_project( + "custom-lockfile", + Spelling::Gemfile, + true, + true, + None, + Driver::ScanVexCustomLockfile, + ) + .await; + assert!(fx.is_none(), "the custom-lockfile driver asserts in place"); +} + +/// #751: bundler 1.x loads a twin's `Gemfile` and bundler >= 2 its +/// `gems.rb`. The hosted scan used to wire `gems.rb` and attest while +/// bundler 1.17 installed the unpatched gem from the `Gemfile`; since a +/// lock's `BUNDLED WITH` does not say which bundler installs, it must +/// refuse the twin on every bundler line, and the untouched twin must +/// still install. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "host capstone: shells out to a real ruby/gem/bundler (>= 1.17); \ + the unpinned `test` job skips it, an e2e job with a pinned toolchain runs it via --ignored"] +async fn gem_hosted_twin_redirects_nothing() { + let fx = redirect_scanned_project( + "twin", + Spelling::Gemfile, + false, + true, + None, + Driver::ScanVexTwin, + ) + .await; + assert!(fx.is_none(), "the twin driver asserts in place"); +} + /// #548: a gem declared in two `group` blocks must not be half-rewritten /// (bundler refuses `= 1.0.0` next to `>= 0` on every install). #[tokio::test(flavor = "multi_thread")] diff --git a/crates/socket-patch-cli/tests/hosted_memory_engine.rs b/crates/socket-patch-cli/tests/hosted_memory_engine.rs index 54826f34d..e092e4c6d 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_engine.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_engine.rs @@ -991,6 +991,150 @@ async fn a_vlt_project_is_withheld_as_offline() { assert!(output.changed_files.is_empty()); } +const GEM_BASIC_FIXTURE: &str = "redirect/gem/bundler/basic"; + +/// The gem fixture's API mocks and input files. +async fn gem_server_and_input() -> (MockServer, BTreeMap>) { + let server = MockServer::start().await; + let patches = patches_from_overrides( + &fixtures_root() + .join(GEM_BASIC_FIXTURE) + .join("overrides.json"), + None, + ); + mount_api(&server, &patches).await; + let input = fixture_files(&fixtures_root().join(GEM_BASIC_FIXTURE).join("input")); + (server, input) +} + +fn warning_codes(redirect: &Value) -> Vec { + redirect["warnings"] + .as_array() + .unwrap() + .iter() + .map(|w| w["code"].as_str().unwrap().to_string()) + .collect() +} + +fn changed_paths(output: &HostedScanOutput) -> Vec<&str> { + output + .changed_files + .iter() + .map(|f| f.path.as_str()) + .collect() +} + +/// #749: bundler 4 reads the lock `bundle config set lockfile custom.lock` +/// names, which the rewriter never pins. With a leftover `Gemfile.lock` +/// beside it, the run used to rewrite that ignored lock, report success, +/// and break every frozen install; the project is refused with nothing +/// written instead. A memory tree only finds gem candidates through the +/// lock bundler loads (#736), and that lock is none of the default ones +/// here, so the project yields no gem candidate; the run reports +/// `gem_lock_unsupported` instead (the per-candidate +/// `redirect_gem_bundle_lockfile_unsupported` refusal is covered by the +/// engine unit tests). +#[tokio::test] +async fn a_bundler4_custom_lockfile_is_refused() { + let (server, input) = gem_server_and_input().await; + let mut files = input.clone(); + files.insert("custom.lock".to_string(), input["Gemfile.lock"].clone()); + files.insert( + ".bundle/config".to_string(), + b"---\nBUNDLE_LOCKFILE: \"custom.lock\"\n".to_vec(), + ); + let output = run_engine(&server, build_input(&files, &[], options(false))).await; + let project = &output.projects[0]; + assert!(project.error.is_none(), "{:?}", project.error); + assert!(project.redirected.is_empty(), "{:?}", project.redirected); + assert!( + changed_paths(&output).is_empty(), + "{:?}", + changed_paths(&output) + ); + assert_gem_lock_unsupported(&output); +} + +/// The run says the project's gems were not scanned, rather than finding +/// none: the lock inventory's `gem_lock_unsupported` diagnosis. +fn assert_gem_lock_unsupported(output: &HostedScanOutput) { + let codes: Vec<&str> = output.warnings.iter().map(|w| w.code.as_str()).collect(); + assert!(codes.contains(&"gem_lock_unsupported"), "{codes:?}"); +} + +/// #749: a configured lockfile naming the pair's own default lock is the +/// lock the rewriter pins anyway, so the project is wired as usual. +#[tokio::test] +async fn a_lockfile_setting_naming_the_default_lock_is_wired() { + let (server, input) = gem_server_and_input().await; + let mut files = input.clone(); + files.insert( + ".bundle/config".to_string(), + b"---\nBUNDLE_LOCKFILE: \"Gemfile.lock\"\n".to_vec(), + ); + let output = run_engine(&server, build_input(&files, &[], options(false))).await; + let project = &output.projects[0]; + assert_eq!( + project.redirected.len(), + 1, + "{:?}", + warning_codes(&project.redirect) + ); + assert_eq!(changed_paths(&output), vec!["Gemfile", "Gemfile.lock"]); +} + +/// The fixture lock re-stamped `BUNDLED WITH `. +fn bundled_with(lock: &[u8], version: &str) -> Vec { + let text = String::from_utf8(lock.to_vec()).unwrap(); + let (head, _) = text.split_once("BUNDLED WITH").unwrap(); + format!("{head}BUNDLED WITH\n {version}\n").into_bytes() +} + +/// A `Gemfile` + `gems.rb` twin with each lock bundled by `versions`. +fn gem_twin( + input: &BTreeMap>, + versions: (&str, &str), +) -> BTreeMap> { + let mut files = BTreeMap::new(); + files.insert("Gemfile".to_string(), input["Gemfile"].clone()); + files.insert("gems.rb".to_string(), input["Gemfile"].clone()); + files.insert( + "Gemfile.lock".to_string(), + bundled_with(&input["Gemfile.lock"], versions.0), + ); + files.insert( + "gems.locked".to_string(), + bundled_with(&input["Gemfile.lock"], versions.1), + ); + files +} + +/// #751: bundler 1.x loads a twin's `Gemfile` and bundler >= 2 its +/// `gems.rb`, and a lock's `BUNDLED WITH` records who wrote it, not who +/// installs it, so no twin is wired whatever its locks say: refused, +/// nothing written. No lock is the one bundler loads (#736), so the +/// memory tree yields no gem candidate and the run reports +/// `gem_lock_unsupported`; the per-candidate +/// `redirect_gem_twin_manifest_ambiguous` refusal is covered by the engine +/// unit tests. +#[tokio::test] +async fn a_gemfile_gems_rb_twin_is_refused() { + let (server, input) = gem_server_and_input().await; + for versions in [ + ("1.17.3", "1.17.3"), + ("2.6.2", "2.6.2"), + ("1.17.3", "2.6.2"), + ("2.6.2", "1.17.3"), + ] { + let files = gem_twin(&input, versions); + let output = run_engine(&server, build_input(&files, &[], options(false))).await; + let project = &output.projects[0]; + assert!(project.redirected.is_empty(), "{versions:?}"); + assert!(changed_paths(&output).is_empty(), "{versions:?}"); + assert_gem_lock_unsupported(&output); + } +} + /// #736: the engine's purl set comes from the lock bundler loads. A /// `gems.rb` project's gems live in `gems.locked`; reading only /// `Gemfile.lock` found nothing to redirect, and a leftover `Gemfile.lock` diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index c7526ee03..ef8a04915 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -1445,22 +1445,41 @@ fn expand_tilde(value: &Path, home: Option<&Path>) -> PathBuf { /// [`crate::formats::gem::manifest::classify`] for `root` on disk: the /// manifest bundler loads, reading the ambient `BUNDLE_GEMFILE` / -/// `BUNDLE_APP_CONFIG` and the app config file. +/// `BUNDLE_LOCKFILE` / `BUNDLE_APP_CONFIG` and the app config file. pub async fn bundler_loaded_manifest(root: &Path) -> crate::formats::gem::manifest::LoadedManifest { bundler_loaded_manifest_with_env( root, - std::env::var_os("BUNDLE_GEMFILE").as_deref(), - std::env::var_os("BUNDLE_APP_CONFIG").as_deref(), - bundler_ignores_config(), - ambient_bundler_global_config_file(root).as_deref(), + BundlerEnv { + gemfile: std::env::var_os("BUNDLE_GEMFILE").as_deref(), + lockfile: std::env::var_os("BUNDLE_LOCKFILE").as_deref(), + app_config: std::env::var_os("BUNDLE_APP_CONFIG").as_deref(), + ignore_config: bundler_ignores_config(), + global_config: ambient_bundler_global_config_file(root).as_deref(), + }, ) .await } +/// The bundler environment [`bundler_loaded_manifest_with_env`] reads. +#[derive(Debug, Clone, Copy, Default)] +pub struct BundlerEnv<'a> { + /// `BUNDLE_GEMFILE`. + pub gemfile: Option<&'a OsStr>, + /// `BUNDLE_LOCKFILE` (bundler 4). + pub lockfile: Option<&'a OsStr>, + /// `BUNDLE_APP_CONFIG`. + pub app_config: Option<&'a OsStr>, + /// [`bundler_ignores_config`]. + pub ignore_config: bool, + /// [`bundler_global_config_file`]. + pub global_config: Option<&'a Path>, +} + /// [`bundler_loaded_manifest`] for the project `view` shows. A disk view /// (or a snapshot of one) reads the ambient environment and the app config /// like bundler; a memory view has no environment, so only its own -/// `.bundle/config` counts. +/// `.bundle/config` counts (its `BUNDLE_GEMFILE` and bundler 4's +/// `BUNDLE_LOCKFILE`). pub(crate) async fn bundler_loaded_manifest_in( view: &ProjectView<'_>, ) -> crate::formats::gem::manifest::LoadedManifest { @@ -1471,8 +1490,23 @@ pub(crate) async fn bundler_loaded_manifest_in( } ProjectView::Memory(_) => { let config = view.read_text(".bundle/config").await.ok(); - let value = config.as_deref().and_then(manifest::config_gemfile); - manifest::classify(Path::new("/"), None, value.as_deref(), None) + let gemfile = config.as_deref().and_then(manifest::config_gemfile); + let lockfile = config.as_deref().and_then(manifest::config_lockfile); + let root = Path::new("/"); + let gems_rb = view.is_file("gems.rb"); + let loaded = manifest::classify(root, None, gemfile.as_deref(), None); + // `/` only stands in for the project root: bundler opens an + // absolute lockfile as is, never the project's own lock, so + // it must not compare equal to the pair's lock at `/`. + if let Some(value) = lockfile.as_deref() { + if Path::new(value).has_root() && loaded.pair(gems_rb).is_some() { + return manifest::LoadedManifest::UnsupportedLockfile { + value: value.to_string(), + by: manifest::GemfileSetting::AppConfig, + }; + } + } + loaded.with_lockfile(root, None, lockfile.as_deref(), None, gems_rb) } } } @@ -1481,37 +1515,78 @@ pub(crate) async fn bundler_loaded_manifest_in( /// [`LoadedManifest::pair`](crate::formats::gem::manifest::LoadedManifest::pair) /// — `gems.locked` when the root holds a `gems.rb` file and nothing /// configures `BUNDLE_GEMFILE`, else `Gemfile.lock` — or `None` when -/// `BUNDLE_GEMFILE` names a manifest outside the two default pairs. Every -/// lock READER asks this (lock inventory, ledger recovery, VEX discovery), -/// so none reads a twin bundler ignores (#736). +/// `BUNDLE_GEMFILE` names a manifest outside the two default pairs, or +/// bundler 4's `BUNDLE_LOCKFILE` names a lock other than the pair's own +/// (#749). A `Gemfile` + `gems.rb` twin under default discovery is `None`: +/// bundler 1.x loads the `Gemfile` and >= 2 loads `gems.rb`, and nothing +/// says which runs (#751). Every lock READER asks this (lock +/// inventory, ledger recovery, VEX discovery), so none reads a twin +/// bundler ignores (#736). pub(crate) async fn bundler_loaded_lock_in(view: &ProjectView<'_>) -> Option<&'static str> { - bundler_loaded_manifest_in(view) - .await - .pair(view.is_file("gems.rb")) - .map(|(_, lock)| lock) + bundler_loaded_lock_diagnosed_in(view).await.ok() +} + +/// [`bundler_loaded_lock_in`], with the reason when bundler loads no lock +/// socket-patch reads: the detail of the unsupported `BUNDLE_GEMFILE` / +/// `BUNDLE_LOCKFILE`, or of the `Gemfile` + `gems.rb` twin. Lock inventory surfaces it so a lockfile-only scan does not +/// skip the project's gems silently. +pub(crate) async fn bundler_loaded_lock_diagnosed_in( + view: &ProjectView<'_>, +) -> Result<&'static str, String> { + use crate::formats::gem::manifest::{self, LoadedManifest}; + let loaded = bundler_loaded_manifest_in(view).await; + let gems_rb = view.is_file("gems.rb"); + if loaded == LoadedManifest::Default + && bundler_may_see_file(view, "gems.rb") + && bundler_may_see_file(view, "Gemfile") + { + return Err(manifest::twin_manifest_refusal()); + } + match loaded.pair(gems_rb) { + Some((_, lock)) => Ok(lock), + None => Err(loaded.unsupported_detail().unwrap_or_default()), + } +} + +/// Whether bundler's `File.file?` may find `rel` in `view`: a regular file +/// (through symlinks on disk), or, on a memory view, a symlink, whose +/// target the view doesn't carry. The twin check fails closed on it +/// rather than guess that the link dangles. +fn bundler_may_see_file(view: &ProjectView<'_>, rel: &str) -> bool { + view.is_file(rel) || matches!(view, ProjectView::Memory(project) if project.is_symlink(rel)) } /// [`bundler_loaded_manifest`] with the environment passed explicitly (hermetic -/// tests). `ignore_config` is [`bundler_ignores_config`]; `global_config` is -/// [`bundler_global_config_file`]. +/// tests). pub async fn bundler_loaded_manifest_with_env( root: &Path, - gemfile_env: Option<&OsStr>, - app_config_env: Option<&OsStr>, - ignore_config: bool, - global_config: Option<&Path>, + env: BundlerEnv<'_>, ) -> crate::formats::gem::manifest::LoadedManifest { - let config_value = read_app_config(root, app_config_env, ignore_config) - .await - .and_then(|text| crate::formats::gem::manifest::config_gemfile(&text)); - let global_value = read_global_config(global_config, ignore_config) + use crate::formats::gem::manifest; + let config = read_app_config(root, env.app_config, env.ignore_config).await; + let config = config.as_deref(); + let gemfile = config.and_then(manifest::config_gemfile); + let lockfile = config.and_then(manifest::config_lockfile); + // Bundler's `File.file?`, which the lock readers' `is_file` mirrors: a + // regular file, through symlinks. A directory or a dangling symlink + // named `gems.rb` leaves the `Gemfile` pair loaded. + let gems_rb_present = tokio::fs::metadata(root.join("gems.rb")) .await - .and_then(|text| crate::formats::gem::manifest::config_gemfile(&text)); - crate::formats::gem::manifest::classify( + .is_ok_and(|m| m.is_file()); + let global = read_global_config(env.global_config, env.ignore_config).await; + let global = global.as_deref(); + manifest::classify( + root, + env.gemfile, + gemfile.as_deref(), + global.and_then(manifest::config_gemfile).as_deref(), + ) + .with_lockfile( root, - gemfile_env, - config_value.as_deref(), - global_value.as_deref(), + env.lockfile, + lockfile.as_deref(), + global.and_then(manifest::config_lockfile).as_deref(), + gems_rb_present, ) } @@ -2041,7 +2116,7 @@ mod tests { "---\nBUNDLE_GEMFILE: \"Gemfile.next\"\n", ) .unwrap(); - let m = bundler_loaded_manifest_with_env(dir.path(), None, None, false, None).await; + let m = bundler_loaded_manifest_with_env(dir.path(), BundlerEnv::default()).await; assert!(matches!( m, crate::formats::gem::manifest::LoadedManifest::Unsupported { @@ -2052,15 +2127,22 @@ mod tests { // BUNDLE_APP_CONFIG moves the config file away from `.bundle`. let m = bundler_loaded_manifest_with_env( dir.path(), - None, - Some(std::ffi::OsStr::new("elsewhere")), - false, - None, + BundlerEnv { + app_config: Some(std::ffi::OsStr::new("elsewhere")), + ..BundlerEnv::default() + }, ) .await; assert_eq!(m, crate::formats::gem::manifest::LoadedManifest::Default); // BUNDLE_IGNORE_CONFIG: bundler reads no config file at all. - let m = bundler_loaded_manifest_with_env(dir.path(), None, None, true, None).await; + let m = bundler_loaded_manifest_with_env( + dir.path(), + BundlerEnv { + ignore_config: true, + ..BundlerEnv::default() + }, + ) + .await; assert_eq!(m, crate::formats::gem::manifest::LoadedManifest::Default); } @@ -2078,10 +2160,10 @@ mod tests { .unwrap(); let m = bundler_loaded_manifest_with_env( dir.path(), - Some(std::ffi::OsStr::new("Gemfile")), - None, - false, - None, + BundlerEnv { + gemfile: Some(std::ffi::OsStr::new("Gemfile")), + ..BundlerEnv::default() + }, ) .await; assert_eq!( @@ -2093,6 +2175,120 @@ mod tests { ); } + /// #749: bundler only counts a `gems.rb` that `File.file?` accepts (a + /// regular file, through symlinks). A directory or a dangling symlink + /// named `gems.rb` leaves the `Gemfile` pair loaded, so + /// `BUNDLE_LOCKFILE: gems.locked` names a lock other than its own and + /// must stay unsupported rather than bind `Gemfile.lock`. + #[tokio::test] + async fn a_non_regular_gems_rb_does_not_make_gems_locked_the_pairs_lock() { + use crate::formats::gem::manifest::LoadedManifest; + let config = "---\nBUNDLE_LOCKFILE: \"gems.locked\"\n"; + let project = |make_gems_rb: &dyn Fn(&Path)| { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join(".bundle")).unwrap(); + std::fs::write(dir.path().join(".bundle/config"), config).unwrap(); + std::fs::write(dir.path().join("Gemfile"), "gem \"rack\"\n").unwrap(); + make_gems_rb(&dir.path().join("gems.rb")); + dir + }; + let dir = project(&|p| std::fs::create_dir(p).unwrap()); + let m = bundler_loaded_manifest_with_env(dir.path(), BundlerEnv::default()).await; + assert!( + matches!(m, LoadedManifest::UnsupportedLockfile { .. }), + "directory gems.rb: {m:?}" + ); + #[cfg(unix)] + { + let dir = project(&|p| std::os::unix::fs::symlink("missing.rb", p).unwrap()); + let m = bundler_loaded_manifest_with_env(dir.path(), BundlerEnv::default()).await; + assert!( + matches!(m, LoadedManifest::UnsupportedLockfile { .. }), + "dangling gems.rb: {m:?}" + ); + // A symlink to a regular gems.rb is one bundler loads. + let dir = project(&|p| { + std::fs::write(p.with_file_name("real.rb"), "gem \"rack\"\n").unwrap(); + std::os::unix::fs::symlink("real.rb", p).unwrap(); + }); + let m = bundler_loaded_manifest_with_env(dir.path(), BundlerEnv::default()).await; + assert_eq!(m, LoadedManifest::Default); + } + } + + /// #749: bundler 4's configured lockfile (`BUNDLE_LOCKFILE`, the + /// environment first, then the app config) naming anything but the + /// loaded pair's own lock is unsupported; naming that lock is a no-op. + #[tokio::test] + async fn loaded_manifest_reads_the_lockfile_setting() { + use crate::formats::gem::manifest::{GemfileSetting, LoadedManifest}; + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join(".bundle")).unwrap(); + let config = |value: &str| { + std::fs::write( + dir.path().join(".bundle/config"), + format!("---\nBUNDLE_LOCKFILE: \"{value}\"\n"), + ) + .unwrap() + }; + config("custom.lock"); + let m = bundler_loaded_manifest_with_env(dir.path(), BundlerEnv::default()).await; + assert_eq!( + m, + LoadedManifest::UnsupportedLockfile { + value: "custom.lock".into(), + by: GemfileSetting::AppConfig + } + ); + assert_eq!(m.pair(false), None); + // The environment wins over the app config, as in `Bundler::CLI`. + let m = bundler_loaded_manifest_with_env( + dir.path(), + BundlerEnv { + lockfile: Some(std::ffi::OsStr::new("Gemfile.lock")), + ..BundlerEnv::default() + }, + ) + .await; + assert_eq!(m, LoadedManifest::Default); + let m = bundler_loaded_manifest_with_env( + dir.path(), + BundlerEnv { + lockfile: Some(std::ffi::OsStr::new("other.lock")), + ..BundlerEnv::default() + }, + ) + .await; + assert!(matches!( + m, + LoadedManifest::UnsupportedLockfile { + by: GemfileSetting::Env, + .. + } + )); + // BUNDLE_IGNORE_CONFIG drops the app config value. + let m = bundler_loaded_manifest_with_env( + dir.path(), + BundlerEnv { + ignore_config: true, + ..BundlerEnv::default() + }, + ) + .await; + assert_eq!(m, LoadedManifest::Default); + // The default lock of the pair bundler loads is a no-op; with a + // gems.rb, that lock is gems.locked, not Gemfile.lock. + config("Gemfile.lock"); + let m = bundler_loaded_manifest_with_env(dir.path(), BundlerEnv::default()).await; + assert_eq!(m, LoadedManifest::Default); + std::fs::write(dir.path().join("gems.rb"), "").unwrap(); + let m = bundler_loaded_manifest_with_env(dir.path(), BundlerEnv::default()).await; + assert!(matches!(m, LoadedManifest::UnsupportedLockfile { .. })); + config("./gems.locked"); + let m = bundler_loaded_manifest_with_env(dir.path(), BundlerEnv::default()).await; + assert_eq!(m, LoadedManifest::Default); + } + /// #483: bundler's cache dir is the `cache_path` setting — the app /// config value first, then `BUNDLE_CACHE_PATH`, else `vendor/cache`. #[tokio::test] @@ -2280,7 +2476,14 @@ mod tests { ) .unwrap(); assert!(matches!( - bundler_loaded_manifest_with_env(root, None, Some(OsStr::new("")), false, None).await, + bundler_loaded_manifest_with_env( + root, + BundlerEnv { + app_config: Some(OsStr::new("")), + ..BundlerEnv::default() + } + ) + .await, crate::formats::gem::manifest::LoadedManifest::Unsupported { .. } )); assert_eq!( @@ -3242,7 +3445,14 @@ mod tests { let global = dir.path().join("global-config"); std::fs::write(&global, "---\nBUNDLE_GEMFILE: \"Gemfile.next\"\n").unwrap(); let g = Some(global.as_path()); - let m = bundler_loaded_manifest_with_env(&root, None, None, false, g).await; + let m = bundler_loaded_manifest_with_env( + &root, + BundlerEnv { + global_config: g, + ..BundlerEnv::default() + }, + ) + .await; assert_eq!( m, LoadedManifest::Unsupported { @@ -3259,10 +3469,11 @@ mod tests { assert_eq!( bundler_loaded_manifest_with_env( &root, - Some(std::ffi::OsStr::new("Gemfile")), - None, - false, - g + BundlerEnv { + gemfile: Some(std::ffi::OsStr::new("Gemfile")), + global_config: g, + ..BundlerEnv::default() + } ) .await, LoadedManifest::Configured { @@ -3278,15 +3489,51 @@ mod tests { ) .unwrap(); assert_eq!( - bundler_loaded_manifest_with_env(&root, None, None, false, g).await, + bundler_loaded_manifest_with_env( + &root, + BundlerEnv { + global_config: g, + ..BundlerEnv::default() + } + ) + .await, LoadedManifest::Configured { manifest: "gems.rb", by: GemfileSetting::AppConfig } ); + // `bundle config set --global lockfile` is read from the same file, + // below the local app config (#749 on top of #577). + std::fs::write( + &global, + "---\nBUNDLE_GEMFILE: \"Gemfile.next\"\nBUNDLE_LOCKFILE: \"custom.lock\"\n", + ) + .unwrap(); + assert_eq!( + bundler_loaded_manifest_with_env( + &root, + BundlerEnv { + global_config: g, + ..BundlerEnv::default() + } + ) + .await, + LoadedManifest::UnsupportedLockfile { + value: "custom.lock".into(), + by: GemfileSetting::GlobalConfig + } + ); // BUNDLE_IGNORE_CONFIG skips both files. assert_eq!( - bundler_loaded_manifest_with_env(&root, None, None, true, g).await, + bundler_loaded_manifest_with_env( + &root, + BundlerEnv { + ignore_config: true, + global_config: g, + ..BundlerEnv::default() + } + ) + .await, LoadedManifest::Default ); } @@ -3304,21 +3551,43 @@ mod tests { // An exported empty value shadows the global file and leaves // Bundler's default Gemfile/gems.rb discovery active. assert_eq!( - bundler_loaded_manifest_with_env(&root, Some(OsStr::new("")), None, false, g).await, + bundler_loaded_manifest_with_env( + &root, + BundlerEnv { + gemfile: Some(OsStr::new("")), + global_config: g, + ..BundlerEnv::default() + } + ) + .await, LoadedManifest::Default ); // The same value written by `bundle config set --local gemfile ''` // is present even though it names no file. std::fs::write(root.join(".bundle/config"), "---\nBUNDLE_GEMFILE: \"\"\n").unwrap(); assert_eq!( - bundler_loaded_manifest_with_env(&root, None, None, false, g).await, + bundler_loaded_manifest_with_env( + &root, + BundlerEnv { + global_config: g, + ..BundlerEnv::default() + } + ) + .await, LoadedManifest::Default ); // Bundler does not re-export an empty local setting, so an existing // non-empty environment value still chooses the manifest. assert_eq!( - bundler_loaded_manifest_with_env(&root, Some(OsStr::new("Gemfile")), None, false, g) - .await, + bundler_loaded_manifest_with_env( + &root, + BundlerEnv { + gemfile: Some(OsStr::new("Gemfile")), + global_config: g, + ..BundlerEnv::default() + } + ) + .await, LoadedManifest::Configured { manifest: "Gemfile", by: GemfileSetting::Env diff --git a/crates/socket-patch-core/src/formats/gem/manifest.rs b/crates/socket-patch-core/src/formats/gem/manifest.rs index 1cfa65736..2b5ef9d1d 100644 --- a/crates/socket-patch-core/src/formats/gem/manifest.rs +++ b/crates/socket-patch-core/src/formats/gem/manifest.rs @@ -29,6 +29,20 @@ //! directory, a missing file) is [`LoadedManifest::Unsupported`]: the //! rewriters and the lock readers only know the two default pairs, so the //! callers fail closed rather than wire a manifest Bundler never reads. +//! Bundler 4's custom lockfile (`BUNDLE_LOCKFILE` from the environment, +//! else `BUNDLE_LOCKFILE:` in the app config, else in the global config — +//! `Bundler::CLI` checks the environment before `Bundler.settings`) is +//! layered on by [`LoadedManifest::with_lockfile`]: a value naming the lock of the pair bundler loads anyway changes nothing, +//! and anything else is [`LoadedManifest::UnsupportedLockfile`] — the +//! rewriters only edit the default lock of each pair, so wiring a project +//! whose lock lives elsewhere would leave the lock bundler reads unpinned +//! (#749). +//! +//! A `Gemfile` + `gems.rb` twin under default discovery is ambiguous across +//! bundler majors (1.x loads the `Gemfile`, >= 2 loads `gems.rb`), and +//! nothing on disk says which bundler runs: callers refuse it with +//! [`twin_manifest_refusal`] (#751). +//! //! The model is pure: //! the disk and environment reads live in //! [`crate::crawlers::ruby_crawler::bundler_loaded_manifest`]. @@ -78,6 +92,9 @@ pub enum LoadedManifest { }, /// `BUNDLE_GEMFILE` names any other file. Unsupported { value: String, by: GemfileSetting }, + /// Bundler 4's `BUNDLE_LOCKFILE` names a lock other than the default + /// lock of the pair bundler loads. + UnsupportedLockfile { value: String, by: GemfileSetting }, } impl LoadedManifest { @@ -88,7 +105,9 @@ impl LoadedManifest { LoadedManifest::Default if gems_rb_present => "gems.rb", LoadedManifest::Default => "Gemfile", LoadedManifest::Configured { manifest, .. } => manifest, - LoadedManifest::Unsupported { .. } => return None, + LoadedManifest::Unsupported { .. } | LoadedManifest::UnsupportedLockfile { .. } => { + return None + } }; Some(if manifest == "gems.rb" { ("gems.rb", "gems.locked") @@ -121,9 +140,96 @@ impl LoadedManifest { by.describe() )) } + LoadedManifest::UnsupportedLockfile { value, by } => { + let (knob, remedy) = match by { + GemfileSetting::Env => ( + "the BUNDLE_LOCKFILE environment variable", + "unset BUNDLE_LOCKFILE", + ), + GemfileSetting::AppConfig => ( + "BUNDLE_LOCKFILE in the bundler app config (.bundle/config)", + "run `bundle config unset --local lockfile`", + ), + GemfileSetting::GlobalConfig => ( + "BUNDLE_LOCKFILE in the global bundler config (~/.bundle/config)", + "run `bundle config unset --global lockfile`", + ), + }; + Some(format!( + "bundler reads the lockfile `{value}` ({knob}), not the Gemfile.lock or \ + gems.locked socket-patch pins; wiring the manifest alone would leave that \ + lock unpinned and break frozen installs, so it left the gem manifests \ + untouched ({remedy} to use the default lock, and re-run)" + )) + } _ => None, } } + + /// Layer Bundler 4's configured lockfile onto `self`: `lockfile_env` + /// (`BUNDLE_LOCKFILE`) first, else `lockfile_config` (the app config's + /// `BUNDLE_LOCKFILE:`), else `lockfile_global` (the global config's), as + /// `Bundler::CLI` resolves it. A relative value + /// is read against `root`, like `BUNDLE_GEMFILE`. A value naming the + /// default lock of the pair bundler loads (given whether the root holds + /// a `gems.rb`) leaves `self` unchanged; any other value is + /// [`LoadedManifest::UnsupportedLockfile`]. An unsupported manifest + /// stays the answer: it is refused either way. + pub fn with_lockfile( + self, + root: &Path, + lockfile_env: Option<&OsStr>, + lockfile_config: Option<&str>, + lockfile_global: Option<&str>, + gems_rb_present: bool, + ) -> LoadedManifest { + let Some((_, lock)) = self.pair(gems_rb_present) else { + return self; + }; + // The first tier that holds the key wins, as in `Settings#[]`: a + // present empty value shadows the tiers below and means the + // default lock. + let (value, by) = match (lockfile_env, lockfile_config, lockfile_global) { + (Some(env), _, _) => (PathBuf::from(env), GemfileSetting::Env), + (None, Some(config), _) => (PathBuf::from(config), GemfileSetting::AppConfig), + (None, None, Some(global)) => (PathBuf::from(global), GemfileSetting::GlobalConfig), + (None, None, None) => return self, + }; + if value.as_os_str().is_empty() { + return self; + } + let target = resolve_against(root, &value); + let expected = resolve_against(root, Path::new(lock)); + if target.is_some() && target == expected { + return self; + } + LoadedManifest::UnsupportedLockfile { + value: value.display().to_string(), + by, + } + } +} + +/// The `BUNDLE_LOCKFILE:` value of a bundler config file (bundler 4's +/// `bundle config set lockfile `). An empty value is still returned: +/// it shadows the tiers below it ([`LoadedManifest::with_lockfile`]). +pub fn config_lockfile(contents: &str) -> Option { + bundle_config_setting_including_empty(contents, "BUNDLE_LOCKFILE") +} + +/// Why a `Gemfile` + `gems.rb` twin under DEFAULT discovery is never +/// wired: bundler 1.x loads the `Gemfile` and bundler >= 2 loads `gems.rb`, +/// and only the bundler that runs decides. A lock's `BUNDLED WITH` records +/// who wrote it, not who installs it (bundler >= 2 installs a 1.x lock and +/// 1.x installs a 2.x one, each from its own spelling), so nothing a scan +/// can read picks the pair and attesting either could leave the installed +/// one unpatched (#751). Vendored mode refuses the twin the same way. +pub fn twin_manifest_refusal() -> String { + "both Gemfile and gems.rb are present; bundler 1.x loads the Gemfile while bundler >= 2 \ + loads gems.rb, and socket-patch cannot tell which bundler installs the project, so it \ + left the gem manifests untouched (remove the spelling you don't use, or set \ + BUNDLE_GEMFILE to the one you do, and re-run)" + .to_string() } /// The `BUNDLE_GEMFILE:` value of a bundler app config file (flat YAML that @@ -398,6 +504,124 @@ mod tests { assert!(matches!(m, LoadedManifest::Unsupported { .. })); } + /// #749: a configured lockfile is judged against the pair bundler + /// loads; a manifest refusal stays the answer. + #[test] + fn with_lockfile_accepts_only_the_pairs_own_lock() { + let unset = + classify(&root(), None, None, None).with_lockfile(&root(), None, None, None, false); + assert_eq!(unset, LoadedManifest::Default); + let own = classify(&root(), None, None, None).with_lockfile( + &root(), + Some(OsStr::new("Gemfile.lock")), + None, + None, + false, + ); + assert_eq!(own, LoadedManifest::Default); + let custom = classify(&root(), None, None, None).with_lockfile( + &root(), + None, + Some("custom.lock"), + None, + false, + ); + assert_eq!( + custom, + LoadedManifest::UnsupportedLockfile { + value: "custom.lock".into(), + by: GemfileSetting::AppConfig + } + ); + let detail = custom.unsupported_detail().unwrap(); + assert!(detail.contains("custom.lock"), "{detail}"); + assert!( + detail.contains("bundle config unset --local lockfile"), + "{detail}" + ); + // The other pair's lock is not what bundler loads with this manifest. + let configured = classify(&root(), None, Some("Gemfile"), None).with_lockfile( + &root(), + Some(OsStr::new("gems.locked")), + None, + None, + true, + ); + assert!(matches!( + configured, + LoadedManifest::UnsupportedLockfile { + by: GemfileSetting::Env, + .. + } + )); + assert!(configured + .unsupported_detail() + .unwrap() + .contains("unset BUNDLE_LOCKFILE")); + // An unsupported manifest keeps its own refusal. + let manifest = classify(&root(), None, Some("Gemfile.next"), None).with_lockfile( + &root(), + Some(OsStr::new("custom.lock")), + None, + None, + false, + ); + assert!(matches!(manifest, LoadedManifest::Unsupported { .. })); + // `bundle config set --global lockfile` is the lowest tier (#577's + // global file feeds `Bundler.settings[:lockfile]` too). + let global = classify(&root(), None, None, None).with_lockfile( + &root(), + None, + None, + Some("custom.lock"), + false, + ); + assert_eq!( + global, + LoadedManifest::UnsupportedLockfile { + value: "custom.lock".into(), + by: GemfileSetting::GlobalConfig + } + ); + assert!(global + .unsupported_detail() + .unwrap() + .contains("bundle config unset --global lockfile")); + let shadowed = classify(&root(), None, None, None).with_lockfile( + &root(), + None, + Some("Gemfile.lock"), + Some("custom.lock"), + false, + ); + assert_eq!(shadowed, LoadedManifest::Default); + // A present empty value stops at its tier like `Settings#[]`: the + // global custom lock is shadowed and bundler uses the default one. + for (env, config) in [(Some(OsStr::new("")), None), (None, Some(""))] { + let cleared = classify(&root(), None, None, None).with_lockfile( + &root(), + env, + config, + Some("custom.lock"), + false, + ); + assert_eq!(cleared, LoadedManifest::Default, "{env:?} {config:?}"); + } + } + + #[test] + fn config_lockfile_reads_bundlers_own_spelling() { + assert_eq!( + config_lockfile("---\nBUNDLE_LOCKFILE: \"custom.lock\"\n"), + Some("custom.lock".into()) + ); + assert_eq!( + config_lockfile("---\nBUNDLE_LOCKFILE: \"\"\n"), + Some(String::new()) + ); + assert_eq!(config_lockfile("---\nBUNDLE_GEMFILE: \"x\"\n"), None); + } + #[test] fn config_gemfile_reads_bundlers_own_spelling() { assert_eq!( diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index aedd9746f..bed397374 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -788,12 +788,15 @@ const GEM_MANIFEST_FILES: [&str; 4] = ["Gemfile", "Gemfile.lock", "gems.rb", "ge /// to bundler's own choice, so it can never wire a manifest bundler /// ignores: /// -/// - no `BUNDLE_GEMFILE`: unchanged (the rewriter's `gems.rb`-first choice -/// and its divergence guard are bundler's default discovery); +/// - no `BUNDLE_GEMFILE`: unchanged for a lone `Gemfile` or `gems.rb`; a +/// `Gemfile` + `gems.rb` twin is withheld, since bundler 1.x loads the +/// `Gemfile`, >= 2 loads `gems.rb`, and nothing says which runs +/// ([`manifest::twin_manifest_refusal`](crate::formats::gem::manifest::twin_manifest_refusal)); /// - `BUNDLE_GEMFILE` naming the root `Gemfile` / `gems.rb`: the other /// spelling is dropped; -/// - `BUNDLE_GEMFILE` naming anything else: every spelling is dropped and -/// [`CandidateFiles::gem_refusal`] says why; +/// - `BUNDLE_GEMFILE` naming anything else, or bundler 4's +/// `BUNDLE_LOCKFILE` naming a lock other than the pair's own: every +/// spelling is dropped and [`CandidateFiles::gem_refusal`] says why; /// - a bundler mirror capturing the patch-registry source (`mirror.all`, /// or `mirror.`; see [`crate::formats::gem::mirror`]): every /// spelling is dropped the same way (#681). @@ -804,7 +807,7 @@ async fn keep_bundler_loaded_gem_files( candidates: &[Candidate], out: &mut CandidateFiles, ) { - use crate::formats::gem::manifest::LoadedManifest; + use crate::formats::gem::manifest::{self, LoadedManifest}; let sources: Vec<&str> = candidates .iter() .filter_map(|c| c.dep.registry_override.as_ref()) @@ -823,8 +826,14 @@ async fn keep_bundler_loaded_gem_files( } }; let refusal = if let Some(detail) = loaded.unsupported_detail() { + let code = match &loaded { + LoadedManifest::UnsupportedLockfile { .. } => { + "redirect_gem_bundle_lockfile_unsupported" + } + _ => "redirect_gem_bundle_gemfile_unsupported", + }; Some(RewriteWarning { - code: "redirect_gem_bundle_gemfile_unsupported".into(), + code: code.into(), detail, }) } else { @@ -841,8 +850,30 @@ async fn keep_bundler_loaded_gem_files( ), }) }; + // A spelling bundler sees (`File.file?`) even when this run couldn't + // read it: a symlink, an unreadable or a non-UTF-8 file still makes the + // project a twin, as lock inventory (`view.is_file`) already counts it. + let present = |rel: &str| { + out.files.contains_key(rel) + || view.is_file(rel) + || out.symlinked_reads.iter().any(|r| r == rel) + || out.unreadable_reads.iter().any(|r| r == rel) + || out.undecodable_reads.iter().any(|r| r == rel) + }; + let is_twin = present("gems.rb") && present("Gemfile"); + let mut twin_ambiguous = None; let keep: &[&str] = match (&loaded, &refusal) { - (_, Some(_)) | (LoadedManifest::Unsupported { .. }, _) => &[], + (_, Some(_)) + | (LoadedManifest::Unsupported { .. } | LoadedManifest::UnsupportedLockfile { .. }, _) => { + &[] + } + // Default discovery of a twin: bundler 1.x loads the `Gemfile` + // and >= 2 loads `gems.rb`, and nothing here says which runs, so + // neither pair is wired (#751). + (LoadedManifest::Default, None) if is_twin => { + twin_ambiguous = Some(manifest::twin_manifest_refusal()); + &[] + } (LoadedManifest::Default, None) => return, (LoadedManifest::Configured { .. }, None) => { let (gemfile, lock) = loaded @@ -856,7 +887,10 @@ async fn keep_bundler_loaded_gem_files( out.symlinked_reads.retain(|rel| !dropped(rel)); out.unreadable_reads.retain(|rel| !dropped(rel)); out.undecodable_reads.retain(|rel| !dropped(rel)); - out.gem_refusal = refusal; + out.gem_refusal = refusal.or(twin_ambiguous.map(|detail| RewriteWarning { + code: "redirect_gem_twin_manifest_ambiguous".into(), + detail, + })); } /// The pypi wheels whose metadata a native lock rewrite needs, in @@ -3172,6 +3206,10 @@ mod tests { PLATFORMS\n ruby\n\nDEPENDENCIES\n rails (= 7.0.0)\n\nBUNDLED WITH\n 2.5.22\n"; async fn gem_rewrite(p: &MemoryProject) -> (CandidateFiles, Rewritten) { + gem_rewrite_in(&ProjectView::Memory(p)).await + } + + async fn gem_rewrite_in(view: &ProjectView<'_>) -> (CandidateFiles, Rewritten) { let outer = OuterAllowRemote::default; let options = RewriteOptions { dry_run: false, @@ -3185,10 +3223,9 @@ mod tests { blocking: false, }; let candidates = vec![gem_candidate()]; - let view = ProjectView::Memory(p); - let read = read_candidate_files(&view, &BTreeSet::new(), &candidates).await; + let read = read_candidate_files(view, &BTreeSet::new(), &candidates).await; let done = rewrite( - &view, + view, read.clone(), &candidates, BTreeMap::new(), @@ -3243,6 +3280,169 @@ mod tests { .collect() } + /// #749: bundler 4's `BUNDLE_LOCKFILE` naming another lock leaves every + /// gem manifest out of the candidates, and the run says why. + #[tokio::test] + async fn bundle_lockfile_naming_another_lock_redirects_nothing() { + let mut p = MemoryProject::new(); + p.insert_text("Gemfile", GEMFILE); + p.insert_text("Gemfile.lock", GEM_LOCK); + p.insert_text("custom.lock", GEM_LOCK); + p.insert_text(".bundle/config", "---\nBUNDLE_LOCKFILE: \"custom.lock\"\n"); + let (read, done) = gem_rewrite(&p).await; + assert!(!read.files.contains_key("Gemfile")); + assert!(!read.files.contains_key("Gemfile.lock")); + assert!( + done.rewrite.files.is_empty(), + "{:?}", + done.rewrite.files.keys() + ); + let codes = warning_codes(&done); + assert!( + codes.contains(&"redirect_gem_bundle_lockfile_unsupported"), + "{codes:?}" + ); + } + + /// #749: a memory view has no real root, so an absolute + /// `BUNDLE_LOCKFILE` that would land on the pair's lock if the project + /// sat at `/` still names a file outside the project. Bundler opens + /// that path, never the in-repo lock, so the pair stays out. + #[tokio::test] + async fn absolute_bundle_lockfile_redirects_nothing() { + for (gems_rb, lock) in [(false, "/Gemfile.lock"), (true, "/gems.locked")] { + let mut p = MemoryProject::new(); + if gems_rb { + p.insert_text("gems.rb", GEMFILE); + p.insert_text("gems.locked", GEM_LOCK); + } else { + p.insert_text("Gemfile", GEMFILE); + p.insert_text("Gemfile.lock", GEM_LOCK); + } + p.insert_text( + ".bundle/config", + format!("---\nBUNDLE_LOCKFILE: \"{lock}\"\n").as_str(), + ); + let (_read, done) = gem_rewrite(&p).await; + assert!( + done.rewrite.files.is_empty(), + "{lock}: {:?}", + done.rewrite.files.keys() + ); + let codes = warning_codes(&done); + assert!( + codes.contains(&"redirect_gem_bundle_lockfile_unsupported"), + "{lock}: {codes:?}" + ); + } + } + + /// #751: a `Gemfile` + `gems.rb` twin is withheld whatever its locks' + /// `BUNDLED WITH` say (which bundler wrote a lock is not which one + /// installs it), and the run says why. + #[tokio::test] + async fn twin_redirects_nothing_whatever_the_locks_say() { + let legacy = GEM_LOCK.replace("2.5.22", "1.17.3"); + for (gemfile_lock, gems_locked) in [ + (legacy.as_str(), legacy.as_str()), + (legacy.as_str(), GEM_LOCK), + (GEM_LOCK, GEM_LOCK), + ] { + let mut p = MemoryProject::new(); + p.insert_text("Gemfile", GEMFILE); + p.insert_text("Gemfile.lock", gemfile_lock); + p.insert_text("gems.rb", GEMFILE); + p.insert_text("gems.locked", gems_locked); + let (_read, done) = gem_rewrite(&p).await; + assert!( + done.rewrite.files.is_empty(), + "{:?}", + done.rewrite.files.keys() + ); + let codes = warning_codes(&done); + assert!( + codes.contains(&"redirect_gem_twin_manifest_ambiguous"), + "{codes:?}" + ); + } + } + + /// A twin whose other spelling this run can't read (a symlink, an + /// unreadable or a non-UTF-8 file) is still a twin: bundler's + /// `File.file?` sees it, so neither pair is wired (Bugbot on #768). + #[tokio::test] + async fn twin_with_an_unreadable_spelling_redirects_nothing() { + for (other, entry) in [ + ("gems.rb", MemoryEntry::Symlink), + ("Gemfile", MemoryEntry::Symlink), + ( + "gems.rb", + MemoryEntry::Binary(vec![0xff, 0xfe, 0x00].into()), + ), + ] { + let mut p = MemoryProject::new(); + for (rel, text) in [ + ("Gemfile", GEMFILE), + ("Gemfile.lock", GEM_LOCK), + ("gems.rb", GEMFILE), + ("gems.locked", GEM_LOCK), + ] { + if rel != other { + p.insert_text(rel, text); + } + } + p.insert(other, entry); + let (_read, done) = gem_rewrite(&p).await; + assert!( + done.rewrite.files.is_empty(), + "{other}: {:?}", + done.rewrite.files.keys() + ); + let codes = warning_codes(&done); + assert!( + codes.contains(&"redirect_gem_twin_manifest_ambiguous"), + "{other}: {codes:?}" + ); + } + } + + /// On disk, a twin spelling that `stat`s as a regular file but can't + /// be read (permission denied) is still a twin: bundler's `File.file?` + /// sees it, so neither pair is wired (Bugbot on #768). + #[cfg(unix)] + #[tokio::test] + async fn twin_with_an_unreadable_disk_spelling_redirects_nothing() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + for (rel, text) in [ + ("Gemfile", GEMFILE), + ("Gemfile.lock", GEM_LOCK), + ("gems.rb", GEMFILE), + ("gems.locked", GEM_LOCK), + ] { + std::fs::write(root.join(rel), text).unwrap(); + } + let gems_rb = root.join("gems.rb"); + std::fs::set_permissions(&gems_rb, std::fs::Permissions::from_mode(0o000)).unwrap(); + if std::fs::read(&gems_rb).is_ok() { + // Running as root: permissions can't make the read fail. + return; + } + let (_read, done) = gem_rewrite_in(&ProjectView::Disk(root)).await; + std::fs::set_permissions(&gems_rb, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert!( + done.rewrite.files.is_empty(), + "{:?}", + done.rewrite.files.keys() + ); + let codes = warning_codes(&done); + assert!( + codes.contains(&"redirect_gem_twin_manifest_ambiguous"), + "{codes:?}" + ); + } + /// #681: `bundle config set --local mirror.all ` sends the /// patch-registry `source` block to the mirror, which serves the /// upstream gem. The redirect used to be written and attested; now no @@ -3359,23 +3559,23 @@ mod tests { assert!(!done.rewrite.files.contains_key("gems.locked")); } - /// Without `BUNDLE_GEMFILE` nothing changes: `gems.rb` is still the - /// spelling bundler (and the rewriter) picks. + /// Without `BUNDLE_GEMFILE` a lone `gems.rb` pair is still the one + /// bundler (and the rewriter) picks; a twin is withheld + /// ([`twin_redirects_nothing_whatever_the_locks_say`]). #[tokio::test] - async fn default_discovery_still_prefers_gems_rb() { + async fn default_discovery_wires_a_lone_gems_rb() { let mut p = MemoryProject::new(); - p.insert_text("Gemfile", GEMFILE); - p.insert_text("Gemfile.lock", GEM_LOCK); p.insert_text("gems.rb", GEMFILE); p.insert_text("gems.locked", GEM_LOCK); - let (read, done) = gem_rewrite(&p).await; - assert!(read.files.contains_key("Gemfile")); + let (_read, done) = gem_rewrite(&p).await; assert!( done.rewrite.files.contains_key("gems.rb"), "{:?}", done.rewrite.files.keys() ); - assert!(!done.rewrite.files.contains_key("Gemfile")); + assert!(warning_codes(&done) + .iter() + .all(|c| !c.starts_with("redirect_gem_twin"))); } /// #333: the Pipenv planner keys a live lock on the `Pipfile` beside diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index e32d1d4c4..0804751c1 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -2982,6 +2982,43 @@ mod tests { assert!(!root.join(".socket/vendor").exists()); } + /// #749: bundler 4's `bundle config set lockfile custom.lock` makes + /// bundler read `custom.lock`, which vendored mode never wires: wiring + /// `Gemfile.lock` would leave the lock bundler installs from untouched. + /// Refused before any write. + #[tokio::test] + async fn a_bundler4_custom_lockfile_is_refused() { + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; + tokio::fs::write(root.join("custom.lock"), LOCK_DIRECT) + .await + .unwrap(); + tokio::fs::create_dir_all(root.join(".bundle")) + .await + .unwrap(); + tokio::fs::write( + root.join(".bundle/config"), + "---\nBUNDLE_LOCKFILE: \"custom.lock\"\n", + ) + .await + .unwrap(); + + let (code, detail) = + unwrap_refused(run_vendor(&root, &blobs, &installed, &record, false).await); + assert_eq!(code, "gemfile_not_loaded"); + assert!(detail.contains("custom.lock"), "{detail}"); + assert!( + detail.contains("bundle config unset --local lockfile"), + "{detail}" + ); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(), + LOCK_DIRECT + ); + assert!(!root.join(".socket/vendor").exists()); + } + /// `BUNDLE_GEMFILE` naming the project's own Gemfile beside a `gems.rb` /// makes bundler load the Gemfile, so vendoring wires it as usual. #[tokio::test] diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/gem.rs b/crates/socket-patch-core/src/vendor/lock_inventory/gem.rs index 90712c074..8591d9907 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/gem.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/gem.rs @@ -4,13 +4,13 @@ use std::path::Path; -use crate::crawlers::ruby_crawler::bundler_loaded_lock_in; +use crate::crawlers::ruby_crawler::{bundler_loaded_lock_diagnosed_in, bundler_loaded_lock_in}; pub(super) use crate::formats::gem::gem_download_url; use crate::formats::gem::GemfileLock; use crate::utils::fs::read_regular_to_string; use super::view::ProjectView; -use super::{dedup_prefer_integrity, LockfileEntry}; +use super::{dedup_prefer_integrity, LockfileEntry, UnsupportedNpmLayout}; // ── registry view ── @@ -58,6 +58,31 @@ pub(super) async fn inventory_gemfile_lock_raw_in( GemfileLock::parse(&text).entries() } +/// Why the project's Bundler lock was not inventoried, when it holds gem +/// files ([`GEM_FILES`]) but bundler loads no lock socket-patch reads +/// ([`bundler_loaded_lock_diagnosed_in`]): an unsupported `BUNDLE_GEMFILE` +/// or `BUNDLE_LOCKFILE`, or a `Gemfile` + `gems.rb` twin (which pair +/// loads depends on the bundler that runs). Without it a lockfile-only scan would +/// report the project's gems as absent rather than unscanned. +pub(super) async fn unsupported_gem_layout_in( + view: &ProjectView<'_>, +) -> Option { + if !GEM_FILES.iter().any(|rel| view.is_file(rel)) { + return None; + } + let reason = bundler_loaded_lock_diagnosed_in(view).await.err()?; + Some(UnsupportedNpmLayout { + code: "gem_lock_unsupported", + detail: format!( + "lockfile-only gem dependencies were NOT scanned: bundler loads no Gemfile.lock \ + or gems.locked socket-patch can read here: {reason}" + ), + }) +} + +/// The manifest and lock spellings of the two default Bundler pairs. +const GEM_FILES: [&str; 4] = ["Gemfile", "Gemfile.lock", "gems.rb", "gems.locked"]; + /// The DISTINCT `GEM remote:` bases across ALL GEM sections of the lock /// bundler loads ([`bundler_loaded_lock_in`]; trailing `/` trimmed), in /// first-appearance order. A vendored gem's spec block moved into its PATH diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs b/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs index 622a3d3e8..f7586fa8f 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs @@ -200,7 +200,9 @@ impl LockfileEntry { #[derive(Debug, Clone, PartialEq, Eq)] pub struct UnsupportedNpmLayout { /// Stable diagnosis code, including `bun_lockb_invalid` for malformed - /// binary Bun locks and the flavor probe's Plug'n'Play refusal codes. + /// binary Bun locks, the flavor probe's Plug'n'Play refusal codes, and + /// `gem_lock_unsupported` for a Bundler lock bundler loads but + /// socket-patch cannot read (despite the name, not only npm). pub code: &'static str, /// Human-readable diagnosis with format or filesystem error details. pub detail: String, @@ -337,6 +339,7 @@ async fn union_views_in( Ok(None) => {} Err(diag) => unsupported.push(diag), } + unsupported.extend(gem::unsupported_gem_layout_in(view).await); let views = [ if every { cargo::inventory_cargo_lock_raw_in(view).await diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs index 84c2f00ff..646810289 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs @@ -1657,6 +1657,94 @@ async fn gem_inventory_memory_view_reads_the_lock_bundler_loads() { assert_eq!(gem_purls(&entries), vec!["pkg:gem/rack@2.0.0"]); } +/// #749 / #751: a gem project whose lock bundler loads is none +/// socket-patch reads (a custom `BUNDLE_LOCKFILE`, an unsupported +/// `BUNDLE_GEMFILE`, a `Gemfile` + `gems.rb` twin) +/// yields no gem entries AND a `gem_lock_unsupported` diagnosis, so a +/// lockfile-only scan says the gems were not scanned instead of reporting +/// none. Supported layouts, and projects without gem files, stay quiet. +#[tokio::test] +async fn gem_inventory_diagnoses_a_lock_it_cannot_read() { + let diagnosed = |project: &MemoryProject| { + let project = project.clone(); + async move { + let (entries, unsupported) = + inventory_project_diagnosed_in(&ProjectView::Memory(&project)).await; + let codes: Vec<&str> = unsupported.iter().map(|d| d.code).collect(); + let detail = unsupported + .iter() + .find(|d| d.code == "gem_lock_unsupported") + .map(|d| d.detail.clone()); + (gem_purls(&entries), codes, detail) + } + }; + let lock = + |bundled: &str| rack_lock("https://rubygems.org/", "2.2.8").replace("2.6.9", bundled); + + let mut custom = MemoryProject::new(); + custom.insert_text("Gemfile", "gem \"rack\"\n"); + custom.insert_text("Gemfile.lock", lock("2.6.2")); + custom.insert_text("custom.lock", lock("2.6.2")); + custom.insert_text(".bundle/config", "---\nBUNDLE_LOCKFILE: \"custom.lock\"\n"); + let (purls, codes, detail) = diagnosed(&custom).await; + assert!(purls.is_empty(), "{purls:?}"); + assert_eq!(codes, vec!["gem_lock_unsupported"]); + let detail = detail.unwrap(); + assert!( + detail.contains("NOT scanned") && detail.contains("custom.lock"), + "{detail}" + ); + + let mut gemfile = MemoryProject::new(); + gemfile.insert_text("Gemfile.lock", lock("2.6.2")); + gemfile.insert_text(".bundle/config", "---\nBUNDLE_GEMFILE: \"Gemfile.next\"\n"); + let (purls, codes, _) = diagnosed(&gemfile).await; + assert!(purls.is_empty(), "{purls:?}"); + assert_eq!(codes, vec!["gem_lock_unsupported"]); + + let mut twin = MemoryProject::new(); + twin.insert_text("Gemfile", "gem \"rack\"\n"); + twin.insert_text("gems.rb", "gem \"rack\"\n"); + twin.insert_text("Gemfile.lock", lock("1.17.3")); + twin.insert_text("gems.locked", lock("2.6.2")); + let (purls, codes, detail) = diagnosed(&twin).await; + assert!(purls.is_empty(), "{purls:?}"); + assert_eq!(codes, vec!["gem_lock_unsupported"]); + assert!(detail.unwrap().contains("gems.rb")); + // Locks that agree on the major still leave the installing bundler + // unknown: a twin is never read. + let mut bundler1 = twin.clone(); + bundler1.insert_text("gems.locked", lock("1.17.3")); + let (purls, codes, _) = diagnosed(&bundler1).await; + assert!(purls.is_empty(), "{purls:?}"); + assert_eq!(codes, vec!["gem_lock_unsupported"]); + + // A symlinked spelling (a git mode-120000 entry, whose target the + // memory view doesn't carry) may still be a file to bundler, so the + // twin stays unread (security review on #768). + for linked in ["gems.rb", "Gemfile"] { + let mut symlinked = twin.clone(); + symlinked.insert(linked, MemoryEntry::Symlink); + let (purls, codes, _) = diagnosed(&symlinked).await; + assert!(purls.is_empty(), "{linked}: {purls:?}"); + assert_eq!(codes, vec!["gem_lock_unsupported"], "{linked}"); + } + + // Supported layouts: entries, no diagnosis. + let mut plain = MemoryProject::new(); + plain.insert_text("Gemfile", "gem \"rack\"\n"); + plain.insert_text("Gemfile.lock", lock("2.6.2")); + let (purls, codes, _) = diagnosed(&plain).await; + assert_eq!(purls, vec!["pkg:gem/rack@2.2.8"]); + assert!(codes.is_empty(), "{codes:?}"); + + // No gem files: a stray bundler setting is not a gem project. + let mut npm = MemoryProject::new(); + npm.insert_text(".bundle/config", "---\nBUNDLE_LOCKFILE: \"custom.lock\"\n"); + let (_, codes, _) = diagnosed(&npm).await; + assert!(codes.is_empty(), "{codes:?}"); +} + /// #736: ledger recovery's GEM remote set comes from the lock bundler /// loads too, never from an ignored twin's sources. #[tokio::test] diff --git a/crates/socket-patch-core/src/vex/discover/gem.rs b/crates/socket-patch-core/src/vex/discover/gem.rs index fd01d019a..6d79cbf7e 100644 --- a/crates/socket-patch-core/src/vex/discover/gem.rs +++ b/crates/socket-patch-core/src/vex/discover/gem.rs @@ -155,7 +155,10 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { extract_file(ctx, file, &mut ignored).await; let why = match loaded { Some(lock) => format!("bundler loads {lock}, not {file}"), - None => "BUNDLE_GEMFILE points bundler at another manifest".to_string(), + None => "socket-patch cannot tell which lock bundler loads here (BUNDLE_GEMFILE or \ + BUNDLE_LOCKFILE names another file, or a Gemfile + gems.rb twin leaves it \ + to the bundler major that runs)" + .to_string(), }; for r in ignored.refs { out.diag( diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 4b0a11d80..14cfcbb79 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -17,7 +17,7 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. | npm (`npm`) — pnpm / yarn / berry / bun / vlt | ✅ any install layout, vlt's `node_modules/.vlt` store included (every store copy, copy-on-write) | ✅ seven 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 text `bun.lock` lockfileVersion 0/1/2 and native binary `bun.lockb` revisions 1/2/3 (binary locks stay binary; text workspace vendoring requires lockfileVersion 2 — see [Bun compatibility](testing/bun-compatibility.md)), vlt `vlt-lock.json` lockfileVersion 0/1 (patched package directories for direct dependencies of the root or a workspace member; transitive targets refused — see [vlt notes](#npm-vlt-notes)). 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, vlt (`vlt-lock.json` without `lockfileVersion`, 0 or 1) — pnpm, berry, bun and vlt carry constraints, see [npm hosted-mode notes](#npm-hosted-mode-notes) and [vlt notes](#npm-vlt-notes) | | PyPI (`pypi`) — uv / poetry / pdm / pipenv / pip | ✅ in place | ✅ uv project/script locks, PEP 751 `pylock.toml` / `pylock..toml`, poetry, pdm, pipenv (Pipenv 2018 or later — every `Pipfile.lock` category is rewired, lock-only checkouts included; Pipenv 2023+ does not hash-check local wheels — `vendor_integrity_unverified`; a venv still holding the upstream release is reported as `pypi_pipenv_stale_install`; see [Pipenv compatibility](testing/pipenv-compatibility.md)), 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 1.x and 2.x locks are supported; Poetry 0.x ignores URL sources and is refused. See [Poetry compatibility](testing/poetry-compatibility.md). Pipenv `Pipfile.lock` (pipfile-spec 6 — Pipenv 7 and later; `path` references for 7–11, `file` from 2018; lock-only checkouts and Pipenv's out-of-tree venv are discovered; a warm venv that Pipenv will not reinstall over warns `redirect_pypi_stale_install`; see [Pipenv compatibility](testing/pipenv-compatibility.md)). `pdm.lock` is supported for the lock formats PDM 0.12–1.4 and 2.8.1+ write (`lock_version` 2 / 4.3–4.5.1); the identity-losing 3.1 / 4.0–4.2 formats (PDM 1.8–2.7) are refused. PDM 2.8.0 writes an indistinguishable `4.3` lock but shares that identity-loss bug, so a rewritten 2.8.0 lock crashes `pdm sync` — upgrade to ≥ 2.8.1. See [PDM compatibility](testing/pdm-compatibility.md). | | 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 in the root `Cargo.toml` (v5; per-version Socket keys; pre-v5 `.cargo/config*` wiring migrates on re-run) | ✅ per-patch sparse registry (`[registries.socket-patch-]` + Cargo.lock source/checksum); direct dependencies only — a crate another dependency also pulls in is refused, use `--mode vendored`; with no `Cargo.lock` the graph is unknown, so only a project whose sole dependency is the patched crate is redirected | -| RubyGems (`gem`) | ✅ in place | ✅ Gemfile + Gemfile.lock path pair (`Gemfile` spelling only — a `gems.rb` twin, which bundler ≥ 2 loads instead, or a `BUNDLE_GEMFILE`-configured manifest makes vendoring refuse with `gemfile_not_loaded` before any write) | ✅ 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`; `BUNDLE_GEMFILE` from `.bundle/config` (which outranks the environment, as in bundler), the environment, or the global `~/.bundle/config` / `$BUNDLE_USER_CONFIG` (lowest, as in bundler) is followed when it names the project's `Gemfile` / `gems.rb`, and any other configured manifest is refused with `redirect_gem_bundle_gemfile_unsupported`); a Bundler all-source, exact-source or hostname mirror in app config or the scan environment can capture the patch registry, so the redirect is refused with `redirect_gem_mirror_overrides_source` without printing mirror URLs (scope mirrors to `mirror.https://rubygems.org`; user-global config and mirrors set only in a later install environment are not inspected); 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") | +| RubyGems (`gem`) | ✅ in place | ✅ Gemfile + Gemfile.lock path pair (`Gemfile` spelling only — a `gems.rb` twin, which bundler ≥ 2 loads instead, or a `BUNDLE_GEMFILE`-configured manifest makes vendoring refuse with `gemfile_not_loaded` before any write) | ✅ per-dep `source` block — edits `gems.rb` + `gems.locked` or `Gemfile` + `Gemfile.lock`, whichever the project holds (a `Gemfile` + `gems.rb` twin is refused with `redirect_gem_twin_manifest_ambiguous`: bundler 1.x loads the `Gemfile`, bundler ≥ 2 loads `gems.rb`, and nothing in the project says which bundler installs it; bundler 4's `BUNDLE_LOCKFILE` (environment, app config or global config) naming any other lock is refused with `redirect_gem_bundle_lockfile_unsupported`, as is vendoring with `gemfile_not_loaded`; spellings that diverge beyond Socket's own edits fail closed with `redirect_gem_gemfile_spellings_diverge`; `BUNDLE_GEMFILE` from `.bundle/config` (which outranks the environment, as in bundler), the environment, or the global `~/.bundle/config` / `$BUNDLE_USER_CONFIG` (lowest, as in bundler) is followed when it names the project's `Gemfile` / `gems.rb`, and any other configured manifest is refused with `redirect_gem_bundle_gemfile_unsupported`); a Bundler all-source, exact-source or hostname mirror in app config or the scan environment can capture the patch registry, so the redirect is refused with `redirect_gem_mirror_overrides_source` without printing mirror URLs (scope mirrors to `mirror.https://rubygems.org`; user-global config and mirrors set only in a later install environment are not inspected); 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") | | Go (`golang`) | ✅ `go.mod` `replace` → `.socket/go-patches/` — see [Go: directory replaces and go.sum](#go-directory-replaces-and-gosum) | ✅ `replace` → the committed vendor tree | ✅ (free tier) fork-style `replace` → `patch.socket.dev/gopatch/` + committed `go.sum` pin; see [Go notes](#go-directory-replaces-and-gosum). Paid hosted patches are unsupported; `redirect_golang_unsupported` names the vendored remedy | | Maven (`maven`) — Maven and Gradle | ✅ in place in every copy the build consumes: each `~/.m2` copy it reads and each Gradle `files-2.1` copy; `~/.m2` `.sha1`/`.md5` sidecars are rewritten, Gradle copies get advisories; jar-member records swap in the patch service's whole jar — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) and [Gradle](#gradle) | ✅ single-POM repository, suffixed Maven reactor repository, or Gradle 6.8+ same-GAV repository with settings wiring and SHA-256 checks (a root with both `pom.xml` and a Gradle build wires both); see [JVM vendoring](design/maven-vendoring.md) and [Gradle](#gradle) | ✅ fail-closed by a Socket-only `-socket.` suffix: pom projects get a pinned `` (`${property}` versions are refused); Gradle 6.8+ builds get an owned settings script, lock-entry rewrites and a resolution tripwire — see [Maven & NuGet caveats](#maven--nuget-caveats) and [Gradle](#gradle) | | sbt / Mill / scala-cli (`maven`) | ✅ Coursier caches (sbt 1.3+, sbt 2, Mill, scala-cli) and Ivy caches (sbt 0.13–1.2, `useCoursier := false`) patched in place, Coursier checksum sidecars resynced — see [Scala build tools](#scala-build-tools-sbt-mill-scala-cli) | ✅ sbt 0.13.18+: generated `socket-patch-vendor.sbt` over the committed suffixed `.socket/vendor/maven2` tree; scala-cli directory builds: owned `socket-patch.scala` + same-GAV `.socket/vendor/coursier` tree (Linux / macOS); Mill: not wired (agent or hosted guidance) | ✅ sbt 0.13.18+: one generated `socket-patch.sbt`, gated on sbt's own `sbt update` records; Mill / scala-cli: paste-able snippets only (`redirect_mill_manual_snippet`, `redirect_scala_cli_manual_snippet`) |