From 85d021ada30c4b05e434fa60d678e3fb559638c2 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 12:42:46 -0400 Subject: [PATCH 1/4] WIP: Fix hosted cargo redirect over [patch] overrides (#480) Co-Authored-By: Claude Opus 5.5 (1M context) From fd5e12ff8cf74fe746380df048c1066c7087bee8 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 13:44:35 -0400 Subject: [PATCH 2/4] Refuse hosted cargo redirect over [patch] A crate the user overrides with a [patch.crates-io] (or index-URL) path, git or registry entry was still pinned to the Socket registry by scan --mode hosted: the user's fork was silently replaced with the patched crates.io bytes, a source was spliced into the sourceless lock block, and the stale [patch] entry broke every cargo --locked build while the scan reported redirected: 1 with no warnings. The rewriter now refuses such a crate with redirect_cargo_dep_overridden and rewrites nothing. With a lock, a git / other-registry source always refuses, and a sourceless block refuses when the root Cargo.toml or the project .cargo/config* holds a [patch] entry for it (a sourceless block without one is the vendored-takeover revert shape, still pinned). With no lock, the override tables answer directly. Fixes #480 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../tests/e2e_redirect_cargo_shapes.rs | 109 +++++++ .../src/patch/redirect/mod.rs | 282 ++++++++++++++++++ docs/ecosystems.md | 2 +- 4 files changed, 393 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index bb4592f4b..af1a42dca 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -171,7 +171,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Vendored entries and the rest of the CLI.** Because nothing is in the manifest, vendored patches are invisible to `apply` (nothing to apply in place) but fully visible to `list` (listed from the ledger, labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode, exit 0 on a vendored-only project), `vex` (attested from the embedded records while a lockfile still wires the artifact — see "Manifest-less VEX"), `repair` (health-checked and rebuilt from the ledger), and `scan --prune` (lockfile-driven reconcile). They are exempt from standalone `vendor`'s manifest reconcile (`reconcile_dropped` never touches `detached` entries) and exit via `remove ` (which reverts them), `vendor --revert`, or `rollback`, whose vendored leg reverts every in-scope ledger entry (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). -`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. Gradle is confirmed the same way (`confirmed_gradle_uuids`): only when the final files hold the owned script, the index row, the live apply line in every build's settings file and the suffixed version in every lock entry of the GA (see [Gradle builds](#gradle-builds-v50)). A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). A vendored PyPI package (requirements.txt, Poetry, Pipenv, uv, Hatch, PDM, pylock) is taken over the same way: its vendored wiring is restored to the recorded registry entry, its ledger entry and wheel are removed, and it is redirected in the same commit. The Python rewriters treat any non-registry source as user-authored, so without the revert they refused socket-patch's own vendored source and left the project vendored. A takeover revert that leaves vendored wiring in place is refused with `redirect_vendored_revert_failed`. That covers a drift-skipped record (`vendor_lock_entry_drifted`) and a reverted file that still references the artifact (`vendor_revert_residual_reference`). The ledger entry and artifact are kept, and the package stays vendored and skipped. `--dry-run` predicts the same refusal from the same signals instead of previewing `redirect_would_revert_vendored`. The hosted requirements.txt rewriter only rewrites an existing pin in the root `requirements.txt`, so a vendored requirements.txt package whose wiring is a pin in a `-r` include or a `(transitive)` line vendored mode appended is kept vendored, wet and `--dry-run` alike, with `redirect_requirements_takeover_unreachable` (`redirect.warnings[]`, and `redirect.skipped[].reason`). Its wiring, ledger entry and wheel are kept, so it stays vendored and patched (exit 0). The uv and Poetry rewriters are covered the same way: a vendored uv package whose recorded pre-vendor `uv.lock` entry is at another version than the patch (vendored uv pins the entry down to the patch's version; the revert brings the lock's own version back, and hosted mode only pins the version the lock resolves) is kept vendored with `redirect_uv_takeover_version_unreachable`, and a vendored Poetry package on a Poetry 0.x lock (which hosted mode refuses outright) is kept vendored with `redirect_poetry_lock_unsupported`. **Staged takeover (v5.0)**: the takeover is atomic. Each purl's vendored revert is staged in memory (the run's group commit, the same journaled commit vendored mode uses), the hosted rewrite plans against the reverted project, and a staged purl the rewrite does not pin is retracted: every staged revert is undone, that purl keeps its vendored wiring, ledger entry and artifact byte-identical, and the rest are staged and rewritten again. A retracted purl is skipped (`redirect.skipped[].reason`) with the cause: its existing skip reason (unavailable wheel metadata, …), the rewriter warning that names the package (`redirect_yarn_berry_missing_checksum`, `redirect_pypi_platform_wheel`, …), `redirect_requirements_takeover_unreachable`, the rewrite's lock-level refusal (`redirect_yarn_berry_mixed_line_endings`, `redirect_bun_lock_unsupported`, a Gradle planner refusal, …) or `redirect_takeover_not_pinned`; that warning is reported in `redirect.warnings[]`, followed by `redirect_takeover_kept_vendored` naming the package. Exit 0: the package stays vendored and patched, never unpatched in both modes. The reverts, the hosted pins and the vendored ledger then reach the disk in one commit, and the reverted artifacts are deleted only after it: a refusal or write failure before the commit writes nothing, a failed commit puts back the files it replaced, and a commit interrupted after its journal was written is finished by the next command that takes the apply lock. `--dry-run` runs the same steps and drops the staged state instead of committing, so its `redirected` count and warnings are the wet run's. A hosted run with no takeover stages its writes the same way but commits them without the journal (it writes nothing under `.socket/`): a file that fails to be replaced puts back the ones already replaced, so a failed run leaves no lock half-redirected. 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` before its first wet write (the staged takeover 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"`, `error: {code: "lock_held" | "lock_io", message}` (v5.0: the same object every command uses; no top-level `error.code`), and `redirect: {mode: "hosted"}` retained. **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, patches, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). `patches` (additive, v5.0) is the per-purl outcome of every selected patch, sorted by purl: `{purl, uuid, action}` with `action` `pinned` (`would_pin` under `--dry-run`; `redirected` counts these), `skipped` (`errorCode` = the `skipped[]` reason, `error` = its detail when it has one), or `unpinned` (`errorCode: redirect_unconfirmed` — the patch was granted but no lockfile entry pinning it could be rewritten; the human output's `Not hosted : …` line). An `unpinned` or `skipped` row does not change `status` or the exit code (the hosted exit policy is an open decision, #704). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — `BUNDLE_GEMFILE:` in the bundler app config, which outranks the environment variable as in `Bundler::Settings`, else the environment variable — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_gem_bundle_lockfile_unsupported` (gem: bundler 4's custom lockfile — the `BUNDLE_LOCKFILE` environment variable, else `BUNDLE_LOCKFILE:` in the bundler app config, else in the global config — names a lock other than the loaded pair's own `Gemfile.lock` / `gems.locked`, so no gem is redirected or attested rather than pinning a lock bundler ignores), `redirect_gem_twin_manifest_ambiguous` (gem: a `Gemfile` + `gems.rb` twin under default discovery; bundler 1.x loads the `Gemfile` and bundler ≥ 2 loads `gems.rb`, and a lock's `BUNDLED WITH` records which bundler wrote it, not which one installs it, so neither pair is wired or attested — remove the unused spelling or set `BUNDLE_GEMFILE` to the one in use), `redirect_gem_mirror_overrides_source` (gem: Bundler's all-source, exact patch-source or patch-hostname mirror can route the per-dep `source` block to an unpatched upstream gem. Intake reads the app config (`BUNDLE_APP_CONFIG`, where a set-but-empty value selects `/config`, honoring `BUNDLE_IGNORE_CONFIG`) and all `BUNDLE_MIRROR__...` variables visible to the scan; app config overrides the environment per encoded key, then `mirror.all` takes precedence over exact source, which takes precedence over hostname. URI matching follows Bundler's whole-URI case folding, default-port/trailing-slash normalization and single slash key alias, not URL prefixes. An exact-source fallback-timeout key without a mirror URL shadows the hostname mirror and fetches that source directly; a configured URL is conservatively refused even if a timeout could bypass an unreachable mirror at install time. Like `redirect_gem_bundle_gemfile_unsupported`, the gate leaves the Gemfile pair byte-identical and confirms no gem redirect. On an embedded `scan --vex`, rediscovered older hosted gem pins may attest only from verified installed bytes: a missing tree is not excused by the lockfile, and `--vex-no-verify` omits those hosted gems with `mirror_overrides_source` rather than trusting their intercepted source. Agent/vendored evidence, unrelated ecosystems and standalone VEX behavior are unchanged. Details identify the setting form and its app/environment origin without printing mirror values or source URLs, which may contain credentials. Remove the applicable all/source/hostname setting (including any slash alias) from that origin and reuse its existing mirror URL under `mirror.https://rubygems.org` to clear the refusal; an environment setting must be unset in the scan/install environment. User-global Bundler config and mirrors set only in a later install environment are not inspected; keep those mirrors scoped to the upstream source too), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also additive: `redirect_gem_version_not_locked` (gem: no `GEM` section of the lock lists the crawled `name (version)`, for example a version another project installed into the shared gem home; the gem is skipped with nothing written, so the user's declared constraint and the locked version are never overwritten). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). The root `package.json`, which the rewrite re-renders to add `resolutions`, gets the same gate: a mixed one is refused untouched with the same code — the decision vendored mode takes with `vendor_yarn_berry_mixed_line_endings`, from the same shared berry gate set. This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover of a yarn-berry entry is checked against these project gates (mixed `yarn.lock` / `package.json` line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) on the project as it is before any revert, because the revert re-renders `package.json` in its majority ending — wet and `--dry-run` alike. A refused purl keeps its vendored wiring, ledger entry and artifact byte-identical, is skipped with the gate's code (reported in `redirect.warnings[]`, followed by `redirect_takeover_kept_vendored`) and is never announced as `redirect_takeover_reverted_vendored`. +`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. Gradle is confirmed the same way (`confirmed_gradle_uuids`): only when the final files hold the owned script, the index row, the live apply line in every build's settings file and the suffixed version in every lock entry of the GA (see [Gradle builds](#gradle-builds-v50)). A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). A vendored PyPI package (requirements.txt, Poetry, Pipenv, uv, Hatch, PDM, pylock) is taken over the same way: its vendored wiring is restored to the recorded registry entry, its ledger entry and wheel are removed, and it is redirected in the same commit. The Python rewriters treat any non-registry source as user-authored, so without the revert they refused socket-patch's own vendored source and left the project vendored. A takeover revert that leaves vendored wiring in place is refused with `redirect_vendored_revert_failed`. That covers a drift-skipped record (`vendor_lock_entry_drifted`) and a reverted file that still references the artifact (`vendor_revert_residual_reference`). The ledger entry and artifact are kept, and the package stays vendored and skipped. `--dry-run` predicts the same refusal from the same signals instead of previewing `redirect_would_revert_vendored`. The hosted requirements.txt rewriter only rewrites an existing pin in the root `requirements.txt`, so a vendored requirements.txt package whose wiring is a pin in a `-r` include or a `(transitive)` line vendored mode appended is kept vendored, wet and `--dry-run` alike, with `redirect_requirements_takeover_unreachable` (`redirect.warnings[]`, and `redirect.skipped[].reason`). Its wiring, ledger entry and wheel are kept, so it stays vendored and patched (exit 0). The uv and Poetry rewriters are covered the same way: a vendored uv package whose recorded pre-vendor `uv.lock` entry is at another version than the patch (vendored uv pins the entry down to the patch's version; the revert brings the lock's own version back, and hosted mode only pins the version the lock resolves) is kept vendored with `redirect_uv_takeover_version_unreachable`, and a vendored Poetry package on a Poetry 0.x lock (which hosted mode refuses outright) is kept vendored with `redirect_poetry_lock_unsupported`. **Staged takeover (v5.0)**: the takeover is atomic. Each purl's vendored revert is staged in memory (the run's group commit, the same journaled commit vendored mode uses), the hosted rewrite plans against the reverted project, and a staged purl the rewrite does not pin is retracted: every staged revert is undone, that purl keeps its vendored wiring, ledger entry and artifact byte-identical, and the rest are staged and rewritten again. A retracted purl is skipped (`redirect.skipped[].reason`) with the cause: its existing skip reason (unavailable wheel metadata, …), the rewriter warning that names the package (`redirect_yarn_berry_missing_checksum`, `redirect_pypi_platform_wheel`, …), `redirect_requirements_takeover_unreachable`, the rewrite's lock-level refusal (`redirect_yarn_berry_mixed_line_endings`, `redirect_bun_lock_unsupported`, a Gradle planner refusal, …) or `redirect_takeover_not_pinned`; that warning is reported in `redirect.warnings[]`, followed by `redirect_takeover_kept_vendored` naming the package. Exit 0: the package stays vendored and patched, never unpatched in both modes. The reverts, the hosted pins and the vendored ledger then reach the disk in one commit, and the reverted artifacts are deleted only after it: a refusal or write failure before the commit writes nothing, a failed commit puts back the files it replaced, and a commit interrupted after its journal was written is finished by the next command that takes the apply lock. `--dry-run` runs the same steps and drops the staged state instead of committing, so its `redirected` count and warnings are the wet run's. A hosted run with no takeover stages its writes the same way but commits them without the journal (it writes nothing under `.socket/`): a file that fails to be replaced puts back the ones already replaced, so a failed run leaves no lock half-redirected. 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` before its first wet write (the staged takeover 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"`, `error: {code: "lock_held" | "lock_io", message}` (v5.0: the same object every command uses; no top-level `error.code`), and `redirect: {mode: "hosted"}` retained. **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, patches, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). `patches` (additive, v5.0) is the per-purl outcome of every selected patch, sorted by purl: `{purl, uuid, action}` with `action` `pinned` (`would_pin` under `--dry-run`; `redirected` counts these), `skipped` (`errorCode` = the `skipped[]` reason, `error` = its detail when it has one), or `unpinned` (`errorCode: redirect_unconfirmed` — the patch was granted but no lockfile entry pinning it could be rewritten; the human output's `Not hosted : …` line). An `unpinned` or `skipped` row does not change `status` or the exit code (the hosted exit policy is an open decision, #704). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — `BUNDLE_GEMFILE:` in the bundler app config, which outranks the environment variable as in `Bundler::Settings`, else the environment variable — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_gem_bundle_lockfile_unsupported` (gem: bundler 4's custom lockfile — the `BUNDLE_LOCKFILE` environment variable, else `BUNDLE_LOCKFILE:` in the bundler app config, else in the global config — names a lock other than the loaded pair's own `Gemfile.lock` / `gems.locked`, so no gem is redirected or attested rather than pinning a lock bundler ignores), `redirect_gem_twin_manifest_ambiguous` (gem: a `Gemfile` + `gems.rb` twin under default discovery; bundler 1.x loads the `Gemfile` and bundler ≥ 2 loads `gems.rb`, and a lock's `BUNDLED WITH` records which bundler wrote it, not which one installs it, so neither pair is wired or attested — remove the unused spelling or set `BUNDLE_GEMFILE` to the one in use), `redirect_gem_mirror_overrides_source` (gem: Bundler's all-source, exact patch-source or patch-hostname mirror can route the per-dep `source` block to an unpatched upstream gem. Intake reads the app config (`BUNDLE_APP_CONFIG`, where a set-but-empty value selects `/config`, honoring `BUNDLE_IGNORE_CONFIG`) and all `BUNDLE_MIRROR__...` variables visible to the scan; app config overrides the environment per encoded key, then `mirror.all` takes precedence over exact source, which takes precedence over hostname. URI matching follows Bundler's whole-URI case folding, default-port/trailing-slash normalization and single slash key alias, not URL prefixes. An exact-source fallback-timeout key without a mirror URL shadows the hostname mirror and fetches that source directly; a configured URL is conservatively refused even if a timeout could bypass an unreachable mirror at install time. Like `redirect_gem_bundle_gemfile_unsupported`, the gate leaves the Gemfile pair byte-identical and confirms no gem redirect. On an embedded `scan --vex`, rediscovered older hosted gem pins may attest only from verified installed bytes: a missing tree is not excused by the lockfile, and `--vex-no-verify` omits those hosted gems with `mirror_overrides_source` rather than trusting their intercepted source. Agent/vendored evidence, unrelated ecosystems and standalone VEX behavior are unchanged. Details identify the setting form and its app/environment origin without printing mirror values or source URLs, which may contain credentials. Remove the applicable all/source/hostname setting (including any slash alias) from that origin and reuse its existing mirror URL under `mirror.https://rubygems.org` to clear the refusal; an environment setting must be unset in the scan/install environment. User-global Bundler config and mirrors set only in a later install environment are not inspected; keep those mirrors scoped to the upstream source too), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip), `redirect_cargo_dep_overridden` (the crate does not resolve to crates.io because the user overrides it — its one `Cargo.lock` block carries a git / other-registry `source`, as a `[patch]` git or registry override in the root manifest or any cargo config leaves it, or has no `source` while the root `Cargo.toml` / project `.cargo/config*` holds a `[patch]` path entry for it; with no `Cargo.lock`, the same `[patch.crates-io]` / `[patch."https://github.com/rust-lang/crates.io-index"]` entry for the crate, by key or `package = "…"`, in the root `Cargo.toml` or the project `.cargo/config*` — transactional skip, nothing rewritten: a Socket pin would silently replace the user's fork and the then-unused `[patch]` entry breaks `cargo --locked`). Also additive: `redirect_gem_version_not_locked` (gem: no `GEM` section of the lock lists the crawled `name (version)`, for example a version another project installed into the shared gem home; the gem is skipped with nothing written, so the user's declared constraint and the locked version are never overwritten). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). The root `package.json`, which the rewrite re-renders to add `resolutions`, gets the same gate: a mixed one is refused untouched with the same code — the decision vendored mode takes with `vendor_yarn_berry_mixed_line_endings`, from the same shared berry gate set. This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover of a yarn-berry entry is checked against these project gates (mixed `yarn.lock` / `package.json` line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) on the project as it is before any revert, because the revert re-renders `package.json` in its majority ending — wet and `--dry-run` alike. A refused purl keeps its vendored wiring, ledger entry and artifact byte-identical, is skipped with the gate's code (reported in `redirect.warnings[]`, followed by `redirect_takeover_kept_vendored`) and is never announced as `redirect_takeover_reverted_vendored`. **Attribution gate (v5.0).** A hosted run never leaves wiring that lockfile discovery calls contested: before the rewrite writes any lockfile, the same discovery `vex`, `list`, `rollback`, `remove` and `vendor` read runs over the project as the rewrite would leave it. A candidate whose pin discovery reads but cannot attribute to one package version (a requirements `-r` include resolving the same version from the registry beside a rewired `Pipfile.lock`, #567; a Maven pin in a ``, #260) is left out of the rewrite and reported in `redirect.skipped[]` as `redirect_unattributable` (nothing written for it; exit code unchanged). Unchanged: a pin in a file discovery does not read (a pre-2.6 bundler `Gemfile`, locked by the next `bundle install`) keeps the rewriter's verdict, and so does a deliberate partial redirect the run already reports (a bundled or `bun patch`-ed copy left on the registry, a yarn `npm:` alias entry left on the registry with `redirect_yarn_classic_alias_skipped` / `redirect_yarn_berry_alias_skipped` while the package's direct entry is pinned, a dep withheld from the vlt rewrite while a sibling lock takes it), and so does a vendored→hosted takeover: its vendored wiring is reverted in the run's staged overlay before the rewrite plans, and it is redirected when the rewriters pin it (otherwise it is retracted and stays vendored), in a `--dry-run` preview the same. A lockless NuGet / Cargo pin (an exclusive Socket source mapping without `packages.lock.json`, a Cargo registry pin without `Cargo.lock`) is still written, with a `redirect_pin_lockless` warning: no lockfile records its version, so `vex` cannot attest it and `rollback` / `remove` / `vendor` refuse it until the lockfile exists (whether such pins should be written at all is an open decision). The rollout's recorded view uses the same discovery: a uuid a file merely mentions (a stale `package.json` field, an inactive `pdm.lock`, a comment) is not a pin and does not count as already patched. diff --git a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs index 809eff910..34434948a 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs @@ -21,6 +21,10 @@ //! * `lockless_other_dependencies` — the same project with no committed //! `Cargo.lock`: with no resolved graph to read, a crate declared beside //! any other dependency is refused just as loudly. +//! * `user_patch_same_crate` / `config_patch_same_crate` — the user +//! overrides the patched crate with a `[patch]` path entry (root manifest +//! or `.cargo/config.toml`): hosted mode refuses it rather than dropping +//! the override and breaking `--locked` (#480). //! //! Every shape runs the same chain against the real cargo: a baseline build //! with a private CARGO_HOME (network to crates.io for fixture setup only), @@ -109,6 +113,11 @@ struct Shape { /// A shape hosted mode must REFUSE: the rewriter warning code every /// patch is skipped with. The scan must leave every file untouched. refused: Option<&'static str>, + /// Extra crates.io dependencies fetched into the CARGO_HOME by a helper + /// project before the scan: a patched crate the project itself resolves + /// elsewhere (a `[patch]` override) still needs its registry copy for + /// the served `.crate`, exactly as on a machine that built it before. + prefetch: &'static str, } fn run_socket(cwd: &Path, args: &[&str], cargo_home: &Path) -> (i32, String, String) { @@ -477,6 +486,19 @@ async fn run_shape(shape: Shape) -> Option<()> { if shape.lockless { std::fs::remove_file(proj.join("Cargo.lock")).unwrap(); } + if !shape.prefetch.is_empty() { + let helper = tmp.path().join("prefetch"); + std::fs::create_dir_all(helper.join("src")).unwrap(); + std::fs::write(helper.join("Cargo.toml"), helper_manifest(shape.prefetch)).unwrap(); + std::fs::write(helper.join("src/main.rs"), "fn main() {}\n").unwrap(); + let fetched = cargo(&helper, &["build", "-q"], &home); + assert!( + fetched.status.success(), + "{}: the prefetch helper must build:\n{}", + shape.tag, + stderr(&fetched) + ); + } let before = snapshot(&proj); let mut served = Vec::new(); @@ -787,6 +809,10 @@ fn pin_patched_versions(proj: &Path, home: &Path, patches: &[Patch]) { } } +fn helper_manifest(deps: &str) -> String { + format!("[package]\nname = \"prefetch\"\nversion = \"0.1.0\"\nedition = \"2018\"\n\n[dependencies]\n{deps}") +} + fn consumer_manifest(deps: &str) -> String { format!("[package]\nname = \"consumer\"\nversion = \"0.1.0\"\nedition = \"2018\"\n\n[dependencies]\n{deps}") } @@ -814,6 +840,7 @@ async fn cargo_hosted_multi_version_pins_each_declaration_and_removes_cleanly() crlf: false, lockless: false, refused: None, + prefetch: "", }; let _ = run_shape(shape).await; } @@ -837,6 +864,7 @@ async fn cargo_hosted_legacy_config_is_restored_byte_for_byte() { crlf: false, lockless: false, refused: None, + prefetch: "", }; let _ = run_shape(shape).await; } @@ -875,6 +903,7 @@ async fn cargo_hosted_config_trailing_bytes_are_restored() { crlf: false, lockless: false, refused: None, + prefetch: "", }; if run_shape(shape).await.is_none() { return; @@ -908,6 +937,7 @@ async fn cargo_hosted_same_line_in_two_sections_removes_cleanly() { crlf: false, lockless: false, refused: None, + prefetch: "", }; let _ = run_shape(shape).await; } @@ -947,6 +977,7 @@ async fn cargo_hosted_workspace_member_declaration_is_pinned() { crlf: false, lockless: false, refused: None, + prefetch: "", }; let _ = run_shape(shape).await; } @@ -969,6 +1000,7 @@ async fn cargo_hosted_crlf_project_keeps_its_line_endings() { crlf: true, lockless: false, refused: None, + prefetch: "", }; let _ = run_shape(shape).await; } @@ -994,6 +1026,7 @@ async fn cargo_hosted_refuses_a_crate_another_crate_depends_on() { crlf: false, lockless: false, refused: Some("redirect_cargo_transitive_dependents"), + prefetch: "", }; let _ = run_shape(shape).await; } @@ -1020,6 +1053,82 @@ async fn cargo_hosted_refuses_a_lockless_project_with_other_dependencies() { crlf: false, lockless: true, refused: Some("redirect_cargo_lockless_dependents"), + prefetch: "", + }; + let _ = run_shape(shape).await; +} + +fn local_crate(name: &str, version: &str) -> String { + format!("[package]\nname = \"{name}\"\nversion = \"{version}\"\nedition = \"2018\"\n") +} + +/// #480: the user overrides the patched crate with a root +/// `[patch.crates-io]` path entry. Pinning the declaration to the Socket +/// registry would silently drop the override (cargo: "patch … was not used +/// in the crate graph") and, with a lock, break `cargo fetch --locked`, so +/// hosted mode refuses it and rewrites nothing. The project has no +/// committed `Cargo.lock` (a library): the crawl then walks the whole +/// registry cache, finds the crates.io copy another project fetched, and +/// hands the crate to the rewriter — which must read the override table. +/// (With a lock the rewriter refuses from the sourceless lock block; the +/// unit tests cover that, as the lock-scoped crawl never discovers it.) +#[tokio::test(flavor = "multi_thread")] +async fn cargo_hosted_refuses_a_crate_the_root_manifest_patch_overrides() { + let shape = Shape { + tag: "user-patch-same-crate", + files: vec![ + ( + "Cargo.toml", + format!( + "{}\n[patch.crates-io]\ncfg-if = {{ path = \"cfg-if-local\" }}\n", + consumer_manifest("cfg-if = \"1.0.4\"\n") + ), + ), + ("src/main.rs", "fn main() {}\n".to_string()), + ("cfg-if-local/Cargo.toml", local_crate("cfg-if", "1.0.4")), + ( + "cfg-if-local/src/lib.rs", + "pub fn user_fork() {}\n".to_string(), + ), + ], + patches: vec![CFG_IF_1], + oracle: Vec::new(), + crlf: false, + lockless: true, + refused: Some("redirect_cargo_dep_overridden"), + prefetch: "cfg-if = \"=1.0.4\"\n", + }; + let _ = run_shape(shape).await; +} + +/// #480: the same override in the project `.cargo/config.toml` under the +/// index-URL spelling (cargo merges `[patch]` from the config chain): the +/// manifest says nothing, and the redirect is still refused. +#[tokio::test(flavor = "multi_thread")] +async fn cargo_hosted_refuses_a_crate_a_config_patch_overrides() { + let shape = Shape { + tag: "config-patch-same-crate", + files: vec![ + ("Cargo.toml", consumer_manifest("cfg-if = \"1.0.4\"\n")), + ( + ".cargo/config.toml", + "[patch.\"https://github.com/rust-lang/crates.io-index\"]\n\ + cfg-if = { path = \"local/cfg-if\" }\n" + .to_string(), + ), + ("src/main.rs", "fn main() {}\n".to_string()), + ("local/cfg-if/Cargo.toml", local_crate("cfg-if", "1.0.4")), + ( + "local/cfg-if/src/lib.rs", + "pub fn user_fork() {}\n".to_string(), + ), + ], + patches: vec![CFG_IF_1], + oracle: Vec::new(), + crlf: false, + lockless: true, + refused: Some("redirect_cargo_dep_overridden"), + prefetch: "cfg-if = \"=1.0.4\"\n", }; let _ = run_shape(shape).await; } diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 7f007a03f..67ef0f6a0 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -1868,6 +1868,36 @@ fn rewrite_cargo( }); continue; } + // The crate must resolve to crates.io (or an earlier Socket pin): a + // `[patch]` override — in the root manifest, a project or user + // cargo config, as a path or a git fork — resolves it elsewhere, and + // a Socket pin would silently replace the user's override with the + // crates.io-based patched bytes while the stale `[patch]` entry + // breaks `--locked` (#480). The lock is the authority (it sees every + // config in the chain); without one, the override tables the + // rewriter can read answer instead. + let overridden = match cargo_lock.as_deref() { + Some(lock_text) => { + cargo_lock_override_source(lock_text, &dep.name, &dep.version, || { + cargo_patch_override_table(&manifests, files, &dep.name) + }) + } + None => cargo_patch_override_table(&manifests, files, &dep.name), + }; + if let Some(how) = overridden { + result.warnings.push(RewriteWarning { + code: "redirect_cargo_dep_overridden".into(), + detail: format!( + "{}@{} is overridden in this project ({how}), so it does not resolve to \ + the crates.io package the patch targets; pinning it to the Socket \ + registry would drop the override and break `cargo --locked` — \ + dependency skipped (nothing rewritten). Remove the override to use \ + hosted mode, or patch the overriding source yourself", + dep.name, dep.version + ), + }); + continue; + } // A pin reaches only the declarations it sits on: every OTHER lock // package depending on the crate — a registry/git crate, or a path // package whose manifest was not planned (outside the project, behind @@ -2189,6 +2219,125 @@ fn cargo_manifest_package_id( Some((name.to_string(), version.to_string())) } +/// Whether a Cargo.lock `source` (or a `[patch.]` key) names crates.io, +/// in either the git-index or the sparse spelling. +fn is_crates_io_source(source: &str) -> bool { + let url = source.trim(); + let url = url + .strip_prefix("registry+") + .or_else(|| url.strip_prefix("sparse+")) + .unwrap_or(url) + .trim_end_matches('/'); + url == "https://github.com/rust-lang/crates.io-index" || url == "https://index.crates.io" +} + +/// Whether a Cargo.lock `source` is a Socket per-patch sparse index +/// (`sparse+…//index/`) — an earlier generation's pin, which a +/// re-run legitimately repoints. +fn is_socket_cargo_index(source: &str) -> bool { + source + .trim() + .strip_prefix("sparse+") + .map(|url| url.trim_end_matches('/')) + .and_then(|url| url.strip_suffix("/index")) + .and_then(|url| url.rsplit('/').next()) + .is_some_and(crate::patch::path_safety::is_canonical_uuid) +} + +/// How Cargo.lock says `crate_name@version` is overridden: its one +/// `[[package]]` block resolves to something other than crates.io or a +/// Socket index. A git / other-registry `source` is always a user `[patch]` +/// override (no Socket wiring produces one). A block with NO `source` is a +/// `[patch]` path override only when `visible_override` finds the override +/// table entry: the same sourceless shape is what a vendored → hosted +/// takeover's revert leaves for a crate vendored before any lock existed +/// (its `[patch]` wiring already gone), which the planner then pins. A path +/// DECLARATION is refused before this. `None` when it is a crates.io / +/// Socket block, absent, ambiguous (the lock planner refuses those) or the +/// lock does not parse (the dependents check refuses that). +fn cargo_lock_override_source( + lock: &str, + crate_name: &str, + version: &str, + visible_override: impl FnOnce() -> Option, +) -> Option { + let lock = CargoLock::parse(lock).ok()?; + let mut hits = lock + .packages() + .iter() + .filter(|p| p.name == crate_name && p.version == version); + let (Some(pkg), None) = (hits.next(), hits.next()) else { + return None; + }; + match pkg.source.as_deref() { + None => visible_override() + .map(|table| format!("Cargo.lock resolves it to a local path through the {table}")), + Some(s) if is_crates_io_source(s) || is_socket_cargo_index(s) => None, + Some(s) if s.starts_with("git+") => { + Some("Cargo.lock resolves it from a git source — a `[patch]` git override".to_string()) + } + Some(_) => Some( + "Cargo.lock resolves it from a registry other than crates.io — a `[patch]` \ + registry override" + .to_string(), + ), + } +} + +/// With no Cargo.lock: a crates.io `[patch]` entry for `crate_name` (keyed +/// by it, or renamed with `package = ""`) in the root manifest +/// or the project cargo config — `[patch.crates-io]` or the index-URL +/// spelling. Vendored-mode entries (a path under `.socket/`) are Socket's +/// own wiring, not a user override. +fn cargo_patch_override_table( + manifests: &[(String, String)], + files: &BTreeMap, + crate_name: &str, +) -> Option { + let root = manifests + .iter() + .find(|(k, _)| k == "Cargo.toml") + .map(|(k, t)| (k.as_str(), t.as_str())); + let configs = [".cargo/config.toml", ".cargo/config"] + .into_iter() + .filter_map(|k| files.get(k).map(|t| (k, t.as_str()))); + for (path, text) in root.into_iter().chain(configs) { + let Ok(doc) = text.parse::() else { + continue; + }; + let Some(patch) = doc.get("patch").and_then(toml_edit::Item::as_table_like) else { + continue; + }; + for (registry, table) in patch.iter() { + if registry != "crates-io" && !is_crates_io_source(registry) { + continue; + } + let Some(table) = table.as_table_like() else { + continue; + }; + for (key, entry) in table.iter() { + let field = |name: &str| { + entry + .as_table_like() + .and_then(|t| t.get(name)) + .and_then(toml_edit::Item::as_str) + }; + if field("package").unwrap_or(key) != crate_name { + continue; + } + if field("path").is_some_and(|p| { + let p = p.trim_start_matches("./"); + p.starts_with(".socket/") || p.starts_with(".socket\\") + }) { + continue; + } + return Some(format!("`[patch.{registry}]` entry `{key}` in {path}")); + } + } + } + None +} + /// The Cargo.lock packages that depend on `crate_name@version` and that a /// manifest pin cannot reach: any package with a `source` (a registry or /// git crate), and any source-less (workspace / path) package whose @@ -13158,6 +13307,139 @@ mod tests { files } + /// The serde block of [`cargo_lock_with`] resolved by a user override + /// instead of crates.io: no `source` (a path override) or `source`. + fn cargo_lock_overridden(source: Option<&str>) -> String { + let source = source.map_or(String::new(), |s| format!("source = \"{s}\"\n")); + format!( + "# This file is automatically @generated by Cargo.\n\ + version = 3\n\ + \n\ + [[package]]\n\ + name = \"app\"\n\ + version = \"0.1.0\"\n\ + dependencies = [\n \"serde\",\n]\n\ + \n\ + [[package]]\n\ + name = \"serde\"\n\ + version = \"1.0.190\"\n\ + {source}" + ) + } + + fn assert_cargo_override_refused(files: &BTreeMap, what: &str) { + let r = rewrite_registry_redirect(files, &[cargo_sparse_override()]); + assert!( + r.files.is_empty(), + "{what}: nothing rewritten: {:?}", + r.files + ); + assert!(r.edits.is_empty(), "{what}: {:?}", r.edits); + assert!(r.confirmed_cargo_uuids.is_empty(), "{what}"); + let codes: Vec<&str> = r.warnings.iter().map(|w| w.code.as_str()).collect(); + assert_eq!( + codes, + vec!["redirect_cargo_dep_overridden"], + "{what}: {:?}", + r.warnings + ); + } + + /// #480: a root `[patch.crates-io]` (or its URL spelling) path override + /// resolves the crate to a local path — the lock block has no `source`. + /// Pinning the declaration to the Socket registry silently drops the + /// user's override and splices a source into the sourceless block, so + /// the next `cargo fetch --locked` fails. Hosted mode refuses it. + #[test] + fn cargo_patch_crates_io_path_override_is_refused() { + for table in [ + "[patch.crates-io]", + "[patch.\"https://github.com/rust-lang/crates.io-index\"]", + ] { + let mut files = cargo_files(&format!( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\nserde = \"1.0.190\"\n\n\ + {table}\nserde = {{ path = \"serde-local\" }}\n" + )); + files.insert("Cargo.lock".into(), cargo_lock_overridden(None)); + assert_cargo_override_refused(&files, table); + } + } + + /// #480: the override can live in `.cargo/config.toml` (cargo merges + /// `[patch]` across the config chain), or point at a git fork — the lock + /// then carries the git source, which a repoint would overwrite. + #[test] + fn cargo_config_and_git_overrides_are_refused_from_the_lock_source() { + let manifest = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\nserde = \"1.0.190\"\n"; + let mut config = cargo_files(manifest); + config.insert("Cargo.lock".into(), cargo_lock_overridden(None)); + config.insert( + ".cargo/config.toml".into(), + "[patch.crates-io]\nserde = { path = \"local/serde\" }\n".into(), + ); + assert_cargo_override_refused(&config, "config override"); + + let mut git = cargo_files(&format!( + "{manifest}\n[patch.crates-io]\nserde = {{ git = \"file:///fork\" }}\n" + )); + git.insert( + "Cargo.lock".into(), + cargo_lock_overridden(Some("git+file:///fork#0123456789abcdef")), + ); + assert_cargo_override_refused(&git, "git override"); + + // A sourceless block with NO override table entry is the shape a + // vendored -> hosted takeover's revert leaves for a crate vendored + // before any lock existed: it is pinned, not refused. + let mut reverted = cargo_files(manifest); + reverted.insert("Cargo.lock".into(), cargo_lock_overridden(None)); + let r = rewrite_registry_redirect(&reverted, &[cargo_sparse_override()]); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); + } + + /// #480, lockless: no lock to read the resolution from, so an override + /// of the crate in the root manifest or project config is refused from + /// the override table itself (an aliased `package = "serde"` entry too). + #[test] + fn cargo_lockless_override_is_refused_from_the_patch_table() { + let manifest = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\nserde = \"1.0.190\"\n"; + let mut root = BTreeMap::new(); + root.insert( + "Cargo.toml".to_string(), + format!("{manifest}\n[patch.crates-io]\nserde = {{ path = \"serde-local\" }}\n"), + ); + assert_cargo_override_refused(&root, "lockless root override"); + + let mut aliased = BTreeMap::new(); + aliased.insert("Cargo.toml".to_string(), manifest.to_string()); + aliased.insert( + ".cargo/config.toml".to_string(), + "[patch.\"https://github.com/rust-lang/crates.io-index\"]\n\ + fork = { path = \"fork\", package = \"serde\" }\n" + .into(), + ); + assert_cargo_override_refused(&aliased, "lockless aliased config override"); + } + + /// Control for #480: an override of an UNRELATED crate leaves the + /// patched crate on crates.io, so it is still redirected. + #[test] + fn cargo_override_of_another_crate_still_redirects() { + let files = cargo_files( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\nserde = \"1.0.190\"\n\n\ + [patch.crates-io]\nitoa = { path = \"itoa-local\" }\n", + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); + assert!(r.files["Cargo.lock"].contains(&cargo_index_url())); + } + /// A crate declared in BOTH [dev-dependencies] and /// [dependencies] must gain the registry pin in BOTH sections — a /// first-match-only rewrite gives the two sections different sources for diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 759d7d182..999fedaf7 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -16,7 +16,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 | +| 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`; a crate the user overrides with `[patch]` (path, git or another registry) is refused too, the override is left alone; 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` or `Gemfile` + `Gemfile.lock`, whichever the project holds (a `Gemfile` + `gems.rb` twin is refused with `redirect_gem_twin_manifest_ambiguous`: bundler 1.x loads the `Gemfile`, bundler ≥ 2 loads `gems.rb`, and nothing in the project says which bundler installs it; bundler 4's `BUNDLE_LOCKFILE` (environment, app config or global config) naming any other lock is refused with `redirect_gem_bundle_lockfile_unsupported`, as is vendoring with `gemfile_not_loaded`; spellings that diverge beyond Socket's own edits fail closed with `redirect_gem_gemfile_spellings_diverge`; `BUNDLE_GEMFILE` from `.bundle/config` (which outranks the environment, as in bundler), the environment, or the global `~/.bundle/config` / `$BUNDLE_USER_CONFIG` (lowest, as in bundler) is followed when it names the project's `Gemfile` / `gems.rb`, and any other configured manifest is refused with `redirect_gem_bundle_gemfile_unsupported`); a Bundler all-source, exact-source or hostname mirror in app config or the scan environment can capture the patch registry, so the redirect is refused with `redirect_gem_mirror_overrides_source` without printing mirror URLs (scope mirrors to `mirror.https://rubygems.org`; user-global config and mirrors set only in a later install environment are not inspected); the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning); a stale pre-redirect materialization that `bundle install` would reuse instead of refetching is flagged `redirect_gem_stale_install` with a prescriptive remedy (see CLI_CONTRACT.md's "Gem stale-install guard") | | Go (`golang`) | ✅ `go.mod` `replace` → `.socket/go-patches/` — see [Go: directory replaces and go.sum](#go-directory-replaces-and-gosum) | ✅ `replace` → the committed vendor tree | ✅ (free tier) fork-style `replace` → `patch.socket.dev/gopatch/` + committed `go.sum` pin; see [Go notes](#go-directory-replaces-and-gosum). Paid hosted patches are unsupported; `redirect_golang_unsupported` names the vendored remedy | | Maven (`maven`) — Maven and Gradle | ✅ in place in every copy the build consumes: each `~/.m2` copy it reads and each Gradle `files-2.1` copy; `~/.m2` `.sha1`/`.md5` sidecars are rewritten, Gradle copies get advisories; jar-member records swap in the patch service's whole jar — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) and [Gradle](#gradle) | ✅ suffixed Maven repository (`-socket.` pin + `.mvn/maven.config` + fallback file repository) for every pom root, single-module or reactor, or Gradle 6.8+ same-GAV repository with settings wiring and SHA-256 checks (a root with both `pom.xml` and a Gradle build wires both); see [JVM vendoring](design/maven-vendoring.md) and [Gradle](#gradle) | ✅ fail-closed by a Socket-only `-socket.` suffix: pom projects get a pinned `` (`${property}` versions are refused); Gradle 6.8+ builds get an owned settings script, lock-entry rewrites and a resolution tripwire — see [Maven & NuGet caveats](#maven--nuget-caveats) and [Gradle](#gradle) | From 536734b61a4bfa20cee4e9915a49dd2e807339bf Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 14:17:55 -0400 Subject: [PATCH 3/4] Reuse the lock parse for the override check The #480 override gate parsed Cargo.lock once more per patched crate, a third full parse beside the dependents check and the lock planner, which made hosted cargo scans of large locks ~25% slower (scan performance: cargo/hosted, cargo/rescan). The check now runs on the lock planner's own parse. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/patch/redirect/mod.rs | 66 +++++++++++-------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 67ef0f6a0..479fbea5a 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -1874,29 +1874,16 @@ fn rewrite_cargo( // a Socket pin would silently replace the user's override with the // crates.io-based patched bytes while the stale `[patch]` entry // breaks `--locked` (#480). The lock is the authority (it sees every - // config in the chain); without one, the override tables the - // rewriter can read answer instead. - let overridden = match cargo_lock.as_deref() { - Some(lock_text) => { - cargo_lock_override_source(lock_text, &dep.name, &dep.version, || { - cargo_patch_override_table(&manifests, files, &dep.name) - }) + // config in the chain) and is checked on the lock planner's own + // parse below; without one, the override tables the rewriter can + // read answer here. + if cargo_lock.is_none() { + if let Some(how) = cargo_patch_override_table(&manifests, files, &dep.name) { + result + .warnings + .push(cargo_overridden_warning(&dep.name, &dep.version, &how)); + continue; } - None => cargo_patch_override_table(&manifests, files, &dep.name), - }; - if let Some(how) = overridden { - result.warnings.push(RewriteWarning { - code: "redirect_cargo_dep_overridden".into(), - detail: format!( - "{}@{} is overridden in this project ({how}), so it does not resolve to \ - the crates.io package the patch targets; pinning it to the Socket \ - registry would drop the override and break `cargo --locked` — \ - dependency skipped (nothing rewritten). Remove the override to use \ - hosted mode, or patch the overriding source yourself", - dep.name, dep.version - ), - }); - continue; } // A pin reaches only the declarations it sits on: every OTHER lock // package depending on the crate — a registry/git crate, or a path @@ -1955,7 +1942,19 @@ fn rewrite_cargo( let lock_commit = if let Some(lock_text) = cargo_lock.as_ref() { // A lock that does not parse never reaches here: the dependents // check above refuses it. - let plan = CargoLock::parse(lock_text).map_or(CargoLockPlan::NotFound, |lock| { + let parsed = CargoLock::parse(lock_text).ok(); + let overridden = parsed.as_ref().and_then(|lock| { + cargo_lock_override_source(lock, &dep.name, &dep.version, || { + cargo_patch_override_table(&manifests, files, &dep.name) + }) + }); + if let Some(how) = overridden { + result + .warnings + .push(cargo_overridden_warning(&dep.name, &dep.version, &how)); + continue; + } + let plan = parsed.map_or(CargoLockPlan::NotFound, |lock| { lock.plan_hosted(lock_text, &dep.name, &dep.version, index_url, &cksum) }); match plan { @@ -2253,15 +2252,13 @@ fn is_socket_cargo_index(source: &str) -> bool { /// takeover's revert leaves for a crate vendored before any lock existed /// (its `[patch]` wiring already gone), which the planner then pins. A path /// DECLARATION is refused before this. `None` when it is a crates.io / -/// Socket block, absent, ambiguous (the lock planner refuses those) or the -/// lock does not parse (the dependents check refuses that). +/// Socket block, absent or ambiguous (the lock planner refuses those). fn cargo_lock_override_source( - lock: &str, + lock: &CargoLock, crate_name: &str, version: &str, visible_override: impl FnOnce() -> Option, ) -> Option { - let lock = CargoLock::parse(lock).ok()?; let mut hits = lock .packages() .iter() @@ -2284,6 +2281,21 @@ fn cargo_lock_override_source( } } +/// The `redirect_cargo_dep_overridden` refusal for `name@version`, `how` +/// naming the override. +fn cargo_overridden_warning(name: &str, version: &str, how: &str) -> RewriteWarning { + RewriteWarning { + code: "redirect_cargo_dep_overridden".into(), + detail: format!( + "{name}@{version} is overridden in this project ({how}), so it does not resolve to \ + the crates.io package the patch targets; pinning it to the Socket registry would \ + drop the override and break `cargo --locked` — dependency skipped (nothing \ + rewritten). Remove the override to use hosted mode, or patch the overriding \ + source yourself" + ), + } +} + /// With no Cargo.lock: a crates.io `[patch]` entry for `crate_name` (keyed /// by it, or renamed with `package = ""`) in the root manifest /// or the project cargo config — `[patch.crates-io]` or the index-URL From 88989348f2c0c8ee198ede65cf8fd09b43f11f99 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sat, 10 Oct 2026 10:08:26 -0400 Subject: [PATCH 4/4] Match crates.io [patch] keys the way cargo canonicalizes them A [patch.] table keyed with a .git suffix, a trailing slash or different host / github path casing is the same crates.io source to cargo, so the override check must see it too. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-core/src/patch/redirect/mod.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 5df907eec..59399204b 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -2247,14 +2247,18 @@ fn cargo_manifest_package_id( } /// Whether a Cargo.lock `source` (or a `[patch.]` key) names crates.io, -/// in either the git-index or the sparse spelling. +/// in either the git-index or the sparse spelling. Compared the way cargo's +/// `CanonicalUrl` does: host casing, a trailing `/` and a `.git` suffix do +/// not make a different source (and github paths are case-insensitive). fn is_crates_io_source(source: &str) -> bool { let url = source.trim(); let url = url .strip_prefix("registry+") .or_else(|| url.strip_prefix("sparse+")) .unwrap_or(url) - .trim_end_matches('/'); + .to_ascii_lowercase(); + let url = url.trim_end_matches('/'); + let url = url.strip_suffix(".git").unwrap_or(url); url == "https://github.com/rust-lang/crates.io-index" || url == "https://index.crates.io" } @@ -13691,6 +13695,10 @@ mod tests { for table in [ "[patch.crates-io]", "[patch.\"https://github.com/rust-lang/crates.io-index\"]", + // Spellings cargo canonicalizes to the same crates.io source. + "[patch.\"https://github.com/rust-lang/crates.io-index.git\"]", + "[patch.\"https://GitHub.com/Rust-Lang/crates.io-index/\"]", + "[patch.\"sparse+https://Index.Crates.io/\"]", ] { let mut files = cargo_files(&format!( "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\