From 5fbb3a05b6e757c56fea61257e3dfe03fe61b6fb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 09:28:45 +0000 Subject: [PATCH 1/7] Start fix for #749, #751 Assisted-by: Claude Code:claude-opus-5-5 From e49d5374b7049608d5de760a45bf4c85aa913669 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 09:40:57 +0000 Subject: [PATCH 2/7] Follow the gem lock and twin Bundler loads Hosted mode wired the wrong gem files in two Bundler layouts, so the scan reported success (and its VEX attested a patch) while Bundler installed the unpatched gem or frozen installs failed: - Bundler 4's custom lockfile (BUNDLE_LOCKFILE, env or .bundle/config) was ignored, so the lock Bundler reads was never pinned (#749). A lockfile naming anything but the pair's own default lock is now refused in hosted (redirect_gem_bundle_lockfile_unsupported) and vendored (gemfile_not_loaded) mode before any write. - A Gemfile + gems.rb twin always followed Bundler >= 2 and wired gems.rb, but Bundler 1.x loads the Gemfile (#751). A twin whose locks say BUNDLED WITH 1.x is now wired through the Gemfile pair, and twin locks that disagree on the major are refused (redirect_gem_twin_bundler_versions_diverge). Assisted-by: Claude Code:claude-opus-5-5 --- CHANGELOG.md | 9 + crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../tests/hosted_memory_engine.rs | 169 ++++++++++++++ .../src/crawlers/ruby_crawler.rs | 144 ++++++++++-- .../src/formats/gem/manifest.rs | 209 +++++++++++++++++- .../socket-patch-core/src/formats/gem/mod.rs | 33 +++ crates/socket-patch-core/src/hosted/engine.rs | 61 ++++- crates/socket-patch-core/src/vendor/gem.rs | 37 ++++ docs/ecosystems.md | 2 +- 9 files changed, 634 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3572a298c..3407d0a30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,6 +117,15 @@ limits, and required install commands. twin or a `BUNDLE_GEMFILE` setting (environment or `.bundle/config`) no longer leads to an edit of an ignored `Gemfile` that reports success and attests an unpatched gem; unsupported layouts are refused before any write (#341, #390). +- Gem hosted mode follows the Bundler that wrote a `Gemfile` + `gems.rb` + twin. Bundler 1.x loads the `Gemfile`, so a twin locked `BUNDLED WITH 1.x` + is now wired there instead of in `gems.rb`. Before, the scan's own VEX + attested a gem Bundler installed unpatched. Twin locks that disagree on the + Bundler major are refused with nothing written (#751). +- Gem hosted and vendored modes refuse a project whose Bundler 4 custom + lockfile (`BUNDLE_LOCKFILE`, or `lockfile` in `.bundle/config`) points away + from the default lock. Before, hosted mode left that lock unpinned, reported + success, and every frozen install then failed (#749). - Gem modes read Bundler settings in Bundler's own priority. A `BUNDLE_GEMFILE` in `.bundle/config` now outranks the environment variable, so a dual-boot project with an exported `BUNDLE_GEMFILE=Gemfile` is no longer wired through diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 69fb09df9..bfdbe9b2f 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -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. 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`. 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, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). 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_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 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). 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. 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`. 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, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). 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 — 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_bundler_versions_diverge` (gem: a `Gemfile` + `gems.rb` twin whose locks were written by different bundler majors; bundler 1.x loads the `Gemfile` and bundler ≥ 2 loads `gems.rb`, so neither is wired — a twin whose every `BUNDLED WITH` is 1.x is wired through its `Gemfile` pair), `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 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). 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 the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (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). diff --git a/crates/socket-patch-cli/tests/hosted_memory_engine.rs b/crates/socket-patch-cli/tests/hosted_memory_engine.rs index 761d34ea9..18594aae8 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_engine.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_engine.rs @@ -989,3 +989,172 @@ 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 a +/// default lock; the no-leftover shape is covered on disk by +/// `ruby_crawler`'s `loaded_manifest_reads_the_lockfile_setting`.) +#[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!( + warning_codes(&project.redirect) + .contains(&"redirect_gem_bundle_lockfile_unsupported".into()), + "{:?}", + warning_codes(&project.redirect) + ); + assert!( + changed_paths(&output).is_empty(), + "{:?}", + changed_paths(&output) + ); +} + +/// #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 `Gemfile` before `gems.rb`. A twin whose locks +/// say bundler 1.17 wrote them is wired through the `Gemfile` pair (the +/// one that installs), never `gems.rb`. +#[tokio::test] +async fn a_bundler1_twin_wires_the_gemfile_pair() { + let (server, input) = gem_server_and_input().await; + let files = gem_twin(&input, ("1.17.3", "1.17.3")); + let output = run_engine(&server, build_input(&files, &[], options(false))).await; + let project = &output.projects[0]; + assert!(project.error.is_none(), "{:?}", project.error); + assert_eq!( + project.redirected.len(), + 1, + "{:?}", + warning_codes(&project.redirect) + ); + assert_eq!(changed_paths(&output), vec!["Gemfile", "Gemfile.lock"]); +} + +/// #751 control: a bundler >= 2 twin still wires `gems.rb`, as bundler +/// >= 2 loads it. +#[tokio::test] +async fn a_bundler2_twin_still_wires_gems_rb() { + let (server, input) = gem_server_and_input().await; + let files = gem_twin(&input, ("2.6.2", "2.6.2")); + 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!["gems.locked", "gems.rb"]); +} + +/// #751: twin locks written by different bundler majors leave no safe +/// spelling to wire: refused, nothing written. +#[tokio::test] +async fn a_twin_with_diverging_bundler_majors_is_refused() { + let (server, input) = gem_server_and_input().await; + for versions in [("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!( + warning_codes(&project.redirect) + .contains(&"redirect_gem_twin_bundler_versions_diverge".into()), + "{versions:?}: {:?}", + warning_codes(&project.redirect) + ); + assert!(changed_paths(&output).is_empty()); + } +} diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index fdeeb5153..4ab5779c4 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -946,29 +946,53 @@ 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(), + 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(), + }, ) .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_loaded_manifest`] with the environment passed explicitly (hermetic -/// tests). `ignore_config` is [`bundler_ignores_config`]. +/// tests). pub async fn bundler_loaded_manifest_with_env( root: &Path, - gemfile_env: Option<&OsStr>, - app_config_env: Option<&OsStr>, - ignore_config: bool, + env: BundlerEnv<'_>, ) -> crate::formats::gem::manifest::LoadedManifest { - let config_value = read_app_config(root, app_config_env, 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); + let gems_rb_present = tokio::fs::symlink_metadata(root.join("gems.rb")) .await - .and_then(|text| crate::formats::gem::manifest::config_gemfile(&text)); - crate::formats::gem::manifest::classify(root, gemfile_env, config_value.as_deref()) + .is_ok(); + manifest::classify(root, env.gemfile, gemfile.as_deref()).with_lockfile( + root, + env.lockfile, + lockfile.as_deref(), + gems_rb_present, + ) } /// Whether bundler skips its config files: `Bundler::Settings#ignore_config?` @@ -1215,7 +1239,7 @@ mod tests { "---\nBUNDLE_GEMFILE: \"Gemfile.next\"\n", ) .unwrap(); - let m = bundler_loaded_manifest_with_env(dir.path(), None, None, false).await; + let m = bundler_loaded_manifest_with_env(dir.path(), BundlerEnv::default()).await; assert!(matches!( m, crate::formats::gem::manifest::LoadedManifest::Unsupported { @@ -1226,14 +1250,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, + 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).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); } @@ -1251,9 +1283,10 @@ mod tests { .unwrap(); let m = bundler_loaded_manifest_with_env( dir.path(), - Some(std::ffi::OsStr::new("Gemfile")), - None, - false, + BundlerEnv { + gemfile: Some(std::ffi::OsStr::new("Gemfile")), + ..BundlerEnv::default() + }, ) .await; assert_eq!( @@ -1265,6 +1298,79 @@ mod tests { ); } + /// #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] diff --git a/crates/socket-patch-core/src/formats/gem/manifest.rs b/crates/socket-patch-core/src/formats/gem/manifest.rs index e697f3587..3fb61b8c8 100644 --- a/crates/socket-patch-core/src/formats/gem/manifest.rs +++ b/crates/socket-patch-core/src/formats/gem/manifest.rs @@ -24,6 +24,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 — `Bundler::CLI` checks the +//! environment first) 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`); +//! [`default_twin_manifest`] settles it from the `BUNDLED WITH` lines of +//! the two locks (#751). +//! //! The user-level `~/.bundle/config` is not consulted. The model is pure: //! the disk and environment reads live in //! [`crate::crawlers::ruby_crawler::bundler_loaded_manifest`]. @@ -68,6 +82,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 { @@ -78,7 +95,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") @@ -107,9 +126,106 @@ 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`", + ), + }; + 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:`), 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>, + gems_rb_present: bool, + ) -> LoadedManifest { + let Some((_, lock)) = self.pair(gems_rb_present) else { + return self; + }; + let (value, by) = match ( + lockfile_env.filter(|v| !v.is_empty()), + lockfile_config.filter(|v| !v.is_empty()), + ) { + (Some(env), _) => (PathBuf::from(env), GemfileSetting::Env), + (None, Some(config)) => (PathBuf::from(config), GemfileSetting::AppConfig), + (None, None) => 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 app config file (bundler 4's +/// `bundle config set lockfile `; an empty value counts as unset). +pub fn config_lockfile(contents: &str) -> Option { + bundle_config_setting(contents, "BUNDLE_LOCKFILE") +} + +/// Which manifest bundler's DEFAULT discovery loads when the root holds +/// both a `Gemfile` and a `gems.rb`, judged from the twin locks' texts +/// (`None` = absent): bundler 1.x tries `Gemfile` first and >= 2 tries +/// `gems.rb` first, and the bundler that runs is the one the locks were +/// written with. `Ok("Gemfile")` when every recorded `BUNDLED WITH` is +/// 1.x, `Ok("gems.rb")` when none is (bundler >= 2's order, also when no +/// lock records a version), and `Err(detail)` when the two locks disagree +/// on the major — then no spelling is safe to wire. +pub fn default_twin_manifest( + gemfile_lock: Option<&str>, + gems_locked: Option<&str>, +) -> Result<&'static str, String> { + let majors: Vec<(&str, u32)> = [("Gemfile.lock", gemfile_lock), ("gems.locked", gems_locked)] + .into_iter() + .filter_map(|(file, text)| Some((file, super::bundled_with_major(text?)?))) + .collect(); + let legacy = majors.iter().filter(|(_, major)| *major < 2).count(); + if legacy == 0 { + Ok("gems.rb") + } else if legacy == majors.len() { + Ok("Gemfile") + } else { + let said: Vec = majors + .iter() + .map(|(file, major)| format!("{file} is BUNDLED WITH {major}.x")) + .collect(); + Err(format!( + "both Gemfile and gems.rb are present and {}; bundler 1.x loads the Gemfile while \ + bundler >= 2 loads gems.rb, so socket-patch cannot tell which pair is installed \ + and left the gem manifests untouched (remove the spelling you don't use, and \ + re-run)", + said.join(" but ") + )) + } } /// The `BUNDLE_GEMFILE:` value of a bundler app config file (flat YAML that @@ -321,6 +437,97 @@ 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).with_lockfile(&root(), None, None, false); + assert_eq!(unset, LoadedManifest::Default); + let own = classify(&root(), None, None).with_lockfile( + &root(), + Some(OsStr::new("Gemfile.lock")), + None, + false, + ); + assert_eq!(own, LoadedManifest::Default); + let custom = + classify(&root(), None, None).with_lockfile(&root(), None, Some("custom.lock"), 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")).with_lockfile( + &root(), + Some(OsStr::new("gems.locked")), + 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")).with_lockfile( + &root(), + Some(OsStr::new("custom.lock")), + None, + false, + ); + assert!(matches!(manifest, LoadedManifest::Unsupported { .. })); + } + + /// #751: bundler 1.x loads a twin's `Gemfile`, >= 2 its `gems.rb`; + /// locks that disagree on the major leave no safe answer. + #[test] + fn default_twin_manifest_follows_the_locks_bundler_major() { + let lock = |v: &str| format!("GEM\n specs:\n\nBUNDLED WITH\n {v}\n"); + let (one, two) = (lock("1.17.3"), lock("2.6.2")); + assert_eq!(default_twin_manifest(Some(&one), Some(&one)), Ok("Gemfile")); + assert_eq!(default_twin_manifest(Some(&one), None), Ok("Gemfile")); + assert_eq!(default_twin_manifest(None, Some(&one)), Ok("Gemfile")); + assert_eq!(default_twin_manifest(Some(&two), Some(&two)), Ok("gems.rb")); + assert_eq!(default_twin_manifest(None, None), Ok("gems.rb")); + // A lock without BUNDLED WITH says nothing either way. + assert_eq!( + default_twin_manifest(Some("GEM\n"), Some(&one)), + Ok("Gemfile") + ); + let err = default_twin_manifest(Some(&one), Some(&two)).unwrap_err(); + assert!( + err.contains("Gemfile.lock is BUNDLED WITH 1.x") + && err.contains("gems.locked is BUNDLED WITH 2.x"), + "{err}" + ); + assert!(default_twin_manifest(Some(&two), Some(&one)).is_err()); + } + + #[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"), None); + 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/formats/gem/mod.rs b/crates/socket-patch-core/src/formats/gem/mod.rs index 3c345cda8..e4e14cbb9 100644 --- a/crates/socket-patch-core/src/formats/gem/mod.rs +++ b/crates/socket-patch-core/src/formats/gem/mod.rs @@ -43,6 +43,20 @@ pub(crate) fn bundler_manifest_for(lock: &str) -> &'static str { } } +/// The major version of the bundler that wrote `text`: the version line +/// under the lock's `BUNDLED WITH` header (` 1.17.3` => `1`). `None` +/// when the lock records no parseable one. CRLF is tolerated. +pub(crate) fn bundled_with_major(text: &str) -> Option { + let mut lines = text.lines().map(|l| l.trim_end_matches('\r')); + lines.find(|l| *l == "BUNDLED WITH")?; + let version = lines.next()?.trim(); + let major = version.split('.').next()?; + if major.is_empty() || !major.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + major.parse().ok() +} + /// Remote URLs compared the way bundler normalizes them (trailing `/`). pub(crate) fn same_remote(a: &str, b: &str) -> bool { a.trim_end_matches('/') == b.trim_end_matches('/') @@ -399,6 +413,25 @@ fn parse_checksum(entry: &str) -> Option<((&str, &str), Option)> { #[cfg(test)] mod tests { + #[test] + fn bundled_with_major_reads_the_version_line() { + assert_eq!( + super::bundled_with_major("GEM\n\nBUNDLED WITH\n 1.17.3\n"), + Some(1) + ); + assert_eq!( + super::bundled_with_major("GEM\r\n\r\nBUNDLED WITH\r\n 2.6.2\r\n"), + Some(2) + ); + assert_eq!( + super::bundled_with_major("BUNDLED WITH\n 4.0.17"), + Some(4) + ); + assert_eq!(super::bundled_with_major("GEM\n"), None); + assert_eq!(super::bundled_with_major("BUNDLED WITH\n"), None); + assert_eq!(super::bundled_with_major("BUNDLED WITH\n x.1\n"), None); + } + use super::*; #[test] diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index aebeac1ab..a65ce7b20 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -522,11 +522,17 @@ const GEM_MANIFEST_FILES: [&str; 4] = ["Gemfile", "Gemfile.lock", "gems.rb", "ge /// ignores: /// /// - no `BUNDLE_GEMFILE`: unchanged (the rewriter's `gems.rb`-first choice -/// and its divergence guard are bundler's default discovery); +/// and its divergence guard are bundler >= 2's default discovery) — +/// except a `Gemfile` + `gems.rb` twin whose locks say bundler 1.x wrote +/// them, which loads the `Gemfile` pair, and a twin whose locks disagree +/// on the bundler major, which is withheld +/// ([`manifest::default_twin_manifest`](crate::formats::gem::manifest::default_twin_manifest)); /// - `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_manifest_unsupported`] 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_manifest_unsupported`] +/// says why. /// /// A memory view has no environment: only its own app config is read. async fn keep_bundler_loaded_gem_files(view: &ProjectView<'_>, out: &mut CandidateFiles) { @@ -538,12 +544,37 @@ async fn keep_bundler_loaded_gem_files(view: &ProjectView<'_>, out: &mut Candida } ProjectView::Memory(_) => { let config = view.read_text(".bundle/config").await.ok(); - let value = config.as_deref().and_then(manifest::config_gemfile); + let gemfile = config.as_deref().and_then(manifest::config_gemfile); + let lockfile = config.as_deref().and_then(manifest::config_lockfile); let root = std::path::Path::new("/"); - manifest::classify(root, None, value.as_deref()) + manifest::classify(root, None, gemfile.as_deref()).with_lockfile( + root, + None, + lockfile.as_deref(), + view.is_file("gems.rb"), + ) } }; + let mut twin_ambiguous = None; let keep: &[&str] = match &loaded { + // Default discovery: the rewriter's `gems.rb`-first choice and its + // divergence guard are bundler >= 2's order. A twin the locks say + // bundler 1.x wrote is loaded through its `Gemfile` instead (#751). + LoadedManifest::Default + if out.files.contains_key("gems.rb") && out.files.contains_key("Gemfile") => + { + match manifest::default_twin_manifest( + out.files.get("Gemfile.lock").map(String::as_str), + out.files.get("gems.locked").map(String::as_str), + ) { + Ok("gems.rb") => return, + Ok(_) => &["Gemfile", "Gemfile.lock"], + Err(detail) => { + twin_ambiguous = Some(detail); + &[] + } + } + } LoadedManifest::Default => return, LoadedManifest::Configured { .. } => { let (gemfile, lock) = loaded @@ -551,16 +582,26 @@ async fn keep_bundler_loaded_gem_files(view: &ProjectView<'_>, out: &mut Candida .expect("a configured default spelling has a pair"); &[gemfile, lock] } - LoadedManifest::Unsupported { .. } => &[], + LoadedManifest::Unsupported { .. } | LoadedManifest::UnsupportedLockfile { .. } => &[], }; let dropped = |rel: &str| GEM_MANIFEST_FILES.contains(&rel) && !keep.contains(&rel); out.files.retain(|rel, _| !dropped(rel)); out.symlinked_reads.retain(|rel| !dropped(rel)); out.unreadable_reads.retain(|rel| !dropped(rel)); - out.gem_manifest_unsupported = loaded.unsupported_detail().map(|detail| RewriteWarning { - code: "redirect_gem_bundle_gemfile_unsupported".into(), - detail, - }); + let code = match &loaded { + LoadedManifest::UnsupportedLockfile { .. } => "redirect_gem_bundle_lockfile_unsupported", + _ => "redirect_gem_bundle_gemfile_unsupported", + }; + out.gem_manifest_unsupported = loaded + .unsupported_detail() + .map(|detail| RewriteWarning { + code: code.into(), + detail, + }) + .or(twin_ambiguous.map(|detail| RewriteWarning { + code: "redirect_gem_twin_bundler_versions_diverge".into(), + detail, + })); } /// The pypi wheels whose metadata a native lock rewrite needs, in diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index 99fc41219..23cafd00c 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -2750,6 +2750,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/docs/ecosystems.md b/docs/ecosystems.md index 1beb7b91d..9279faba3 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) or the environment is followed when it names the project's `Gemfile` / `gems.rb`, and any other configured manifest is refused with `redirect_gem_bundle_gemfile_unsupported`); 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` when present (bundler ≥ 2 prefers them over `Gemfile`; a twin whose locks say `BUNDLED WITH 1.x` is wired through `Gemfile` + `Gemfile.lock`, which bundler 1.x loads, and twin locks that disagree on the bundler major are refused with `redirect_gem_twin_bundler_versions_diverge`; bundler 4's `BUNDLE_LOCKFILE` 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) or the environment is followed when it names the project's `Gemfile` / `gems.rb`, and any other configured manifest is refused with `redirect_gem_bundle_gemfile_unsupported`); 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`) | ✅ in-place jar patching leaves the `~/.m2` checksum sidecars stale — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) | ✅ single-POM repository, suffixed Maven reactor repository, or Gradle 6.8+ same-GAV repository with settings wiring and SHA-256 checks; see [JVM vendoring](design/maven-vendoring.md) | ✅ **pom projects only, fail-closed** — the patched jar is pinned at a Socket-only `-socket.` suffix; `${property}` versions are refused; Gradle gets a manual `exclusiveContent` snippet — see [Maven & NuGet caveats](#maven--nuget-caveats) | | NuGet (`nuget`) | ✅ in-place patching deletes `.nupkg.metadata` and advises on the `.nupkg.sha512` tamper-evidence sidecar — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) | ✅ committed folder feed + `packageSourceMapping` + `packages.lock.json` contentHash pin | ✅ `nuget.config` source + source-mapping, `packages.lock.json` contentHash rewrite. See the locked-mode note in [Maven & NuGet caveats](#maven--nuget-caveats) | From 731f489c623c3e35a793e794013cd0a9ac1424ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 09:42:46 +0000 Subject: [PATCH 3/7] Add real-Bundler e2e for custom lock and twins Two host capstones in e2e_redirect_gem_build: a Bundler 4 project with `lockfile custom.lock` (and a leftover Gemfile.lock) redirects and attests nothing and still installs frozen (#749), and a Bundler 1.x Gemfile + gems.rb twin is wired through the Gemfile and a fresh checkout installs the patched gem (#751). Each skips on the Bundler line it does not apply to. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_redirect_gem_build.rs | 178 +++++++++++++++++- 1 file changed, 177 insertions(+), 1 deletion(-) 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 b69a7c3f3..426e23d9a 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs @@ -429,6 +429,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 locked by + /// bundler 1.x (#751): bundler 1.x loads the `Gemfile`, so the run must + /// wire the `Gemfile` pair and leave `gems.rb` / `gems.locked` + /// untouched. Bundler < 2 only. + ScanVexBundler1Twin, } impl Driver { @@ -442,6 +453,8 @@ impl Driver { Driver::ScanVexDualBootEnvGemfile => { "scan --mode hosted (config Gemfile.next, env BUNDLE_GEMFILE=Gemfile)" } + Driver::ScanVexCustomLockfile => "scan --mode hosted (lockfile custom.lock)", + Driver::ScanVexBundler1Twin => "scan --mode hosted (bundler 1.x Gemfile + gems.rb twin)", } } } @@ -548,6 +561,23 @@ 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") + } + Driver::ScanVexBundler1Twin if bundler.at_least(2, 0) => { + Some("bundler >= 2 loads a twin's gems.rb (covered in-process)") + } + _ => 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(); @@ -818,8 +848,30 @@ 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) + ); + } + if driver == Driver::ScanVexBundler1Twin { + // Identical twins, both `BUNDLED WITH 1.x`: bundler 1.x loads the + // Gemfile pair. + std::fs::copy(proj.join(gemfile_name), proj.join("gems.rb")).unwrap(); + std::fs::copy(proj.join(lock_name), proj.join("gems.locked")).unwrap(); + } let argv: Vec<&str> = match driver { Driver::ScanVex + | Driver::ScanVexCustomLockfile + | Driver::ScanVexBundler1Twin | Driver::ScanVexDualBoot | Driver::ScanVexDualBootEnvGemfile | Driver::ScanVexDuplicateDeclaration @@ -877,6 +929,16 @@ 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, + (code, &stdout, &stderr), + &proj, + &pristine_gemfile, + &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"), @@ -958,7 +1020,7 @@ async fn redirect_scanned_project( ); } match driver { - Driver::ScanVex => { + Driver::ScanVex | Driver::ScanVexBundler1Twin => { assert_eq!(env["vex"]["statements"], 1, "vex block: {env}"); assert_eq!( env["vex"]["verified"], false, @@ -967,6 +1029,7 @@ async fn redirect_scanned_project( } Driver::ScanVexDualBoot | Driver::ScanVexDualBootEnvGemfile + | Driver::ScanVexCustomLockfile | Driver::ScanVexDuplicateDeclaration | Driver::ScanVexEvalGemfile => unreachable!("asserted and returned above"), Driver::GetUuid => { @@ -1065,6 +1128,60 @@ fn assert_unwirable_declaration_redirects_nothing( ); } +/// #749's contract on a bundler 4 project whose `.bundle/config` names +/// `custom.lock`: the hosted scan names the setting, leaves the Gemfile, +/// the leftover `Gemfile.lock` (which bundler ignores) and `custom.lock` +/// byte-identical, and attests nothing. Bundler still installs the +/// untouched project frozen (before the fix every frozen install failed). +fn assert_custom_lockfile_redirects_nothing( + bundler: &bundler_e2e::Bundler, + (code, stdout, stderr): (i32, &str, &str), + proj: &Path, + pristine_gemfile: &[u8], + pristine_lock: &[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(&"redirect_gem_bundle_lockfile_unsupported"), + "the BUNDLE_LOCKFILE 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 [ + ("Gemfile", pristine_gemfile), + ("Gemfile.lock", pristine_lock), + ("custom.lock", pristine_lock), + ] { + 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. @@ -1678,6 +1795,65 @@ 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`, not its `gems.rb`. The +/// hosted scan used to wire `gems.rb` and attest while bundler 1.17 +/// installed the unpatched gem from the `Gemfile`; it must wire the +/// `Gemfile` pair, and a fresh checkout of the twin must install the +/// patched bytes. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "host capstone: shells out to a real ruby/gem/bundler (< 2.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_bundler1_twin_wires_the_gemfile_and_installs() { + let Some(fx) = redirect_scanned_project( + "bundler1-twin", + Spelling::Gemfile, + false, + true, + None, + Driver::ScanVexBundler1Twin, + ) + .await + else { + return; + }; + let gems_rb = std::fs::read(fx.proj.join("gems.rb")).unwrap(); + let gems_locked = std::fs::read(fx.proj.join("gems.locked")).unwrap(); + assert_eq!(gems_rb, fx.pristine_gemfile, "gems.rb must be untouched"); + assert_eq!(gems_locked, fx.pristine_lock, "gems.locked must be untouched"); + let fresh = stage_fresh_checkout(&fx, "fresh"); + std::fs::write(fresh.join("gems.rb"), &gems_rb).unwrap(); + std::fs::write(fresh.join("gems.locked"), &gems_locked).unwrap(); + let install = bundle(&fresh, &["install"]); + assert!( + install.status.success(), + "fresh-checkout `bundle install` of the twin must succeed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&install.stdout), + String::from_utf8_lossy(&install.stderr), + ); + assert_patched_install(&fx, &fresh); +} + /// #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")] From 7c84ba88f4ccc5f3da2ce2a318121ea96892615f Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 5 Oct 2026 07:28:02 -0400 Subject: [PATCH 4/7] Drop CHANGELOG entry from this PR Release notes are written when a release is cut, from the merged PR log and the code, so PRs no longer edit CHANGELOG.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3407d0a30..3572a298c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,15 +117,6 @@ limits, and required install commands. twin or a `BUNDLE_GEMFILE` setting (environment or `.bundle/config`) no longer leads to an edit of an ignored `Gemfile` that reports success and attests an unpatched gem; unsupported layouts are refused before any write (#341, #390). -- Gem hosted mode follows the Bundler that wrote a `Gemfile` + `gems.rb` - twin. Bundler 1.x loads the `Gemfile`, so a twin locked `BUNDLED WITH 1.x` - is now wired there instead of in `gems.rb`. Before, the scan's own VEX - attested a gem Bundler installed unpatched. Twin locks that disagree on the - Bundler major are refused with nothing written (#751). -- Gem hosted and vendored modes refuse a project whose Bundler 4 custom - lockfile (`BUNDLE_LOCKFILE`, or `lockfile` in `.bundle/config`) points away - from the default lock. Before, hosted mode left that lock unpinned, reported - success, and every frozen install then failed (#749). - Gem modes read Bundler settings in Bundler's own priority. A `BUNDLE_GEMFILE` in `.bundle/config` now outranks the environment variable, so a dual-boot project with an exported `BUNDLE_GEMFILE=Gemfile` is no longer wired through From 8c2ba59421a2e49711197f1a85f8b7ad7ea879a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 12:21:57 +0000 Subject: [PATCH 5/7] Fix vex alias tests broken by store-copy merge #605 taught the name-keyed npm resolver to probe bundled store trees, so it now finds aliased copies (node_modules/lp) and a nested host's store peers itself. Two vex_consumed tests from #738 assumed that set never held aliases, so main's CI went red after both merged. The tests now feed the alias-free set explicitly to keep covering alias expansion, and also check the resolver's own set reaches the same copies with no duplicates. No production code changes. Assisted-by: Claude Code:claude-opus-5-5 (cherry picked from commit 40dac077872880b6a9c22ae6bae4fcc988f617e9) --- .../src/commands/vex_consumed.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vex_consumed.rs b/crates/socket-patch-cli/src/commands/vex_consumed.rs index b57d475fb..cb0c68023 100644 --- a/crates/socket-patch-cli/src/commands/vex_consumed.rs +++ b/crates/socket-patch-cli/src/commands/vex_consumed.rs @@ -715,8 +715,11 @@ mod tests { None, ) .await; - assert_eq!(installed_again, installed); - let (paths, calls) = tracked_npm_hosted(&common, &installed_again).await; + // Since #605 the name-keyed resolver probes bundled trees itself, so + // it already returns the aliases and the nested store's peers. Feed + // the earlier, alias-free set to keep exercising alias expansion; + // the resolver's own set is checked against the same result below. + let (paths, calls) = tracked_npm_hosted(&common, &installed).await; assert_eq!(calls.len(), 1); let mut inputs = calls[0].clone(); inputs.sort(); @@ -738,6 +741,9 @@ mod tests { .len(), paths.len() ); + let (mut resolved, _) = tracked_npm_hosted(&common, &installed_again).await; + resolved.sort(); + assert_eq!(resolved, expected, "the resolver's own copy set"); } #[cfg(unix)] @@ -768,14 +774,19 @@ mod tests { None, ) .await; - assert!(installed.is_empty(), "{installed:?}"); - let (mut paths, calls) = tracked_npm_hosted(&common, &installed).await; + // Since #605 the name-keyed resolver reaches the alias and its + // sibling peers on its own. An alias-only set (what an alias-blind + // resolver returns) must still expand to the same copies. + let (mut paths, calls) = tracked_npm_hosted(&common, &HashMap::new()).await; assert_eq!(calls, vec![vec![alias.clone()]]); let mut expected = peers; expected.push(alias); paths.sort(); expected.sort(); assert_eq!(paths, expected); + let (mut resolved, _) = tracked_npm_hosted(&common, &installed).await; + resolved.sort(); + assert_eq!(resolved, expected, "the resolver's own copy set"); } #[cfg(unix)] From 9e7af6ef40c5a61732a2f7c726193bc949d2a59b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 19:47:15 +0000 Subject: [PATCH 6/7] Report gem locks socket-patch cannot read Lock inventory reads only the lock bundler loads (#736), so a custom BUNDLE_LOCKFILE, an unsupported BUNDLE_GEMFILE or a Gemfile + gems.rb twin whose locks disagree on the bundler major left a lockfile-only scan with no gem entries and no warning: the per-candidate redirect refusals never ran because there were no gem candidates. Surface the reason as a gem_lock_unsupported diagnosis on the inventory's existing layout-refusal channel, which scan and the in-memory engine already turn into run-level warnings. An empty BUNDLE_LOCKFILE now shadows the tiers below it, as Settings#[] does, instead of letting a global custom lock through. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018ULgrJMQMWEBAsiuprY449 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../tests/hosted_memory_engine.rs | 22 ++++-- .../src/crawlers/ruby_crawler.rs | 23 ++++-- .../src/formats/gem/manifest.rs | 38 +++++++--- .../src/vendor/lock_inventory/gem.rs | 29 ++++++- .../src/vendor/lock_inventory/mod.rs | 5 +- .../src/vendor/lock_inventory/tests.rs | 75 +++++++++++++++++++ 7 files changed, 169 insertions(+), 25 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index c92d0ec02..b7d854123 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 locks disagree on the bundler major), `warnings[]` carries the additive `gem_lock_unsupported` (detail names the setting or the diverging locks) 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. diff --git a/crates/socket-patch-cli/tests/hosted_memory_engine.rs b/crates/socket-patch-cli/tests/hosted_memory_engine.rs index a1f7e9857..15757c8fd 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_engine.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_engine.rs @@ -1030,10 +1030,10 @@ fn changed_paths(output: &HostedScanOutput) -> Vec<&str> { /// 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 at all (as with an -/// unsupported `BUNDLE_GEMFILE`); the `redirect_gem_bundle_lockfile_unsupported` -/// refusal itself is covered by `ruby_crawler`'s -/// `loaded_manifest_reads_the_lockfile_setting` and the engine unit tests. +/// 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; @@ -1052,6 +1052,14 @@ async fn a_bundler4_custom_lockfile_is_refused() { "{:?}", 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 @@ -1139,8 +1147,9 @@ async fn a_bundler2_twin_still_wires_gems_rb() { /// #751: twin locks written by different bundler majors leave no safe /// spelling to wire: refused, nothing written. No lock is the one bundler -/// loads (#736), so the memory tree yields no gem candidate to warn about; -/// the `redirect_gem_twin_bundler_versions_diverge` refusal is covered by the +/// loads (#736), so the memory tree yields no gem candidate and the run +/// reports `gem_lock_unsupported`; the per-candidate +/// `redirect_gem_twin_bundler_versions_diverge` refusal is covered by the /// engine unit tests. #[tokio::test] async fn a_twin_with_diverging_bundler_majors_is_refused() { @@ -1151,6 +1160,7 @@ async fn a_twin_with_diverging_bundler_majors_is_refused() { let project = &output.projects[0]; assert!(project.redirected.is_empty(), "{versions:?}"); assert!(changed_paths(&output).is_empty()); + assert_gem_lock_unsupported(&output); } } diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index 05da569a2..eceeb6fed 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -1206,6 +1206,17 @@ pub(crate) async fn bundler_loaded_manifest_in( /// 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_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 twin whose locks disagree on the bundler +/// major. 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"); @@ -1215,13 +1226,15 @@ pub(crate) async fn bundler_loaded_lock_in(view: &ProjectView<'_>) -> Option<&'s return match manifest::default_twin_manifest( gemfile_lock.as_deref(), gems_locked.as_deref(), - ) { - Ok("Gemfile") => Some("Gemfile.lock"), - Ok(_) => Some("gems.locked"), - Err(_) => None, + )? { + "Gemfile" => Ok("Gemfile.lock"), + _ => Ok("gems.locked"), }; } - loaded.pair(gems_rb).map(|(_, lock)| lock) + match loaded.pair(gems_rb) { + Some((_, lock)) => Ok(lock), + None => Err(loaded.unsupported_detail().unwrap_or_default()), + } } /// [`bundler_loaded_manifest`] with the environment passed explicitly (hermetic diff --git a/crates/socket-patch-core/src/formats/gem/manifest.rs b/crates/socket-patch-core/src/formats/gem/manifest.rs index 30751cdd8..58fe5b312 100644 --- a/crates/socket-patch-core/src/formats/gem/manifest.rs +++ b/crates/socket-patch-core/src/formats/gem/manifest.rs @@ -50,7 +50,7 @@ use std::ffi::OsStr; use std::path::{Path, PathBuf}; -use crate::crawlers::ruby_crawler::{bundle_config_setting, bundle_config_setting_including_empty}; +use crate::crawlers::ruby_crawler::bundle_config_setting_including_empty; use crate::utils::fs::normalize_lexically; /// Where a configured `BUNDLE_GEMFILE` came from. @@ -186,16 +186,18 @@ impl LoadedManifest { let Some((_, lock)) = self.pair(gems_rb_present) else { return self; }; - let (value, by) = match ( - lockfile_env.filter(|v| !v.is_empty()), - lockfile_config.filter(|v| !v.is_empty()), - lockfile_global.filter(|v| !v.is_empty()), - ) { + // 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 { @@ -208,10 +210,11 @@ impl LoadedManifest { } } -/// The `BUNDLE_LOCKFILE:` value of a bundler app config file (bundler 4's -/// `bundle config set lockfile `; an empty value counts as unset). +/// 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(contents, "BUNDLE_LOCKFILE") + bundle_config_setting_including_empty(contents, "BUNDLE_LOCKFILE") } /// Which manifest bundler's DEFAULT discovery loads when the root holds @@ -613,6 +616,18 @@ mod tests { 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:?}"); + } } /// #751: bundler 1.x loads a twin's `Gemfile`, >= 2 its `gems.rb`; @@ -646,7 +661,10 @@ mod tests { config_lockfile("---\nBUNDLE_LOCKFILE: \"custom.lock\"\n"), Some("custom.lock".into()) ); - assert_eq!(config_lockfile("---\nBUNDLE_LOCKFILE: \"\"\n"), None); + assert_eq!( + config_lockfile("---\nBUNDLE_LOCKFILE: \"\"\n"), + Some(String::new()) + ); assert_eq!(config_lockfile("---\nBUNDLE_GEMFILE: \"x\"\n"), None); } 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..a7b3cba92 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 whose locks +/// disagree on the bundler major. 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 cef50b43a..5ccf55f2d 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs @@ -1597,6 +1597,81 @@ 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 twin whose locks disagree on the bundler major) +/// 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("BUNDLED WITH")); + + // 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:?}"); + let mut bundler1 = twin.clone(); + bundler1.insert_text("gems.locked", lock("1.17.3")); + let (purls, codes, _) = diagnosed(&bundler1).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] From cf746ec8b25b87cb6f7fb0a2ae8e70416c116f37 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 19:18:17 +0000 Subject: [PATCH 7/7] Port #878's digest-helper fix to unbreak coverage utils::digest's production_digests_go_through_the_helpers fails on main: three Gradle/JVM files compute digests inline. That makes `coverage`, `test` and `test-release` red on every PR. #878 routes them through utils::digest. This is the same change, ported so this PR's CI is green. It becomes a no-op once #878 lands. Claude-Session: https://claude.ai/code/session_01LS9AJhpVngXZxng8TRA2Kd Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit 4d8cad2802f99d65152574c4ad2060c42838f98c) --- crates/socket-patch-core/src/crawlers/gradle_cache.rs | 9 ++++----- crates/socket-patch-core/src/patch/jvm_jar.rs | 7 ++----- crates/socket-patch-core/src/patch/sidecars/maven.rs | 4 +--- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/gradle_cache.rs b/crates/socket-patch-core/src/crawlers/gradle_cache.rs index ef295ee27..afd7c4fba 100644 --- a/crates/socket-patch-core/src/crawlers/gradle_cache.rs +++ b/crates/socket-patch-core/src/crawlers/gradle_cache.rs @@ -70,8 +70,7 @@ pub fn hash_eq(dir_name: &str, sha1_hex: &str) -> bool { /// Whether `bytes` are the pristine download Gradle stored in the hash /// directory `dir_name` (their sha1 names it). pub fn pristine(dir_name: &str, bytes: &[u8]) -> bool { - use sha1::{Digest, Sha1}; - hash_eq(dir_name, &hex::encode(Sha1::digest(bytes))) + hash_eq(dir_name, &crate::utils::digest::sha1_hex_of(bytes)) } /// Whether `path` is a version directory of a `files-2.1` tree @@ -432,8 +431,6 @@ impl DerivedIndex { /// The [`DerivedCopies`] of the jar `jar_leaf` whose pristine bytes /// hash to `pristine_sha1`. pub fn query(&self, jar_leaf: &str, pristine_sha1: &str) -> DerivedCopies { - use sha1::{Digest, Sha1}; - let instrumented = format!("instrumented-{jar_leaf}"); let mut out = DerivedCopies { incomplete: self.incomplete, @@ -460,7 +457,9 @@ impl DerivedIndex { out.stale.push(path.clone()); } else if name == jar_leaf || name == instrumented { match crate::utils::fs::read_regular_to_bytes_sync(path) { - Ok(bytes) if hash_eq(&hex::encode(Sha1::digest(&bytes)), pristine_sha1) => { + Ok(bytes) + if hash_eq(&crate::utils::digest::sha1_hex_of(&bytes), pristine_sha1) => + { out.stale.push(path.clone()) } Ok(_) => out.unknown.push(path.clone()), diff --git a/crates/socket-patch-core/src/patch/jvm_jar.rs b/crates/socket-patch-core/src/patch/jvm_jar.rs index 82d679406..f38a84403 100644 --- a/crates/socket-patch-core/src/patch/jvm_jar.rs +++ b/crates/socket-patch-core/src/patch/jvm_jar.rs @@ -25,8 +25,6 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use crate::crawlers::gradle_cache; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; @@ -353,12 +351,11 @@ fn unpatched_members( } fn sha256_hex(bytes: &[u8]) -> String { - use sha2::Digest as _; - hex::encode(sha2::Sha256::digest(bytes)) + crate::utils::digest::sha256_hex_of(bytes) } fn sha1_hex(bytes: &[u8]) -> String { - hex::encode(sha1::Sha1::digest(bytes)) + crate::utils::digest::sha1_hex_of(bytes) } /// `/jvm-originals/.jar`. diff --git a/crates/socket-patch-core/src/patch/sidecars/maven.rs b/crates/socket-patch-core/src/patch/sidecars/maven.rs index f2f5a2466..8798bfce6 100644 --- a/crates/socket-patch-core/src/patch/sidecars/maven.rs +++ b/crates/socket-patch-core/src/patch/sidecars/maven.rs @@ -17,8 +17,6 @@ use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use super::{ SidecarAdvisory, SidecarAdvisoryCode, SidecarError, SidecarFile, SidecarFileAction, SidecarPayload, SidecarSeverity, @@ -44,7 +42,7 @@ impl Algo { fn digest(self, bytes: &[u8]) -> String { match self { - Algo::Sha1 => hex::encode(sha1::Sha1::digest(bytes)), + Algo::Sha1 => crate::utils::digest::sha1_hex_of(bytes), Algo::Md5 => hex::encode(md5(bytes)), } }