diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fc1c47ac..a9ba54701 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,10 @@ limits, and required install commands. ### Fixed +- Gem hosted and vendored modes wire only the manifest Bundler loads. A `gems.rb` + 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). - **npm dependencies installed from git, a URL or `file:` are no longer reported patched.** npm installs such a dependency from the dependent's spec (`github:user/repo`, `https://…/x.tgz`, `file:…`) and ignores the diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 9cf4d9ea1..779456c18 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -159,7 +159,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`). 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_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`). 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` — the environment variable, or `BUNDLE_GEMFILE:` in the bundler app config — 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). 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** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 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/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 4eae3aab2..fcf931bbd 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -2351,6 +2351,21 @@ pub(crate) async fn vendor_records_reusing( // restore, raised HERE instead — the same `failed` event, // code and detail, in the dry run and the wet run alike — // so the hosted wiring stays untouched. + // The gem backend's manifest refusal, likewise raised before + // the restore (a hosted `gems.rb` project cannot vendor). + if candidate.starts_with("pkg:gem/") { + if let Some((code, detail)) = + socket_patch_core::vendor::gem::gem_manifest_refusal(&common.cwd).await + { + has_errors = true; + env.record( + PatchEvent::new(PatchAction::Failed, candidate.clone()) + .with_error(code, detail.clone()), + ); + report_vendor_failure(common, candidate, &detail); + continue; + } + } if candidate.starts_with("pkg:npm/") { let refusal = berry_takeover_refusal .get_or_init(|| { 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 28191bbf0..fe874c013 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs @@ -400,6 +400,11 @@ enum Driver { /// selector (v4.0). No `--vex` (get has none); the uuid path needs only /// the view + reference mocks and is exempt from installed narrowing. GetUuid, + /// [`Driver::ScanVex`] on a dual-boot project whose `.bundle/config` + /// sets `BUNDLE_GEMFILE: "Gemfile.next"` (#390): bundler loads + /// `Gemfile.next`, so the run must redirect nothing and attest nothing. + /// The fixture asserts that contract itself and yields `None`. + ScanVexDualBoot, } impl Driver { @@ -407,6 +412,7 @@ impl Driver { match self { Driver::ScanVex => "scan --mode hosted", Driver::GetUuid => "get --mode hosted", + Driver::ScanVexDualBoot => "scan --mode hosted (BUNDLE_GEMFILE=Gemfile.next)", } } } @@ -754,8 +760,22 @@ async fn redirect_scanned_project( // --vex (get has none), get's envelope with the nested `redirect`. let api = server.uri(); let proj_str = proj.to_str().expect("utf8 tmp path"); + if driver == Driver::ScanVexDualBoot { + // The next-Rails dual boot: a `Gemfile.next` pair that bundler loads + // through the committed `.bundle/config`. + std::fs::copy(proj.join(gemfile_name), proj.join("Gemfile.next")).unwrap(); + std::fs::copy(proj.join(lock_name), proj.join("Gemfile.next.lock")).unwrap(); + let args = bundler.config_local_args("gemfile", "Gemfile.next"); + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + let cfg = bundle(&proj, &args); + assert!( + cfg.status.success(), + "bundle config set --local gemfile failed:\n{}", + String::from_utf8_lossy(&cfg.stderr) + ); + } let argv: Vec<&str> = match driver { - Driver::ScanVex => vec![ + Driver::ScanVex | Driver::ScanVexDualBoot => vec![ "scan", "--mode", "hosted", @@ -792,6 +812,19 @@ async fn redirect_scanned_project( ], }; let (code, stdout, stderr) = run_socket(&proj, &argv); + if driver == Driver::ScanVexDualBoot { + let env: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("not JSON: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}")); + // `--vex` with nothing to attest is an error: the run must not + // look like a successful, attested patch. + assert_ne!(code, 0, "nothing was patched or attested: {env}"); + assert_eq!( + env["error"]["code"], "manifest_not_found", + "envelope: {env}" + ); + assert_dual_boot_redirects_nothing(&env, &proj, &pristine_gemfile, &pristine_lock); + return None; + } assert_eq!( code, 0, @@ -865,6 +898,7 @@ async fn redirect_scanned_project( "in-run hosted VEX is attested from this run's fetched record, not hash-verified: {env}" ); } + Driver::ScanVexDualBoot => unreachable!("asserted and returned above"), Driver::GetUuid => { // get's hosted envelope (CLI_CONTRACT.md "get --mode and // installed narrowing"): `found` counts the resolved patch; @@ -918,6 +952,52 @@ async fn redirect_scanned_project( }) } +/// #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. +/// Then the real bundler, loading `Gemfile.next`, resolves the upstream gem +/// (nothing pretends otherwise). +fn assert_dual_boot_redirects_nothing( + env: &serde_json::Value, + proj: &Path, + pristine_gemfile: &[u8], + pristine_lock: &[u8], +) { + 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_gemfile_unsupported"), + "the BUNDLE_GEMFILE refusal must be reported: {env}" + ); + assert!( + !warning_codes.contains(&"redirect_gem_no_gemfile"), + "the refusal names its real cause, not a missing Gemfile: {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 gem bundler installs unpatched: {env}" + ); + for (file, want) in [ + ("Gemfile", pristine_gemfile), + ("Gemfile.lock", pristine_lock), + ("Gemfile.next", pristine_gemfile), + ("Gemfile.next.lock", pristine_lock), + ] { + assert_eq!( + std::fs::read(proj.join(file)).unwrap(), + want, + "{file} must be byte-untouched" + ); + } + assert_no_redirect_ledger(proj); +} + /// v5 hosted mode never writes `.socket/vendor/redirect-state.json`. fn assert_no_redirect_ledger(proj: &Path) { assert!( @@ -1388,6 +1468,87 @@ async fn gem_hosted_gems_rb_spelling_redirects_and_installs() { manifestless_vex_matrix(&fx, &fresh).await; } +/// #341 follow-through: a CHECKSUMS-converged hosted `gems.rb` pin is a +/// live hosted pin, so `get --mode vendored` takes it over. Vendored mode +/// cannot wire `gems.rb`, and the refusal must come before the takeover +/// reverts the pin. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "host capstone: shells out to a real ruby/gem/bundler (>= 1.17; CHECKSUMS arm >= 2.6); \ + the unpinned `test` job skips it, an e2e job with a pinned toolchain runs it via --ignored"] +async fn gem_hosted_gems_rb_pin_survives_a_refused_vendored_takeover() { + let Some(fx) = redirect_scanned_project( + "gems.rb takeover", + Spelling::GemsRb, + true, + true, + None, + Driver::ScanVex, + ) + .await + else { + return; + }; + vendor_takeover_keeps_the_hosted_gems_rb_pin(&fx); +} + +/// A hosted→vendored takeover of a `gems.rb` project: vendored mode cannot +/// wire `gems.rb`, so `get --mode vendored` must refuse BEFORE it restores the hosted +/// pin's upstream entry, or the gem ends up unpatched in both modes. +fn vendor_takeover_keeps_the_hosted_gems_rb_pin(fx: &RedirectFixture) { + let before: Vec> = ["gems.rb", "gems.locked"] + .iter() + .map(|f| std::fs::read(fx.proj.join(f)).unwrap()) + .collect(); + let proj = fx.proj.to_str().expect("utf8 tmp path"); + let api = fx._server.uri(); + let (code, stdout, stderr) = run_socket( + &fx.proj, + &[ + "get", UUID, "--mode", "vendored", "--json", "--yes", "--cwd", proj, "--api-url", + &api, "--org", ORG, "--api-token", "fake", + // The mock serves the patch registry: its origin is the one a + // hosted pin is trusted on. + "--patch-server-url", &api, + ], + ); + assert_ne!(code, 0, "vendor must refuse.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stdout.contains("gemfile_not_loaded"), + "the manifest refusal names its cause:\n{stdout}" + ); + assert!( + !stdout.contains("vendor_takeover_reverted_redirect"), + "the hosted pin must not be reverted first:\n{stdout}" + ); + for (file, before) in ["gems.rb", "gems.locked"].iter().zip(before) { + assert_eq!( + std::fs::read(fx.proj.join(file)).unwrap(), + before, + "{file} keeps its hosted wiring" + ); + } +} + +/// #390: bundler's `BUNDLE_GEMFILE` (here a committed `.bundle/config` +/// naming `Gemfile.next`, the dual-boot layout) picks the manifest it +/// loads. The hosted scan used to rewrite the ignored `Gemfile`, report +/// success and attest the patch; it must redirect and attest nothing. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "host capstone: shells out to a real ruby/gem/bundler (>= 1.17; CHECKSUMS arm >= 2.6); \ + the unpinned `test` job skips it, an e2e job with a pinned toolchain runs it via --ignored"] +async fn gem_hosted_bundle_gemfile_dual_boot_redirects_nothing() { + let fx = redirect_scanned_project( + "dual-boot", + Spelling::Gemfile, + false, + true, + None, + Driver::ScanVexDualBoot, + ) + .await; + assert!(fx.is_none(), "the dual-boot driver asserts in place"); +} + /// The compact-index DEPENDENCY contract, pinned from the red side: a patch /// registry whose `/info` omits the gem's runtime deps (production's /// HISTORICAL behavior until the 2026-08-18 republish fixed the served index) diff --git a/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs index cf8e660d5..fa81746f8 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs @@ -1398,3 +1398,167 @@ async fn gem_get_uuid_vendored_fresh_checkout_bundle_install() { }); }); } + +/// A real `rack ~> 3.1` project installed into `vendor/bundle`, with a +/// marker patch for the installed rack staged under `.socket/`. `None` = +/// skip (message already printed). +fn staged_rack_project( + tag: &str, +) -> Option<(tempfile::TempDir, PathBuf, bundler_e2e::Bundler, String)> { + let bundler = gate(tag)?; + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + std::fs::write( + proj.join("Gemfile"), + "source \"https://rubygems.org\"\n\ngem \"rack\", \"~> 3.1\"\n", + ) + .unwrap(); + let config = bundle( + &proj, + &argv(&bundler.config_local_args("path", "vendor/bundle")), + false, + ); + let install = config + .status + .success() + .then(|| bundle(&proj, &["install"], false)); + if !install.as_ref().is_some_and(|i| i.status.success()) { + println!("SKIP e2e_vendor_gem_build ({tag}): fixture `bundle install` failed"); + return None; + } + let lock = std::fs::read_to_string(proj.join("Gemfile.lock")).unwrap(); + let version = locked_gem_version(&lock, DEP).expect("resolved rack version"); + let mut ruby = Command::new("ruby"); + ruby.args(["-e", "puts Gem.ruby_api_version"]); + cache_env::isolate(&mut ruby); + let api = ruby.output().expect("failed to run ruby"); + let api = String::from_utf8_lossy(&api.stdout).trim().to_string(); + let installed_rb = proj + .join("vendor/bundle/ruby") + .join(&api) + .join("gems") + .join(format!("{DEP}-{version}")) + .join("lib/rack.rb"); + let orig = std::fs::read(&installed_rb).expect("installed lib/rack.rb"); + let patched: Vec = [orig.as_slice(), b"\n# SOCKET-PATCH-VENDOR-E2E-MARKER\n"].concat(); + let purl = format!("pkg:gem/{DEP}@{version}"); + stage_patch_with_vuln(&proj, &purl, "lib/rack.rb", &orig, &patched); + Some((tmp, proj, bundler, purl)) +} + +/// Run `vendor` on a project whose manifest choice vendored mode cannot +/// follow (`loaded` is what the host bundler loads), and assert the refusal: a `gemfile_not_loaded` failure for the +/// gem, and every manifest and lock byte-untouched. +fn assert_vendor_refuses_unloaded_gemfile(proj: &Path, purl: &str, loaded: &str, files: &[&str]) { + // The premise: the host bundler loads `loaded`. + let probe = bundle( + proj, + &["exec", "ruby", "-e", "puts Bundler.default_gemfile"], + false, + ); + assert!( + String::from_utf8_lossy(&probe.stdout) + .trim() + .ends_with(&format!("/{loaded}")), + "bundler must load {loaded} (test premise):\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&probe.stdout), + String::from_utf8_lossy(&probe.stderr) + ); + let before: Vec> = files + .iter() + .map(|f| std::fs::read(proj.join(f)).unwrap()) + .collect(); + let (code, stdout, stderr) = run_socket( + proj, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_ne!( + code, 0, + "vendor must not succeed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env = parse_envelope(&stdout); + assert_eq!(env["summary"]["applied"], 0, "nothing vendored: {env}"); + let event = env["events"] + .as_array() + .unwrap() + .iter() + .find(|e| e["purl"] == purl) + .unwrap_or_else(|| panic!("an event for {purl}: {env}")); + assert_eq!(event["errorCode"], "gemfile_not_loaded", "event: {event}"); + for (file, before) in files.iter().zip(before) { + assert_eq!( + std::fs::read(proj.join(file)).unwrap(), + before, + "{file} must be byte-untouched" + ); + } + assert!( + !proj.join(".socket/vendor/gem").exists(), + "no vendored copy is written" + ); +} + +/// #341: a `gems.rb` / `gems.locked` twin beside the Gemfile pair is what +/// bundler loads. Vendor used to wire the ignored Gemfile, exit 0, and +/// leave bundler installing upstream rack; it must refuse before any write. +#[test] +#[ignore = "host capstone: shells out to a real bundler >= 1.17; the unpinned `test` job \ + skips it, the e2e job runs it with a pinned toolchain via --ignored"] +fn gem_vendor_refuses_a_gems_rb_twin() { + let Some((_tmp, proj, bundler, purl)) = staged_rack_project("gems.rb twin") else { + return; + }; + std::fs::copy(proj.join("Gemfile"), proj.join("gems.rb")).unwrap(); + std::fs::copy(proj.join("Gemfile.lock"), proj.join("gems.locked")).unwrap(); + // bundler >= 2 loads gems.rb; 1.x still reads the Gemfile first. The + // twin is refused either way (vendor cannot tell which bundler runs). + let loaded = if bundler.at_least(2, 0) { + "gems.rb" + } else { + "Gemfile" + }; + assert_vendor_refuses_unloaded_gemfile( + &proj, + &purl, + loaded, + &["Gemfile", "Gemfile.lock", "gems.rb", "gems.locked"], + ); +} + +/// #390: `bundle config set --local gemfile Gemfile.next` (the dual-boot +/// layout) makes bundler load `Gemfile.next`. Vendor used to wire the +/// ignored Gemfile; it must refuse before any write. +#[test] +#[ignore = "host capstone: shells out to a real bundler >= 1.17; the unpinned `test` job \ + skips it, the e2e job runs it with a pinned toolchain via --ignored"] +fn gem_vendor_refuses_a_bundle_gemfile_dual_boot() { + let Some((_tmp, proj, bundler, purl)) = staged_rack_project("dual boot") else { + return; + }; + std::fs::copy(proj.join("Gemfile"), proj.join("Gemfile.next")).unwrap(); + std::fs::copy(proj.join("Gemfile.lock"), proj.join("Gemfile.next.lock")).unwrap(); + let cfg = bundle( + &proj, + &argv(&bundler.config_local_args("gemfile", "Gemfile.next")), + false, + ); + assert!(cfg.status.success(), "bundle config set --local gemfile"); + assert_vendor_refuses_unloaded_gemfile( + &proj, + &purl, + "Gemfile.next", + &[ + "Gemfile", + "Gemfile.lock", + "Gemfile.next", + "Gemfile.next.lock", + ], + ); +} diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index 8c2a1744e..d8c4faf52 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -944,11 +944,38 @@ fn expand_tilde(value: &Path, home: Option<&Path>) -> PathBuf { value.to_path_buf() } +/// [`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. +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(), + ) + .await +} + +/// [`bundler_loaded_manifest`] with the environment passed explicitly (hermetic +/// tests). +pub async fn bundler_loaded_manifest_with_env( + root: &Path, + gemfile_env: Option<&OsStr>, + app_config_env: Option<&OsStr>, +) -> crate::formats::gem::manifest::LoadedManifest { + let config = bundler_app_config_dir(root, app_config_env).join("config"); + let config_value = crate::utils::fs::read_regular_to_string(&config) + .await + .ok() + .and_then(|text| crate::formats::gem::manifest::config_gemfile(&text)); + crate::formats::gem::manifest::classify(root, gemfile_env, config_value.as_deref()) +} + /// Bundler's app-config dir for `root`, following `Bundler.app_config_path` /// exactly: `$BUNDLE_APP_CONFIG` when set (a relative value resolves against /// the project root, NOT the process cwd), else `/.bundle` — e.g. the /// official ruby Docker images export `BUNDLE_APP_CONFIG=/usr/local/bundle`. -fn bundler_app_config_dir(root: &Path, env_value: Option<&OsStr>) -> PathBuf { +pub(crate) fn bundler_app_config_dir(root: &Path, env_value: Option<&OsStr>) -> PathBuf { match env_value { Some(v) if !v.is_empty() => { let p = PathBuf::from(v); @@ -1070,7 +1097,7 @@ fn parse_bundle_config_path(contents: &str) -> Option { /// Unwrap one bundler app-config scalar: trim, then strip one matching /// pair of double or single quotes (bundler double-quotes what it writes). -fn unquote_bundle_config_value(rest: &str) -> &str { +pub(crate) fn unquote_bundle_config_value(rest: &str) -> &str { let v = rest.trim(); v.strip_prefix('"') .and_then(|s| s.strip_suffix('"')) @@ -1097,6 +1124,33 @@ fn is_safe_gem_coordinate(name: &str, version: &str) -> bool { mod tests { use super::*; + #[tokio::test] + async fn loaded_manifest_reads_the_app_config_file() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join(".bundle")).unwrap(); + std::fs::write( + dir.path().join(".bundle/config"), + "---\nBUNDLE_GEMFILE: \"Gemfile.next\"\n", + ) + .unwrap(); + let m = bundler_loaded_manifest_with_env(dir.path(), None, None).await; + assert!(matches!( + m, + crate::formats::gem::manifest::LoadedManifest::Unsupported { + by: crate::formats::gem::manifest::GemfileSetting::AppConfig, + .. + } + )); + // 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")), + ) + .await; + assert_eq!(m, crate::formats::gem::manifest::LoadedManifest::Default); + } + #[test] fn test_parse_gem_dir_name() { assert_eq!( diff --git a/crates/socket-patch-core/src/formats/gem/manifest.rs b/crates/socket-patch-core/src/formats/gem/manifest.rs new file mode 100644 index 000000000..12644f3a1 --- /dev/null +++ b/crates/socket-patch-core/src/formats/gem/manifest.rs @@ -0,0 +1,268 @@ +//! Which manifest Bundler loads for a project: the ONE answer shared by the +//! hosted rewriter's caller and the vendored backend, so neither can wire a +//! file Bundler ignores. +//! +//! Bundler's own order (`Bundler::SharedHelpers#default_gemfile` and the CLI's +//! `gemfile` setting): +//! +//! 1. `BUNDLE_GEMFILE` from the environment (a relative value is read +//! against the project root: bundler expands it against the directory +//! `bundle` runs in, which is the project, not socket-patch's own +//! cwd when it runs with `--cwd`); +//! 2. `BUNDLE_GEMFILE:` in the app config file, `$BUNDLE_APP_CONFIG/config` +//! else `/.bundle/config` (what `bundle config set --local gemfile +//! Gemfile.next` writes; relative to the project root); +//! 3. otherwise `gems.rb` when present, else `Gemfile` (bundler >= 2; 1.x +//! reads a `Gemfile` first, so callers treat a twin as ambiguous or +//! follow the >= 2 order, as the hosted rewriter does). +//! +//! A configured value that names the root's own `Gemfile` or `gems.rb` is +//! that spelling; anything else (`Gemfile.next`, a file in another +//! 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. +//! 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`]. + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +use crate::crawlers::ruby_crawler::unquote_bundle_config_value; +use crate::utils::fs::normalize_lexically; + +/// Where a configured `BUNDLE_GEMFILE` came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GemfileSetting { + /// The `BUNDLE_GEMFILE` environment variable. + Env, + /// `BUNDLE_GEMFILE:` in the project's bundler app config file. + AppConfig, +} + +impl GemfileSetting { + /// How the setting is named in warnings and refusals. + pub fn describe(self) -> &'static str { + match self { + GemfileSetting::Env => "the BUNDLE_GEMFILE environment variable", + GemfileSetting::AppConfig => { + "BUNDLE_GEMFILE in the bundler app config (.bundle/config)" + } + } + } +} + +/// The manifest Bundler loads for a project root. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LoadedManifest { + /// No `BUNDLE_GEMFILE`: Bundler's default discovery (bundler >= 2: `gems.rb` first, + /// then `Gemfile`), which callers apply to the files they see. + Default, + /// `BUNDLE_GEMFILE` names the root's own `Gemfile` or `gems.rb`. + Configured { + manifest: &'static str, + by: GemfileSetting, + }, + /// `BUNDLE_GEMFILE` names any other file. + Unsupported { value: String, by: GemfileSetting }, +} + +impl LoadedManifest { + /// The manifest/lock pair Bundler loads, given whether the root holds a + /// `gems.rb`. `None` for [`LoadedManifest::Unsupported`]. + pub fn pair(&self, gems_rb_present: bool) -> Option<(&'static str, &'static str)> { + let manifest = match self { + LoadedManifest::Default if gems_rb_present => "gems.rb", + LoadedManifest::Default => "Gemfile", + LoadedManifest::Configured { manifest, .. } => manifest, + LoadedManifest::Unsupported { .. } => return None, + }; + Some(if manifest == "gems.rb" { + ("gems.rb", "gems.locked") + } else { + ("Gemfile", "Gemfile.lock") + }) + } + + /// The detail line for a caller that refuses an unsupported manifest. + pub fn unsupported_detail(&self) -> Option { + match self { + LoadedManifest::Unsupported { value, by } => { + let remedy = match by { + GemfileSetting::Env => { + "unset BUNDLE_GEMFILE, or point it at the project's Gemfile" + } + GemfileSetting::AppConfig => { + "run `bundle config unset --local gemfile`, or point it at the \ + project's Gemfile" + } + }; + Some(format!( + "bundler loads `{value}` ({}), not the project's Gemfile or gems.rb; \ + socket-patch only wires those, so it left the gem manifests untouched \ + ({remedy}, and re-run)", + by.describe() + )) + } + _ => None, + } + } +} + +/// The `BUNDLE_GEMFILE:` value of a bundler app config file (flat YAML that +/// bundler writes itself; an empty value counts as unset). +pub fn config_gemfile(contents: &str) -> Option { + let mut found = None; + for line in contents.lines() { + if let Some(rest) = line.strip_prefix("BUNDLE_GEMFILE:") { + let v = unquote_bundle_config_value(rest); + found = (!v.is_empty()).then(|| v.to_string()); + } + } + found +} + +/// Classify the configured `BUNDLE_GEMFILE` (environment first, then the +/// app config value) against `root`, which also anchors a relative value. +pub fn classify( + root: &Path, + gemfile_env: Option<&OsStr>, + config_value: Option<&str>, +) -> LoadedManifest { + let (value, by) = match gemfile_env.filter(|v| !v.is_empty()) { + Some(v) => (PathBuf::from(v), GemfileSetting::Env), + None => match config_value.filter(|v| !v.is_empty()) { + Some(v) => (PathBuf::from(v), GemfileSetting::AppConfig), + None => return LoadedManifest::Default, + }, + }; + let display = value.display().to_string(); + let absolute = |p: &Path| { + std::path::absolute(p) + .ok() + .and_then(|p| normalize_lexically(&p)) + }; + let target = if value.is_absolute() { + absolute(&value) + } else { + absolute(&root.join(&value)) + }; + let root = absolute(root); + if let (Some(target), Some(root)) = (target, root) { + for manifest in ["Gemfile", "gems.rb"] { + if target == root.join(manifest) { + return LoadedManifest::Configured { manifest, by }; + } + } + } + LoadedManifest::Unsupported { value: display, by } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn root() -> PathBuf { + std::path::absolute("/proj").unwrap() + } + + #[test] + fn no_setting_is_bundlers_default_discovery() { + let m = classify(&root(), None, None); + assert_eq!(m, LoadedManifest::Default); + assert_eq!(m.pair(true), Some(("gems.rb", "gems.locked"))); + assert_eq!(m.pair(false), Some(("Gemfile", "Gemfile.lock"))); + // An empty value is unset, as in bundler. + assert_eq!( + classify(&root(), Some(OsStr::new("")), Some("")), + LoadedManifest::Default + ); + } + + #[test] + fn config_naming_another_manifest_is_unsupported() { + let m = classify(&root(), None, Some("Gemfile.next")); + assert_eq!( + m, + LoadedManifest::Unsupported { + value: "Gemfile.next".into(), + by: GemfileSetting::AppConfig + } + ); + assert_eq!(m.pair(false), None); + assert!(m.unsupported_detail().unwrap().contains("Gemfile.next")); + } + + #[test] + fn config_naming_the_default_spellings_selects_that_pair() { + // `bundle config set --local gemfile Gemfile` beside a gems.rb: + // bundler loads Gemfile + Gemfile.lock, not gems.rb. + let m = classify(&root(), None, Some("Gemfile")); + assert_eq!(m.pair(true), Some(("Gemfile", "Gemfile.lock"))); + let m = classify(&root(), None, Some("./gems.rb")); + assert_eq!(m.pair(false), Some(("gems.rb", "gems.locked"))); + let abs = root().join("Gemfile"); + let m = classify(&root(), None, Some(abs.to_str().unwrap())); + assert_eq!(m.pair(true), Some(("Gemfile", "Gemfile.lock"))); + } + + /// The environment wins over the app config, and a relative value is + /// read against the project root even when socket-patch runs elsewhere + /// with `--cwd` (Bugbot on #431: `BUNDLE_GEMFILE=Gemfile` must select + /// the project's Gemfile, not a file under the process cwd). + #[test] + fn env_wins_over_config_and_is_anchored_at_the_project_root() { + let m = classify(&root(), Some(OsStr::new("Gemfile")), Some("Gemfile.next")); + assert_eq!( + m, + LoadedManifest::Configured { + manifest: "Gemfile", + by: GemfileSetting::Env + } + ); + let m = classify(&root(), Some(OsStr::new("Gemfile.next")), None); + assert!(matches!( + m, + LoadedManifest::Unsupported { + by: GemfileSetting::Env, + .. + } + )); + } + + /// The refusal names the remedy for the knob that set it: unsetting the + /// environment variable does nothing to a `.bundle/config` setting. + #[test] + fn unsupported_detail_names_the_knob_that_set_it() { + let env = classify(&root(), Some(OsStr::new("Gemfile.next")), None); + let env = env.unsupported_detail().unwrap(); + assert!(env.contains("unset BUNDLE_GEMFILE"), "{env}"); + let config = classify(&root(), None, Some("Gemfile.next")); + let config = config.unsupported_detail().unwrap(); + assert!( + config.contains("bundle config unset --local gemfile"), + "{config}" + ); + assert!(!config.contains("unset BUNDLE_GEMFILE"), "{config}"); + } + + #[test] + fn a_manifest_in_another_directory_is_unsupported() { + let m = classify(&root(), None, Some("../other/Gemfile")); + assert!(matches!(m, LoadedManifest::Unsupported { .. })); + let m = classify(&root(), None, Some("sub/Gemfile")); + assert!(matches!(m, LoadedManifest::Unsupported { .. })); + } + + #[test] + fn config_gemfile_reads_bundlers_own_spelling() { + assert_eq!( + config_gemfile( + "---\nBUNDLE_PATH: \"vendor/bundle\"\nBUNDLE_GEMFILE: \"Gemfile.next\"\n" + ), + Some("Gemfile.next".into()) + ); + assert_eq!(config_gemfile("---\nBUNDLE_GEMFILE: \"\"\n"), None); + assert_eq!(config_gemfile("---\nBUNDLE_PATH: \"x\"\n"), None); + } +} diff --git a/crates/socket-patch-core/src/formats/gem/mod.rs b/crates/socket-patch-core/src/formats/gem/mod.rs index a8054fc12..627b5125f 100644 --- a/crates/socket-patch-core/src/formats/gem/mod.rs +++ b/crates/socket-patch-core/src/formats/gem/mod.rs @@ -19,6 +19,7 @@ //! readers that must refuse such a lock. pub(crate) mod hosted; +pub(crate) mod manifest; use std::collections::{BTreeSet, HashMap}; diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 856c8eb9d..a4351b939 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -302,6 +302,10 @@ pub struct CandidateFiles { /// project whose candidates could rewrite (or whose rewrite depends on) /// one is refused, since the rewriters would treat it as absent. pub unreadable_reads: Vec, + /// Set when bundler is configured (`BUNDLE_GEMFILE`) to load a manifest + /// the gem rewriter cannot edit: every gem manifest and lock was left + /// out of `files`, and the rewrite reports this instead of a redirect. + pub gem_manifest_unsupported: Option, } impl CandidateFiles { @@ -458,6 +462,9 @@ pub async fn read_candidate_files( } } } + if candidates.iter().any(|c| c.dep.ecosystem == "gem") { + keep_bundler_loaded_gem_files(view, &mut out).await; + } out.symlinked_reads.sort(); out.symlinked_reads.dedup(); out.unreadable_reads.sort(); @@ -465,6 +472,57 @@ pub async fn read_candidate_files( out } +/// The Bundler manifest/lock spellings among the candidate files. +const GEM_MANIFEST_FILES: [&str; 4] = ["Gemfile", "Gemfile.lock", "gems.rb", "gems.locked"]; + +/// Leave only the gem manifest pair bundler loads in the candidate set +/// (see [`crate::formats::gem::manifest`]). The gem rewriter picks between +/// the two default spellings by filename alone; this narrows what it sees +/// to bundler's own choice, so it can never wire a manifest bundler +/// ignores: +/// +/// - no `BUNDLE_GEMFILE`: unchanged (the rewriter's `gems.rb`-first choice +/// and its divergence guard are bundler's default discovery); +/// - `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. +/// +/// 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) { + use crate::formats::gem::manifest::{self, LoadedManifest}; + let loaded = match view { + ProjectView::Disk(root) + | ProjectView::Snapshot(crate::vendor::lock_inventory::DiskSnapshot { root, .. }) => { + crate::crawlers::ruby_crawler::bundler_loaded_manifest(root).await + } + ProjectView::Memory(_) => { + let config = view.read_text(".bundle/config").await.ok(); + let value = config.as_deref().and_then(manifest::config_gemfile); + let root = std::path::Path::new("/"); + manifest::classify(root, None, value.as_deref()) + } + }; + let keep: &[&str] = match &loaded { + LoadedManifest::Default => return, + LoadedManifest::Configured { .. } => { + let (gemfile, lock) = loaded + .pair(out.files.contains_key("gems.rb")) + .expect("a configured default spelling has a pair"); + &[gemfile, lock] + } + LoadedManifest::Unsupported { .. } => &[], + }; + 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, + }); +} + /// The pypi wheels whose metadata a native lock rewrite needs, in /// candidate order: `(override, sha256)` of every `.whl` artifact some /// `uv.lock` / PEP 723 script lock would rewrite. Each native lock is @@ -727,6 +785,7 @@ pub async fn rewrite( rush_lock_keys, symlinked_reads, unreadable_reads, + gem_manifest_unsupported, } = read; // The rewriters' override slice — materialized ONCE, after the last // candidate filter, so it can never disagree with `candidates`. @@ -788,6 +847,13 @@ pub async fn rewrite( ); (files, rewrite) }; + // The gem files were withheld on purpose: say why, not "no Gemfile". + if let Some(warning) = gem_manifest_unsupported { + rewrite + .warnings + .retain(|w| w.code != "redirect_gem_no_gemfile"); + rewrite.warnings.push(warning); + } if let Some(content) = binary_content { rewrite .warnings @@ -1577,6 +1643,140 @@ mod tests { assert!(read.unreadable_reads.is_empty()); } + fn gem_candidate() -> Candidate { + use crate::patch::redirect::{Integrity, RegistryOverride, RegistryOverrideIdentifiers}; + Candidate { + purl: "pkg:gem/rails@7.0.0".into(), + dep: DepOverride { + ecosystem: "gem".into(), + name: "rails".into(), + namespace: None, + version: "7.0.0".into(), + token: "tok".into(), + patch_uuid: "uuid".into(), + artifact_url: "https://patch.test/rails-7.0.0.gem".into(), + registry_override: Some(RegistryOverride { + kind: "rubygems-compact-index".into(), + index_url: "https://patch.test/gem/tok/uuid/".into(), + identifiers: RegistryOverrideIdentifiers { + name: "rails".into(), + version: "7.0.0".into(), + gem_checksum_sha256: Some("f".repeat(64)), + ..Default::default() + }, + }), + integrity: Integrity::default(), + }, + } + } + + const GEMFILE: &str = "source \"https://rubygems.org\"\n\ngem \"rails\", \"7.0.0\"\n"; + const GEM_LOCK: &str = "GEM\n remote: https://rubygems.org/\n specs:\n rails (7.0.0)\n\n\ + PLATFORMS\n ruby\n\nDEPENDENCIES\n rails (= 7.0.0)\n\nBUNDLED WITH\n 2.5.22\n"; + + async fn gem_rewrite(p: &MemoryProject) -> (CandidateFiles, Rewritten) { + let outer = OuterAllowRemote::default; + let options = RewriteOptions { + dry_run: false, + targets_pipenv_lock: false, + pipenv_major: None, + pipenv_unknown_detail: String::new(), + trust_lockfile_config: true, + npm_allow_remote_config: true, + npm_outer: &outer, + blocking: false, + }; + let candidates = vec![gem_candidate()]; + let view = ProjectView::Memory(p); + let read = read_candidate_files(&view, &BTreeSet::new(), &candidates).await; + let done = rewrite( + &view, + read.clone(), + &candidates, + BTreeMap::new(), + &BTreeSet::new(), + &[], + options, + ) + .await; + (read, done) + } + + /// #390: `bundle config set --local gemfile Gemfile.next` makes bundler + /// load `Gemfile.next`; the hosted redirect used to rewrite `Gemfile` + /// (which bundler ignores) and attest the patch. Now no gem file is a + /// candidate and the run says why. + #[tokio::test] + async fn bundle_gemfile_naming_another_manifest_redirects_nothing() { + let mut p = MemoryProject::new(); + for name in ["Gemfile", "Gemfile.next"] { + p.insert_text(name, GEMFILE); + } + for name in ["Gemfile.lock", "Gemfile.next.lock"] { + p.insert_text(name, GEM_LOCK); + } + p.insert_text(".bundle/config", "---\nBUNDLE_GEMFILE: \"Gemfile.next\"\n"); + let (read, done) = gem_rewrite(&p).await; + assert!(!read.files.contains_key("Gemfile")); + assert!(!read.files.contains_key("Gemfile.lock")); + assert!( + done.rewrite.files.keys().all(|k| !k.starts_with("Gemfile")), + "{:?}", + done.rewrite.files.keys() + ); + let codes: Vec<&str> = done + .rewrite + .warnings + .iter() + .map(|w| w.code.as_str()) + .collect(); + assert!( + codes.contains(&"redirect_gem_bundle_gemfile_unsupported"), + "{codes:?}" + ); + assert!(!codes.contains(&"redirect_gem_no_gemfile"), "{codes:?}"); + } + + /// `BUNDLE_GEMFILE: Gemfile` beside a `gems.rb`: bundler loads the + /// Gemfile pair, so that is the pair the redirect edits (the rewriter's + /// own filename rule would have picked gems.rb). + #[tokio::test] + async fn bundle_gemfile_naming_the_gemfile_redirects_it_over_gems_rb() { + let mut p = MemoryProject::new(); + p.insert_text("Gemfile", GEMFILE); + p.insert_text("Gemfile.lock", GEM_LOCK); + p.insert_text("gems.rb", GEMFILE); + p.insert_text("gems.locked", GEM_LOCK); + p.insert_text(".bundle/config", "---\nBUNDLE_GEMFILE: \"Gemfile\"\n"); + let (_read, done) = gem_rewrite(&p).await; + assert!( + done.rewrite.files.contains_key("Gemfile"), + "{:?}", + done.rewrite.files.keys() + ); + assert!(!done.rewrite.files.contains_key("gems.rb")); + assert!(!done.rewrite.files.contains_key("gems.locked")); + } + + /// Without `BUNDLE_GEMFILE` nothing changes: `gems.rb` is still the + /// spelling bundler (and the rewriter) picks. + #[tokio::test] + async fn default_discovery_still_prefers_gems_rb() { + let mut p = MemoryProject::new(); + p.insert_text("Gemfile", GEMFILE); + p.insert_text("Gemfile.lock", GEM_LOCK); + p.insert_text("gems.rb", GEMFILE); + p.insert_text("gems.locked", GEM_LOCK); + let (read, done) = gem_rewrite(&p).await; + assert!(read.files.contains_key("Gemfile")); + assert!( + done.rewrite.files.contains_key("gems.rb"), + "{:?}", + done.rewrite.files.keys() + ); + assert!(!done.rewrite.files.contains_key("Gemfile")); + } + /// #333: the Pipenv planner keys a live lock on the `Pipfile` beside /// it, so the candidate reads must carry it — read from disk, and kept /// as present in memory even when the host has no content for it. diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index 3e65da507..2aaa4e3d0 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -136,6 +136,50 @@ struct GemPrelude { copy_ok: bool, } +/// Why vendored mode must not wire this project's Gemfile, if it must not: +/// bundler loads a different manifest. This backend edits the `Gemfile` + +/// `Gemfile.lock` pair, so a project where bundler loads `gems.rb` (it wins +/// over a Gemfile twin) or a `BUNDLE_GEMFILE`-configured manifest is refused +/// before any write: wiring the ignored Gemfile would report success while +/// bundler installs the upstream gem. The CLI's hosted→vendored takeover +/// asks this BEFORE it reverts a live hosted pin, so a refused gem keeps +/// its hosted wiring instead of ending up unpatched in both modes. +pub async fn gem_manifest_refusal(project_root: &Path) -> Option<(&'static str, String)> { + use crate::formats::gem::manifest::LoadedManifest; + let loaded = crate::crawlers::ruby_crawler::bundler_loaded_manifest(project_root).await; + let gems_rb_present = tokio::fs::symlink_metadata(project_root.join("gems.rb")) + .await + .is_ok(); + match loaded.pair(gems_rb_present) { + Some((GEMFILE, GEMFILE_LOCK)) => None, + // A `gems.rb` twin is refused whatever bundler runs: bundler >= 2 + // loads `gems.rb` (with a "Multiple gemfiles" warning) while 1.x + // still reads the Gemfile first, so wiring the Gemfile is only + // right on a bundler this backend cannot see. + Some((manifest, lock)) if matches!(loaded, LoadedManifest::Default) => Some(( + "gemfile_not_loaded", + format!( + "a {manifest} sits beside the Gemfile and bundler >= 2 loads {manifest} + \ + {lock} instead of the Gemfile + Gemfile.lock pair vendored mode wires (a \ + gems.rb project cannot vendor yet); use hosted mode, or remove gems.rb / \ + gems.locked if the Gemfile is the real manifest" + ), + )), + Some((manifest, lock)) => Some(( + "gemfile_not_loaded", + format!( + "BUNDLE_GEMFILE makes bundler load {manifest} + {lock}, not the Gemfile + \ + Gemfile.lock pair vendored mode wires (a gems.rb project cannot vendor yet); \ + use hosted mode" + ), + )), + None => Some(( + "gemfile_not_loaded", + loaded.unsupported_detail().unwrap_or_default(), + )), + } +} + async fn gem_prelude( purl: &str, installed_path: &Path, @@ -238,6 +282,9 @@ async fn gem_prelude( } // ── project files ──────────────────────────────────────────────────── + if let Some((code, detail)) = gem_manifest_refusal(project_root).await { + return Err(refused(code, detail)); + } let gemfile_path = project_root.join(GEMFILE); let gemfile_text = match read_regular_to_string(&gemfile_path).await { Ok(t) => t, @@ -2607,6 +2654,112 @@ mod tests { ) } + /// #341: a `gems.rb` beside the Gemfile is the manifest bundler loads + /// ("Multiple gemfiles ... ignoring them in favor of gems.rb"). Wiring + /// the ignored Gemfile reported success while bundler installed the + /// upstream gem; vendor must refuse before any write instead. + #[tokio::test] + async fn gems_rb_twin_is_refused_before_any_write() { + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; + tokio::fs::write(root.join("gems.rb"), GEMFILE_DIRECT) + .await + .unwrap(); + tokio::fs::write(root.join("gems.locked"), LOCK_DIRECT) + .await + .unwrap(); + + let (code, detail) = + unwrap_refused(run_vendor(&root, &blobs, &installed, &record, false).await); + assert_eq!(code, "gemfile_not_loaded"); + assert!(detail.contains("gems.rb"), "{detail}"); + for (file, want) in [ + (GEMFILE, GEMFILE_DIRECT), + (GEMFILE_LOCK, LOCK_DIRECT), + ("gems.rb", GEMFILE_DIRECT), + ("gems.locked", LOCK_DIRECT), + ] { + assert_eq!( + tokio::fs::read_to_string(root.join(file)).await.unwrap(), + want + ); + } + assert!(!root.join(".socket/vendor").exists()); + } + + /// #390: `bundle config set --local gemfile Gemfile.next` makes bundler + /// load `Gemfile.next` (+ `Gemfile.next.lock`); wiring `Gemfile` left the + /// loaded manifest unpatched. Refused before any write. + #[tokio::test] + async fn bundle_gemfile_naming_another_manifest_is_refused() { + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; + tokio::fs::write(root.join("Gemfile.next"), GEMFILE_DIRECT) + .await + .unwrap(); + tokio::fs::write(root.join("Gemfile.next.lock"), LOCK_DIRECT) + .await + .unwrap(); + tokio::fs::create_dir_all(root.join(".bundle")) + .await + .unwrap(); + tokio::fs::write( + root.join(".bundle/config"), + "---\nBUNDLE_GEMFILE: \"Gemfile.next\"\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("Gemfile.next"), "{detail}"); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), + GEMFILE_DIRECT + ); + 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] + async fn bundle_gemfile_naming_the_gemfile_overrides_a_gems_rb_twin() { + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; + tokio::fs::write(root.join("gems.rb"), GEMFILE_DIRECT) + .await + .unwrap(); + tokio::fs::create_dir_all(root.join(".bundle")) + .await + .unwrap(); + tokio::fs::write( + root.join(".bundle/config"), + "---\nBUNDLE_GEMFILE: \"Gemfile\"\n", + ) + .await + .unwrap(); + + let (result, _entry, _w) = + unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(result.success, "vendor failed: {:?}", result.error); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(), + expected_lock_direct() + ); + assert_eq!( + tokio::fs::read_to_string(root.join("gems.rb")) + .await + .unwrap(), + GEMFILE_DIRECT + ); + } + #[tokio::test] async fn test_direct_dep_happy_path() { let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 1814ba2e9..4b8636ef5 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` project cannot vendor yet) | ✅ per-dep `source` block — edits `gems.rb` + `gems.locked` when present (bundler prefers them over `Gemfile`; spellings that diverge beyond Socket's own edits fail closed with `redirect_gem_gemfile_spellings_diverge`); the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning); a stale pre-redirect materialization that `bundle install` would reuse instead of refetching is flagged `redirect_gem_stale_install` with a prescriptive remedy (see CLI_CONTRACT.md's "Gem stale-install guard") | +| 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 the environment or `.bundle/config` 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) |