diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index d5f8cecf0..df4238d25 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -214,7 +214,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 has no zip (`h1:` of the module tree) line for at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written); a `/go.mod`-only line doesn't count, since go writes one for every version MVS reads (#509). In a go < 1.17 graph, where `require` holds only lower bounds, the build selects the highest of the required version and go.sum's zip-line versions, and a patch for a lower version warns `redirect_golang_version_mismatch` (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` (a top-level `warnings[]` entry, and the `errorCode` of the package's `skipped` event). 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 a `skipped` event whose `errorCode` is 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 `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 `verified` events and warnings are the wet run's `applied` events and warnings. 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 prints the envelope with `status: "error"` and `error: {code: "lock_held" | "lock_io", message}` (no `redirect` payload: nothing was rewritten). **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). Under `--json` (v5.0, MAJOR) the outcome is recorded into the scan envelope: one event per selected patch, sorted by purl, each with `details.mode: "hosted"` — `applied` for a confirmed pin (`verified` under `--dry-run`), `skipped` with the skip reason as `errorCode` (and its detail as `reason`), or `skipped` / `redirect_unconfirmed` for a patch that was granted but no lockfile entry pinning it could be rewritten (the human output's `Not hosted : …` line) — plus a `redirect: {mode: "hosted", rewrittenFiles}` payload (the files rewritten, or that would be). A `skipped` event 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; a leading UTF-8 BOM, which dotnet reads past, is not corrupt and is kept on rewrite), `redirect_nuget_lock_other_version` (the lock also resolves the patched id at another version in some target framework: the exact-id `packageSourceMapping` would route that framework to a feed that serves only the patched version, so the dep is skipped with nothing written; only lock entries at the patched version are ever re-pinned, and `resolved` is never rewritten), `redirect_nuget_mapping_set_aside` (v5.0 #462: another source's `packageSourceMapping` also named the patched id exactly — Visual Studio's mapping UI writes such lists — which ties with the Socket source so NuGet would take the package from whichever feed answers first; that pattern, or its whole `` when it was the only one, is commented out in a `` comment while the patch is wired, and `remove` / `rollback` put it back byte-exact), `redirect_nuget_mapping_conflict` (such a pattern sits in markup that cannot go in a comment; the dep is skipped, nothing written). v5.0 #354: when the rewriter creates the `packageSourceMapping`, its `*` catch-all also names the sources NuGet merges in from the user config and every parent directory's config (honoring ``), since NuGet drops every source no pattern names; a fresh config only seeds `nuget.org` when those inherited configs have it (a parent that cleared it for a mirror keeps that choice); when an inherited config already has a `packageSourceMapping` (NuGet merges those too), no catch-all is written at all — only the Socket pattern — so a source the parent restricts is never widened; the exclusivity set-aside exempts only the source the run wires, so a `socket-patch-*` lookalike key or a stale uuid naming the id is set aside too, `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), `redirect_gem_no_lockfile` (gem: the project has a `Gemfile` / `gems.rb` but no `Gemfile.lock` / `gems.locked`, so nothing records which version it resolves and the crawled version may be another project's copy in the shared gem home; the gem is skipped with nothing written, never pinned over the user's constraint or appended as a new dependency, and the detail asks for `bundle lock` (or `bundle install`) and a re-run). 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 `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 has no zip (`h1:` of the module tree) line for at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written); a `/go.mod`-only line doesn't count, since go writes one for every version MVS reads (#509). In a go < 1.17 graph, where `require` holds only lower bounds, the build selects the highest of the required version and go.sum's zip-line versions, and a patch for a lower version warns `redirect_golang_version_mismatch` (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` (a top-level `warnings[]` entry, and the `errorCode` of the package's `skipped` event). 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 a `skipped` event whose `errorCode` is 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 `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 `verified` events and warnings are the wet run's `applied` events and warnings. 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 prints the envelope with `status: "error"` and `error: {code: "lock_held" | "lock_io", message}` (no `redirect` payload: nothing was rewritten). **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). Under `--json` (v5.0, MAJOR) the outcome is recorded into the scan envelope: one event per selected patch, sorted by purl, each with `details.mode: "hosted"` — `applied` for a confirmed pin (`verified` under `--dry-run`), `skipped` with the skip reason as `errorCode` (and its detail as `reason`), or `skipped` / `redirect_unconfirmed` for a patch that was granted but no lockfile entry pinning it could be rewritten (the human output's `Not hosted : …` line) — plus a `redirect: {mode: "hosted", rewrittenFiles}` payload (the files rewritten, or that would be). A `skipped` event 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; a leading UTF-8 BOM, which dotnet reads past, is not corrupt and is kept on rewrite), `redirect_nuget_lock_other_version` (the lock also resolves the patched id at another version in some target framework: the exact-id `packageSourceMapping` would route that framework to a feed that serves only the patched version, so the dep is skipped with nothing written; only lock entries at the patched version are ever re-pinned, and `resolved` is never rewritten), `redirect_nuget_mapping_set_aside` (v5.0 #462: another source's `packageSourceMapping` also named the patched id exactly — Visual Studio's mapping UI writes such lists — which ties with the Socket source so NuGet would take the package from whichever feed answers first; that pattern, or its whole `` when it was the only one, is commented out in a `` comment while the patch is wired, and `remove` / `rollback` put it back byte-exact), `redirect_nuget_mapping_conflict` (such a pattern sits in markup that cannot go in a comment; the dep is skipped, nothing written). v5.0 #354: when the rewriter creates the `packageSourceMapping`, its `*` catch-all also names the sources NuGet merges in from the user config and every parent directory's config (honoring ``), since NuGet drops every source no pattern names; a fresh config only seeds `nuget.org` when those inherited configs have it (a parent that cleared it for a mirror keeps that choice); when an inherited config already has a `packageSourceMapping` (NuGet merges those too), no catch-all is written at all — only the Socket pattern — so a source the parent restricts is never widened; the exclusivity set-aside exempts only the source the run wires, so a `socket-patch-*` lookalike key or a stale uuid naming the id is set aside too, `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_cargo_directory_source` (#455: a cargo config replaces crates.io with an on-disk source through its `[source.crates-io] replace-with` chain — a directory source, as a `cargo vendor` project has, or a `cargo local-registry` — so the project builds every crates.io crate offline from it and a Socket registry pin would break `cargo build --frozen --offline`. The configs read are the ones cargo reads for a build in the scanned directory: the project's own `.cargo/config*`, every parent directory's (a monorepo root) and `$CARGO_HOME`'s. Every cargo dependency is skipped with nothing written, one warning each naming the crate and the config that set the replacement, and the detail points at agent mode, which patches the copy cargo builds in place. A replacement with a registry mirror is not an offline source and is still redirected). 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), `redirect_gem_no_lockfile` (gem: the project has a `Gemfile` / `gems.rb` but no `Gemfile.lock` / `gems.locked`, so nothing records which version it resolves and the crawled version may be another project's copy in the shared gem home; the gem is skipped with nothing written, never pinned over the user's constraint or appended as a new dependency, and the detail asks for `bundle lock` (or `bundle install`) and a re-run). 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 `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 as a `skipped` event with `errorCode` `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. @@ -530,7 +530,11 @@ Hosted and vendored mode need no per-install step for any ecosystem. In agent mo are patched by `socket-patch apply` like every other ecosystem: - **cargo** — `apply` patches the crate **in place** wherever the crawler finds it: the project - `vendor/` directory or the shared registry cache (`$CARGO_HOME/registry/src/...`). The + `cargo vendor` directory source or the shared registry cache (`$CARGO_HOME/registry/src/...`). + The directory source is the one cargo builds from: the `[source.crates-io] replace-with` chain of + every `.cargo/config*` cargo reads (the working directory and each ancestor, then `$CARGO_HOME`'s), + wherever it points (#338). A `vendor/` no config wires is not crawled, and a configured directory + that is missing yields nothing rather than the registry copy cargo ignores. The `.cargo-checksum.json` sidecar is rewritten so `cargo build` accepts the modified files. Rollback restores the original bytes from the `beforeHash` blobs. *(Note: a non-vendored crate patches the **shared** registry cache, which affects other projects on the machine and is reset by `cargo clean` diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 7c5405c86..069dc5ceb 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -9,6 +9,7 @@ use std::time::Duration; use futures_util::StreamExt; use socket_patch_core::api::client::hold_back_debug; use socket_patch_core::api::types::BatchPackagePatches; +use socket_patch_core::crawlers::CargoCrawler; use socket_patch_core::patch::apply_lock::LockGuard; use socket_patch_core::patch::redirect::yarnrc::resolve_outer_yarn_mirror_for_process; use socket_patch_core::patch::redirect::DepOverride; @@ -1292,6 +1293,10 @@ async fn run_redirect_selected_untracked( // ancestor rc files), located the way yarn 1 does: a mirror set in any // of them refuses the classic rewrite like a project one. let yarn_classic_outer = || resolve_outer_yarn_mirror_for_process(&common.cwd); + // The cargo configs cargo reads for a build here (the project's, every + // parent directory's and `$CARGO_HOME`'s): a directory source or local + // registry in any of them refuses the cargo rewrite like a project one. + let cargo_outer = || CargoCrawler::crates_io_source_for(&common.cwd); let rewrite_options = || { RewriteOptions { dry_run: common.dry_run, @@ -1305,6 +1310,7 @@ async fn run_redirect_selected_untracked( npm_allow_remote_config: !common.no_npm_allow_remote_config, npm_outer: &npm_outer, yarn_classic_outer: &yarn_classic_outer, + cargo_outer: &cargo_outer, blocking: true, takeover_uuids: takeover_uuids.clone(), patch_server_origins: patch_server_origins.clone(), diff --git a/crates/socket-patch-cli/src/ecosystem_dispatch.rs b/crates/socket-patch-cli/src/ecosystem_dispatch.rs index 8edd94e83..e1017c7ff 100644 --- a/crates/socket-patch-cli/src/ecosystem_dispatch.rs +++ b/crates/socket-patch-cli/src/ecosystem_dispatch.rs @@ -89,7 +89,8 @@ pub async fn cargo_copies_still_patched<'a>( /// The copies of the Cargo `purls` [`find_all_packages_for_rollback`] /// locates (the roots rollback restores), plus, with `shadowed_registry`, /// the shared `$CARGO_HOME/registry/src` copies of a local Cargo project -/// with a `cargo vendor` dir, whose crawl searches only that dir — where +/// whose cargo config replaces crates.io with a `cargo vendor` directory, +/// whose crawl searches only that dir — where /// an apply from before `cargo vendor` may have left the patch. The prune /// only reads them (its keep decision); rollback restores them only for a /// vendored crate whose record it is about to drop (#336). @@ -103,7 +104,14 @@ pub async fn find_cargo_copies( let local = !options.global && options.global_prefix.is_none(); let cargo_project = options.cwd.join("Cargo.toml").is_file() || options.cwd.join("Cargo.lock").is_file(); - if shadowed_registry && local && cargo_project && options.cwd.join("vendor").is_dir() { + if shadowed_registry + && local + && cargo_project + && CargoCrawler::new() + .directory_source(&options.cwd) + .await + .is_some() + { let registry = CrawlerOptions { cwd: options.cwd.clone(), global: true, diff --git a/crates/socket-patch-cli/tests/e2e_cargo.rs b/crates/socket-patch-cli/tests/e2e_cargo.rs index fb5004047..58eb89672 100644 --- a/crates/socket-patch-cli/tests/e2e_cargo.rs +++ b/crates/socket-patch-cli/tests/e2e_cargo.rs @@ -223,6 +223,14 @@ async fn scan_discovers_vendor_crates() { // a crate source once the root is identified as a Cargo project. A vendored // project always carries a lockfile, so stage one as the project marker. std::fs::write(dir.path().join("Cargo.lock"), "version = 3\n").unwrap(); + // `cargo vendor`'s source replacement: only a wired tree is where + // cargo builds from (#338). + std::fs::create_dir_all(dir.path().join(".cargo")).unwrap(); + std::fs::write( + dir.path().join(".cargo/config.toml"), + "[source.crates-io]\nreplace-with = \"v\"\n[source.v]\ndirectory = \"vendor\"\n", + ) + .unwrap(); // Set up vendor directory let vendor_dir = dir.path().join("vendor"); @@ -494,6 +502,12 @@ async fn sync_keeps_shared_cache_entry_behind_a_cargo_vendor_dir() { .unwrap(); let registry = root.join(".cargo/registry/src/index.crates.io-test"); let itoa_old = fake_registry_crate(®istry, "itoa", "1.0.11", patched); + // The source replacement that makes `vendor/` cargo's source (#338). + std::fs::write( + root.join(".cargo/config.toml"), + "[source.crates-io]\nreplace-with = \"v\"\n[source.v]\ndirectory = \"vendor\"\n", + ) + .unwrap(); let socket = root.join(".socket"); let itoa = "pkg:cargo/itoa@1.0.11"; diff --git a/crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs b/crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs index 66259e941..e3a755281 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs @@ -381,6 +381,102 @@ fn apply_then_cargo_check_succeeds() { manifestless_agent_patch_is_not_attested(&consumer, &cargo_home); } +/// Move the consumer's directory source to `dir` (relative to `root`), +/// wired by `root/.cargo/config.toml`, as `cargo vendor ` lays it +/// out when run there. +fn relocate_vendor(consumer: &Path, root: &Path, dir: &str) { + std::fs::create_dir_all(root.join(dir).parent().unwrap()).unwrap(); + std::fs::rename(consumer.join("vendor"), root.join(dir)).unwrap(); + std::fs::remove_dir_all(consumer.join(".cargo")).unwrap(); + std::fs::create_dir_all(root.join(".cargo")).unwrap(); + std::fs::write( + root.join(".cargo/config.toml"), + format!( + "[source.crates-io]\nreplace-with = \"vendored-test\"\n\n\ + [source.vendored-test]\ndirectory = \"{dir}\"\n" + ), + ) + .unwrap(); +} + +/// Apply in `cwd`, then prove the copy at `vendored` (the directory +/// source cargo builds from) is patched and `cargo check --frozen +/// --offline` accepts it. +fn apply_patches_directory_source(cwd: &Path, vendored: &Path, cargo_home: &Path) { + let (stdout, _stderr) = assert_run_ok( + cwd, + &[ + "apply", + "--json", + "--offline", + "--cwd", + cwd.to_str().unwrap(), + ], + "socket-patch apply", + ); + let env = parse_json_envelope(&stdout); + assert_eq!(env["status"], "success", "{stdout}"); + assert_eq!( + std::fs::read_to_string(vendored.join("src/lib.rs")).unwrap(), + PATCHED_LIB_RS, + "the directory source cargo builds from must be patched: {stdout}" + ); + let out = cargo_check(cwd, cargo_home); + assert!( + out.status.success(), + "cargo check after apply:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); +} + +/// #338 shape 1: `cargo vendor third_party/rust` — a directory source +/// with a custom name. Apply must patch that copy, not look for +/// `/vendor` and fall back to the registry cache. +#[test] +#[ignore] +fn apply_patches_a_custom_named_vendor_dir() { + if !cargo_e2e_matrix::cargo_available("e2e_safety_cargo_build") { + return; + } + let root = tempfile::tempdir().unwrap(); + let consumer = stage_consumer(root.path()); + relocate_vendor(&consumer, &consumer, "third_party/rust"); + let cargo_home = root.path().join(".cargo-home"); + generate_lockfile(&consumer, &cargo_home); + assert!(cargo_check(&consumer, &cargo_home).status.success()); + stage_socket_manifest(&consumer); + + let vendored = consumer.join("third_party/rust/safety-fixture"); + apply_patches_directory_source(&consumer, &vendored, &cargo_home); +} + +/// #338 shape 2: apply run from a workspace member, with the vendor +/// directory and its config at the workspace root. +#[test] +#[ignore] +fn apply_from_a_member_patches_the_workspace_vendor_dir() { + if !cargo_e2e_matrix::cargo_available("e2e_safety_cargo_build") { + return; + } + let root = tempfile::tempdir().unwrap(); + let ws = root.path().join("ws"); + std::fs::create_dir_all(&ws).unwrap(); + std::fs::write( + ws.join("Cargo.toml"), + "[workspace]\nmembers = [\"consumer\"]\nresolver = \"2\"\n", + ) + .unwrap(); + let consumer = stage_consumer(&ws); + relocate_vendor(&consumer, &ws, "vendor"); + let cargo_home = root.path().join(".cargo-home"); + generate_lockfile(&ws, &cargo_home); + assert!(cargo_check(&consumer, &cargo_home).status.success()); + stage_socket_manifest(&consumer); + + apply_patches_directory_source(&consumer, &ws.join("vendor/safety-fixture"), &cargo_home); +} + /// Step 5 of the module doc: the agent-mode patch attests only through the /// manifest; with it deleted nothing in the lockfile / config wires a /// Socket patch (the `[source.crates-io] replace-with` directory source is diff --git a/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs index b0f2c71a4..92e909b32 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs @@ -1005,7 +1005,17 @@ fn agent_to_vendored_rollback_restores_shared_cache(cargo_vendor_dir: bool) { "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); if cargo_vendor_dir { + // A wired (but empty) directory source: the project crawl then + // searches only `vendor/` (#338). std::fs::create_dir_all(proj.join("vendor")).unwrap(); + std::fs::create_dir_all(proj.join(".cargo")).unwrap(); + let config = proj.join(".cargo/config.toml"); + let mut text = std::fs::read_to_string(&config).unwrap_or_default(); + text.push_str( + "\n[source.crates-io]\nreplace-with = \"vendored-sources\"\n\n\ + [source.vendored-sources]\ndirectory = \"vendor\"\n", + ); + std::fs::write(&config, text).unwrap(); } let (code, stdout, stderr) = run_socket( diff --git a/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs b/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs index 059c4101a..cc5de0306 100644 --- a/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs +++ b/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs @@ -686,13 +686,20 @@ fn rollback_dispatch_branch_cargo() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); write_root_package_json(root); - // Cargo crawler uses the vendor layout when `vendor/` exists. + // Cargo crawler uses the vendor layout when a config wires `vendor/`. std::fs::write( root.join("Cargo.toml"), "[package]\nname = \"t\"\nversion = \"0.0.0\"\n", ) .unwrap(); let purl = "pkg:cargo/__rollback_dispatch__@1.0.0"; + // `cargo vendor`'s source replacement makes `vendor/` cargo's source (#338). + std::fs::create_dir_all(root.join(".cargo")).unwrap(); + std::fs::write( + root.join(".cargo/config.toml"), + "[source.crates-io]\nreplace-with = \"v\"\n[source.v]\ndirectory = \"vendor\"\n", + ) + .unwrap(); let crate_dir = root.join("vendor").join("__rollback_dispatch__"); std::fs::create_dir_all(crate_dir.join("src")).unwrap(); std::fs::write( diff --git a/crates/socket-patch-cli/tests/in_process_rollback_all_ecosystems.rs b/crates/socket-patch-cli/tests/in_process_rollback_all_ecosystems.rs index d0f39ddec..e3d6d6361 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_all_ecosystems.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_all_ecosystems.rs @@ -358,6 +358,13 @@ version = "1.0.0" // Cargo crawler needs a Cargo.toml in cwd to engage. std::fs::write(tmp.path().join("Cargo.toml"), "[workspace]\n").unwrap(); + // `cargo vendor`'s source replacement makes `vendor/` cargo's source (#338). + std::fs::create_dir_all(tmp.path().join(".cargo")).unwrap(); + std::fs::write( + tmp.path().join(".cargo/config.toml"), + "[source.crates-io]\nreplace-with = \"v\"\n[source.v]\ndirectory = \"vendor\"\n", + ) + .unwrap(); assert_ne!(original.to_vec(), patched.to_vec()); assert_eq!( diff --git a/crates/socket-patch-cli/tests/scan/scan_ecosystems_scope_e2e.rs b/crates/socket-patch-cli/tests/scan/scan_ecosystems_scope_e2e.rs index eae02684c..165afaf55 100644 --- a/crates/socket-patch-cli/tests/scan/scan_ecosystems_scope_e2e.rs +++ b/crates/socket-patch-cli/tests/scan/scan_ecosystems_scope_e2e.rs @@ -214,6 +214,13 @@ async fn gc_scan_crawls_the_unselected_ecosystems() { let tmp = tempfile::tempdir().unwrap(); stage_project(tmp.path()); let krate = tmp.path().join("vendor").join(INSTALLED_CRATE); + // `cargo vendor`'s source replacement makes `vendor/` cargo's source (#338). + std::fs::create_dir_all(tmp.path().join(".cargo")).unwrap(); + std::fs::write( + tmp.path().join(".cargo/config.toml"), + "[source.crates-io]\nreplace-with = \"v\"\n[source.v]\ndirectory = \"vendor\"\n", + ) + .unwrap(); std::fs::create_dir_all(&krate).unwrap(); std::fs::write( krate.join("Cargo.toml"), diff --git a/crates/socket-patch-core/src/crawlers/cargo_crawler.rs b/crates/socket-patch-core/src/crawlers/cargo_crawler.rs index 97c831a10..84e5d6592 100644 --- a/crates/socket-patch-core/src/crawlers/cargo_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/cargo_crawler.rs @@ -4,6 +4,9 @@ use std::path::{Path, PathBuf}; use super::listing::list_dir_sync; use super::types::{CrawledPackage, CrawlerOptions}; use crate::formats::cargo::manifest::package_name_version; +use crate::formats::cargo::source_replacement::{ + self, config_layer_paths, ConfigLayer, CratesIoSource, +}; use crate::formats::cargo::CargoLock; use crate::patch::path_safety; use crate::utils::fs::{is_dir, is_dir_sync, run_blocking}; @@ -46,6 +49,20 @@ impl CargoCrawler { Ok(source_roots(options).await.0) } + /// The directory a local Cargo project in `cwd` builds its crates.io + /// dependencies from, when a cargo config replaces crates.io with a + /// directory source (`cargo vendor`): the crawl then searches only that + /// directory, never the shared registry cache. The directory need not + /// exist. + pub async fn directory_source(&self, cwd: &Path) -> Option { + match crates_io_source(cwd).await { + CratesIoSource::Directory(dir) => Some(dir), + CratesIoSource::Registry + | CratesIoSource::LocalRegistry(_) + | CratesIoSource::Other(_) => None, + } + } + /// Crawl all discovered crate source directories and return every /// package found. /// @@ -234,6 +251,14 @@ impl CargoCrawler { _ => crate::utils::fs::home_dir().map(|home| home.join(".cargo")), } } + + /// Where cargo takes crates.io packages from for a build in `cwd`: the + /// source replacement of every cargo config it reads there (`cwd`'s and + /// each parent directory's `.cargo/config*`, then `$CARGO_HOME`'s). + /// Blocking file reads; the hosted scan's cargo refusal reads it too. + pub fn crates_io_source_for(cwd: &Path) -> CratesIoSource { + resolve_for(cwd, Self::cargo_home().as_deref()) + } } /// [`CargoCrawler::get_crate_source_paths`], and whether those roots are @@ -267,14 +292,86 @@ async fn source_roots(options: &CrawlerOptions) -> (Vec, bool) { return (Vec::new(), false); } - // Cargo project: prefer a vendored source tree if present, else - // fall back to the global registry cache. - let vendor_dir = options.cwd.join("vendor"); - if is_dir(&vendor_dir).await { - return (vec![vendor_dir], false); + // Cargo project: crawl what cargo builds from. A config that + // replaces crates.io with a directory source (`cargo vendor`) names + // that directory, wherever it is; without one, cargo reads the + // registry cache, even when an unwired `vendor/` sits beside the + // manifest (#338). A replacement directory that is missing yields + // nothing: cargo can't build from the cache either. + match crates_io_source(&options.cwd).await { + CratesIoSource::Directory(dir) => { + if is_dir(&dir).await { + (vec![dir], false) + } else { + (Vec::new(), false) + } + } + // Cargo unpacks a local registry's `.crate` files into the + // registry cache too. + CratesIoSource::Registry | CratesIoSource::LocalRegistry(_) | CratesIoSource::Other(_) => { + (CargoCrawler::get_registry_src_paths().await, true) + } + } +} + +/// [`source_replacement::crates_io_source`] for a cargo build in `cwd`, +/// reading the config files [`config_layer_paths`] lists (an unreadable one is skipped). +/// Blocking file reads. +/// +/// `cwd` is taken as the logical absolute path, not the canonical one: +/// a resolved directory is reported under it, as ``-joined roots +/// always were. The config walk folds `..` out of it, so `a/b/../proj` +/// reads `a/proj`'s ancestors, not `a/b`'s; a directory under the folded +/// cwd is reported back under the cwd as given, which path-scoped +/// commands strip as a prefix. +fn resolve_for(cwd: &Path, cargo_home: Option<&Path>) -> CratesIoSource { + let given = std::path::absolute(cwd).unwrap_or_else(|_| cwd.to_path_buf()); + let folded = fold_parent_dirs(&given); + let files = config_layer_paths(&folded, cargo_home, |p| p.is_file()); + let texts: Vec<(PathBuf, String)> = files + .into_iter() + .filter_map(|(file, base)| { + crate::utils::fs::read_regular_to_string_sync(&file) + .ok() + .map(|t| (base, t)) + }) + .collect(); + let layers: Vec> = texts + .iter() + .map(|(base, text)| ConfigLayer { base, text }) + .collect(); + let under_given = |dir: PathBuf| match dir.strip_prefix(&folded) { + Ok(rel) => given.join(rel), + Err(_) => dir, + }; + match source_replacement::crates_io_source(&layers) { + CratesIoSource::Directory(dir) => CratesIoSource::Directory(under_given(dir)), + CratesIoSource::LocalRegistry(dir) => CratesIoSource::LocalRegistry(under_given(dir)), + other => other, + } +} + +/// `path` with each `..` removing the component before it. +fn fold_parent_dirs(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for c in path.components() { + match c { + std::path::Component::ParentDir => { + out.pop(); + } + std::path::Component::CurDir => {} + other => out.push(other), + } } + out +} - (CargoCrawler::get_registry_src_paths().await, true) +/// Where cargo takes crates.io packages from for a build in `cwd` +/// ([`resolve_for`]), off the runtime. +async fn crates_io_source(cwd: &Path) -> CratesIoSource { + let cwd = cwd.to_path_buf(); + let cargo_home = CargoCrawler::cargo_home(); + run_blocking(move || resolve_for(&cwd, cargo_home.as_deref())).await } /// The `(name, version)` of every registry-sourced `[[package]]` in @@ -390,6 +487,18 @@ impl Default for CargoCrawler { #[cfg(test)] mod tests { + /// `/.cargo/config.toml` replacing crates.io with `/vendor`, + /// as `cargo vendor` asks: only a wired tree is cargo's source (#338). + fn wire_vendor(root: &Path) { + std::fs::create_dir_all(root.join(".cargo")).unwrap(); + std::fs::write( + root.join(".cargo").join("config.toml"), + "[source.crates-io]\nreplace-with = \"vendored-sources\"\n\n\ + [source.vendored-sources]\ndirectory = \"vendor\"\n", + ) + .unwrap(); + } + use super::*; #[test] @@ -569,6 +678,7 @@ mod tests { ) .await .unwrap(); + wire_vendor(dir.path()); let crawler = CargoCrawler::new(); let options = CrawlerOptions { @@ -622,6 +732,7 @@ mod tests { tokio::fs::write(dir.path().join("Cargo.lock"), "version = 3\n") .await .unwrap(); + wire_vendor(dir.path()); let crawler = CargoCrawler::new(); let options = CrawlerOptions { @@ -1144,6 +1255,7 @@ version = "fake" write(&vendored.path().join("Cargo.lock"), &locked); stage(&vendored.path().join("vendor"), "unlocked", "1.0.0"); mkdir(&vendored.path().join("vendor").join(".hidden")); + wire_vendor(vendored.path()); assert_eq!( purls(&crawler.crawl_all(&local(vendored.path())).await), ["pkg:cargo/unlocked@1.0.0"] @@ -1256,6 +1368,7 @@ version = "fake" let mut perms = PermGuard::default(); let mut rng = Rng::new(7); write(&tmp.path().join("Cargo.toml"), "[workspace]\n"); + wire_vendor(tmp.path()); tree( &mut rng, &tmp.path().join("vendor"), @@ -1332,6 +1445,7 @@ version = "fake" tokio::fs::create_dir_all(&left).await.unwrap(); let fifo = left.join("Cargo.toml"); make_fifo(&fifo); + wire_vendor(dir.path()); // A readable crate beside the FIFO proves the crawl continues. let serde_dir = vendor.join("serde"); tokio::fs::create_dir_all(&serde_dir).await.unwrap(); diff --git a/crates/socket-patch-core/src/formats/cargo/mod.rs b/crates/socket-patch-core/src/formats/cargo/mod.rs index f5247f690..cc922f8a3 100644 --- a/crates/socket-patch-core/src/formats/cargo/mod.rs +++ b/crates/socket-patch-core/src/formats/cargo/mod.rs @@ -27,6 +27,7 @@ pub(crate) mod hosted; pub mod manifest; +pub mod source_replacement; use std::ops::Range; diff --git a/crates/socket-patch-core/src/formats/cargo/source_replacement.rs b/crates/socket-patch-core/src/formats/cargo/source_replacement.rs new file mode 100644 index 000000000..cc8e8eca4 --- /dev/null +++ b/crates/socket-patch-core/src/formats/cargo/source_replacement.rs @@ -0,0 +1,313 @@ +//! Cargo's source replacement: where a project's crates.io dependencies +//! actually come from. +//! +//! A `cargo vendor` project builds from a directory, not from the registry +//! cache, because a cargo config says so: +//! +//! ```toml +//! [source.crates-io] +//! replace-with = "vendored-sources" +//! +//! [source.vendored-sources] +//! directory = "third_party" +//! ``` +//! +//! [`crates_io_source`] follows that `replace-with` chain over the config +//! layers cargo reads, highest precedence first, and reports the source it +//! ends on. Each key is taken from the first layer that sets it, which is +//! how cargo merges config tables. A relative `directory` resolves against +//! the layer's [`ConfigLayer::base`]: the parent of the `.cargo/` directory +//! (or of `$CARGO_HOME`) holding the file, as cargo resolves config paths. +//! +//! Pure: the callers own the reads ([`config_layer_paths`] lists the files). + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use toml_edit::{DocumentMut, Item}; + +/// One cargo config file's text, and the directory its relative paths +/// resolve against. +pub struct ConfigLayer<'a> { + pub base: &'a Path, + pub text: &'a str, +} + +/// Where cargo takes crates.io packages from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CratesIoSource { + /// crates.io itself (no replacement, or a chain that cargo would + /// reject, such as a cycle). + Registry, + /// A directory source (`cargo vendor`): the resolved directory. + Directory(PathBuf), + /// A local registry (`cargo local-registry`): the resolved directory + /// of `.crate` files. Cargo unpacks them into the registry cache. + LocalRegistry(PathBuf), + /// Some other replacement (a registry mirror, git), by source name. + Other(String), +} + +impl CratesIoSource { + /// The on-disk source a project builds every crates.io crate from + /// with no network, and its kind: a directory source or a local + /// registry. A Socket registry pin moves a crate off it. + pub fn offline_source(&self) -> Option<(&'static str, &Path)> { + match self { + CratesIoSource::Directory(dir) => Some(("directory source", dir)), + CratesIoSource::LocalRegistry(dir) => Some(("local registry", dir)), + CratesIoSource::Registry | CratesIoSource::Other(_) => None, + } + } +} + +/// Follow `[source.crates-io] replace-with` through `layers` (highest +/// precedence first). A layer that is not valid TOML is skipped. +pub fn crates_io_source(layers: &[ConfigLayer<'_>]) -> CratesIoSource { + let docs: Vec<(&Path, DocumentMut)> = layers + .iter() + .filter_map(|l| { + crate::formats::text::strip_bom(l.text) + .parse::() + .ok() + .map(|d| (l.base, d)) + }) + .collect(); + let lookup = |name: &str, key: &str| -> Option<(&Path, String)> { + docs.iter().find_map(|(base, doc)| { + let value = doc + .get("source") + .and_then(Item::as_table_like) + .and_then(|s| s.get(name)) + .and_then(Item::as_table_like) + .and_then(|t| t.get(key)) + .and_then(Item::as_str)?; + Some((*base, value.to_string())) + }) + }; + + let mut name = "crates-io".to_string(); + let mut seen = HashSet::from([name.clone()]); + while let Some((_, next)) = lookup(&name, "replace-with") { + if !seen.insert(next.clone()) { + return CratesIoSource::Registry; + } + name = next; + } + if name == "crates-io" { + return CratesIoSource::Registry; + } + if let Some((base, dir)) = lookup(&name, "directory") { + return CratesIoSource::Directory(base.join(dir)); + } + match lookup(&name, "local-registry") { + Some((base, dir)) => CratesIoSource::LocalRegistry(base.join(dir)), + None => CratesIoSource::Other(name), + } +} + +/// The cargo config files cargo reads for a build in `cwd`, highest +/// precedence first, each with its [`ConfigLayer::base`]: every ancestor's +/// `.cargo/config` (cargo prefers the legacy extensionless file when both +/// exist) or `.cargo/config.toml`, deepest first, then `$CARGO_HOME`'s when +/// no ancestor already named it. `exists` is the caller's file probe. +pub fn config_layer_paths( + cwd: &Path, + cargo_home: Option<&Path>, + exists: impl Fn(&Path) -> bool, +) -> Vec<(PathBuf, PathBuf)> { + let pick = |dir: &Path| -> Option { + ["config", "config.toml"] + .iter() + .map(|f| dir.join(f)) + .find(|p| exists(p)) + }; + let mut layers = Vec::new(); + let mut dirs_seen: Vec = Vec::new(); + for ancestor in cwd.ancestors() { + let dot_cargo = ancestor.join(".cargo"); + if let Some(file) = pick(&dot_cargo) { + layers.push((file, ancestor.to_path_buf())); + } + dirs_seen.push(dot_cargo); + } + if let Some(home) = cargo_home { + if !dirs_seen.iter().any(|d| d == home) { + if let Some(file) = pick(home) { + let base = home.parent().unwrap_or(home).to_path_buf(); + layers.push((file, base)); + } + } + } + layers +} + +#[cfg(test)] +mod tests { + use super::*; + + fn layer<'a>(base: &'a Path, text: &'a str) -> ConfigLayer<'a> { + ConfigLayer { base, text } + } + + const VENDORED: &str = "[source.crates-io]\nreplace-with = \"vendored-sources\"\n\n\ + [source.vendored-sources]\ndirectory = \"third_party\"\n"; + + #[test] + fn no_config_is_the_registry() { + assert_eq!(crates_io_source(&[]), CratesIoSource::Registry); + let base = Path::new("/p"); + assert_eq!( + crates_io_source(&[layer(base, "[build]\njobs = 2\n")]), + CratesIoSource::Registry + ); + } + + #[test] + fn directory_source_resolves_against_the_layer_base() { + let base = Path::new("/p"); + assert_eq!( + crates_io_source(&[layer(base, VENDORED)]), + CratesIoSource::Directory(PathBuf::from("/p/third_party")) + ); + let abs = "[source.crates-io]\nreplace-with = \"v\"\n[source.v]\ndirectory = \"/abs/v\"\n"; + assert_eq!( + crates_io_source(&[layer(base, abs)]), + CratesIoSource::Directory(PathBuf::from("/abs/v")) + ); + } + + #[test] + fn dotted_and_crlf_spellings_parse() { + let base = Path::new("/p"); + let text = + "\u{feff}source.crates-io.replace-with = \"v\"\r\nsource.v.directory = \"vendor\"\r\n"; + assert_eq!( + crates_io_source(&[layer(base, text)]), + CratesIoSource::Directory(PathBuf::from("/p/vendor")) + ); + } + + #[test] + fn chain_spans_layers_and_each_key_takes_the_first_layer() { + // The member sets replace-with; the workspace root defines the + // directory, resolved against the root's base. + let member = Path::new("/ws/m"); + let root = Path::new("/ws"); + let layers = [ + layer(member, "[source.crates-io]\nreplace-with = \"v\"\n"), + layer(root, "[source.v]\ndirectory = \"vendor\"\n"), + ]; + assert_eq!( + crates_io_source(&layers), + CratesIoSource::Directory(PathBuf::from("/ws/vendor")) + ); + // A deeper layer's directory wins over a shallower one's. + let root_text = VENDORED.replace("vendored-sources", "v"); + let layers = [ + layer(member, "[source.v]\ndirectory = \"near\"\n"), + layer(root, &root_text), + ]; + assert_eq!( + crates_io_source(&layers), + CratesIoSource::Directory(PathBuf::from("/ws/m/near")) + ); + } + + #[test] + fn multi_hop_chain_is_followed() { + let base = Path::new("/p"); + let text = "[source.crates-io]\nreplace-with = \"a\"\n[source.a]\nreplace-with = \"b\"\n\ + [source.b]\ndirectory = \"vendor\"\n"; + assert_eq!( + crates_io_source(&[layer(base, text)]), + CratesIoSource::Directory(PathBuf::from("/p/vendor")) + ); + } + + #[test] + fn non_directory_and_cyclic_replacements() { + let base = Path::new("/p"); + let mirror = "[source.crates-io]\nreplace-with = \"mirror\"\n\ + [source.mirror]\nregistry = \"sparse+https://m.test/\"\n"; + assert_eq!( + crates_io_source(&[layer(base, mirror)]), + CratesIoSource::Other("mirror".into()) + ); + let cycle = + "[source.crates-io]\nreplace-with = \"a\"\n[source.a]\nreplace-with = \"crates-io\"\n"; + assert_eq!( + crates_io_source(&[layer(base, cycle)]), + CratesIoSource::Registry + ); + } + + #[test] + fn local_registry_replacement_resolves_its_directory() { + let base = Path::new("/p"); + let text = "[source.crates-io]\nreplace-with = \"local\"\n\ + [source.local]\nlocal-registry = \"registry\"\n"; + let got = crates_io_source(&[layer(base, text)]); + assert_eq!( + got, + CratesIoSource::LocalRegistry(PathBuf::from("/p/registry")) + ); + assert_eq!( + got.offline_source(), + Some(("local registry", Path::new("/p/registry"))) + ); + assert_eq!(CratesIoSource::Other("m".into()).offline_source(), None); + } + + #[test] + fn invalid_layer_is_skipped() { + let base = Path::new("/p"); + assert_eq!( + crates_io_source(&[layer(base, "[[["), layer(base, VENDORED)]), + CratesIoSource::Directory(PathBuf::from("/p/third_party")) + ); + } + + #[test] + fn layer_paths_are_deepest_first_then_cargo_home() { + let present = [ + "/ws/m/.cargo/config.toml", + "/ws/.cargo/config", + "/ws/.cargo/config.toml", + "/home/u/.cargo/config.toml", + ]; + let exists = |p: &Path| present.iter().any(|q| Path::new(q) == p); + let got = config_layer_paths( + Path::new("/ws/m"), + Some(Path::new("/home/u/.cargo")), + exists, + ); + assert_eq!( + got, + vec![ + ( + PathBuf::from("/ws/m/.cargo/config.toml"), + PathBuf::from("/ws/m") + ), + (PathBuf::from("/ws/.cargo/config"), PathBuf::from("/ws")), + ( + PathBuf::from("/home/u/.cargo/config.toml"), + PathBuf::from("/home/u") + ), + ] + ); + // $CARGO_HOME inside the ancestor chain is read once. + let got = config_layer_paths( + Path::new("/home/u/proj"), + Some(Path::new("/home/u/.cargo")), + exists, + ); + assert_eq!( + got, + vec![( + PathBuf::from("/home/u/.cargo/config.toml"), + PathBuf::from("/home/u") + )] + ); + } +} diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 88a7fdecd..4cac40cbb 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -27,6 +27,7 @@ use crate::api::types::PackageVendorResult; use crate::constants::npm_family::{ RUSH_COMMON_LOCK_REL, RUSH_SUBSPACES_DIR, VLT_HIDDEN_LOCK_REL, VLT_LOCK, }; +use crate::formats::cargo::source_replacement::CratesIoSource; use crate::patch::redirect::npmrc::{ effective_replace_registry_host, plan_npmrc_allow_remote_with, replace_registry_host_rewrites, NpmrcPlan, OuterAllowRemote, NPMRC_ALLOW_REMOTE_EDIT_KIND, NPMRC_REL, @@ -1339,6 +1340,11 @@ pub struct RewriteOptions<'a> { /// resolved only beside a classic `yarn.lock` (its offline-mirror /// refusal). pub yarn_classic_outer: &'a (dyn Fn() -> OuterYarnMirror + Send + Sync), + /// Where the cargo configs read for a build in the project (its own, + /// every parent directory's and `$CARGO_HOME`'s) take crates.io from, + /// resolved only when a cargo dep is rewritten (the cargo rewriter's + /// offline-source refusal). + pub cargo_outer: &'a (dyn Fn() -> CratesIoSource + Send + Sync), /// Run the rewriters on the blocking pool (the disk flow: pure CPU over /// every lock text). pub blocking: bool, @@ -1923,6 +1929,11 @@ async fn rewrite_once( } else { OuterYarnMirror::default() }; + let cargo_outer = if rewrite_overrides.iter().any(|o| o.ecosystem == "cargo") { + (options.cargo_outer)() + } else { + CratesIoSource::Registry + }; let (files, mut rewrite) = if options.blocking { // Pure CPU over every lock text (the independent rewriter groups // run concurrently inside), so it runs on the blocking pool rather @@ -1938,6 +1949,7 @@ async fn rewrite_once( &withheld, &gradle_unreadable, &yarn_outer, + &cargo_outer, ); (files, rewrite) }) @@ -1956,6 +1968,7 @@ async fn rewrite_once( withheld_from_vlt, &gradle_unreadable, &yarn_outer, + &cargo_outer, ); (files, rewrite) }; @@ -3010,6 +3023,10 @@ mod tests { use super::*; use crate::vendor::lock_inventory::MemoryProject; + fn no_cargo_outer() -> CratesIoSource { + CratesIoSource::Registry + } + const GRANT: &str = "GRANTTOKEN0123"; const PATCH_UUID: &str = "7c8d9e0f-1a2b-4a1b-8c2d-3e4f5a6b7c8d"; @@ -3177,6 +3194,7 @@ mod tests { npm_allow_remote_config: true, npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, + cargo_outer: &no_cargo_outer, blocking: false, takeover_uuids: Default::default(), patch_server_origins: Vec::new(), @@ -3248,6 +3266,7 @@ mod tests { npm_allow_remote_config: true, npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, + cargo_outer: &no_cargo_outer, blocking: false, takeover_uuids: Default::default(), patch_server_origins: Vec::new(), @@ -3552,6 +3571,7 @@ mod tests { npm_allow_remote_config: true, npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, + cargo_outer: &no_cargo_outer, blocking: false, takeover_uuids: Default::default(), patch_server_origins: Vec::new(), @@ -3786,6 +3806,7 @@ snapshots: npm_allow_remote_config: true, npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, + cargo_outer: &no_cargo_outer, blocking: false, takeover_uuids: Default::default(), patch_server_origins: Vec::new(), @@ -3889,6 +3910,7 @@ snapshots: npm_allow_remote_config: true, npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, + cargo_outer: &no_cargo_outer, blocking: false, takeover_uuids: Default::default(), patch_server_origins: Vec::new(), @@ -4063,6 +4085,7 @@ snapshots: npm_allow_remote_config: true, npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, + cargo_outer: &no_cargo_outer, blocking: false, takeover_uuids: Default::default(), patch_server_origins: Vec::new(), @@ -4402,6 +4425,7 @@ snapshots: npm_allow_remote_config: true, npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, + cargo_outer: &no_cargo_outer, blocking: false, takeover_uuids: Default::default(), patch_server_origins: configured.iter().map(|o| o.to_string()).collect(), @@ -4616,6 +4640,7 @@ snapshots: npm_allow_remote_config: true, npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, + cargo_outer: &no_cargo_outer, blocking: false, takeover_uuids: Default::default(), patch_server_origins: Vec::new(), @@ -4669,6 +4694,7 @@ snapshots: npm_allow_remote_config: true, npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, + cargo_outer: &no_cargo_outer, blocking: false, takeover_uuids: Default::default(), patch_server_origins: Vec::new(), @@ -5164,6 +5190,7 @@ snapshots: npm_allow_remote_config: true, npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, + cargo_outer: &no_cargo_outer, blocking: false, takeover_uuids: Default::default(), patch_server_origins: Vec::new(), diff --git a/crates/socket-patch-core/src/hosted/memory/stages.rs b/crates/socket-patch-core/src/hosted/memory/stages.rs index f218262b0..44dabd722 100644 --- a/crates/socket-patch-core/src/hosted/memory/stages.rs +++ b/crates/socket-patch-core/src/hosted/memory/stages.rs @@ -9,6 +9,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use crate::api::types::PackageVendorResult; +use crate::formats::cargo::source_replacement::CratesIoSource; use crate::hosted::engine::{ self, Candidate, CandidateFiles, Refusal, RewriteOptions, SkippedPatch, }; @@ -374,6 +375,8 @@ pub(crate) async fn rewrite( npm_allow_remote_config: options.npm_allow_remote_config, npm_outer: &npm_outer, yarn_classic_outer: &yarn_classic_outer, + // The in-memory engine has no project on disk to read. + cargo_outer: &|| CratesIoSource::Registry, blocking: false, takeover_uuids: Default::default(), patch_server_origins: Vec::new(), diff --git a/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs b/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs index 06da327fe..7bbf2bed5 100644 --- a/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs @@ -138,6 +138,7 @@ fn assert_same_with_metadata( python_metadata, &no_gradle_unreadable, &no_yarn_outer, + &CratesIoSource::Registry, ); let merged = merge_group_outputs(&prefix, run_groups_concurrently(&prefix, &groups)).map( |mut merged| { diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 7a8d54452..f96aa4808 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -58,6 +58,7 @@ pub mod presence; use crate::formats::cargo::hosted::CargoLockPlan; #[cfg(test)] use crate::formats::cargo::hosted::CARGO_LOCK_REFERENCE_KIND; +use crate::formats::cargo::source_replacement::{crates_io_source, ConfigLayer, CratesIoSource}; use crate::formats::cargo::CargoLock; use crate::formats::composer::hosted::rewrite_composer_lock; use crate::formats::gem::gemfile; @@ -598,6 +599,7 @@ pub fn rewrite_registry_redirect_with_pipenv_version( &std::collections::BTreeSet::new(), &std::collections::BTreeSet::new(), &yarnrc::OuterYarnMirror::default(), + &CratesIoSource::Registry, ) } @@ -677,6 +679,7 @@ pub fn rewrite_registry_redirect_withholding_vlt( vlt_withheld: &std::collections::BTreeSet, gradle_unreadable: &std::collections::BTreeSet, yarn_outer: &yarnrc::OuterYarnMirror, + cargo_outer: &CratesIoSource, ) -> RewriteResult { let mut result = RewriteResult::default(); let overrides = withhold_pypi_platform_wheels(overrides, &mut result); @@ -703,6 +706,7 @@ pub fn rewrite_registry_redirect_withholding_vlt( artifact_metadata, gradle_unreadable, yarn_outer, + cargo_outer, ); let mut result = rewrite_groups_parallel(result, &groups); result.vlt_drives = vlt::vlt_drives(files, bun_lockb_present); @@ -721,6 +725,7 @@ type RewriterGroup<'a> = Box; /// withholding prefix's. Every group sees the full withheld override slice, /// exactly as the serial chain did; the vlt rewriter sees `vlt_overrides`, /// that slice minus the vlt-withheld uuids. +#[allow(clippy::too_many_arguments)] fn rewriter_groups<'a>( files: &'a BTreeMap, overrides: &'a [DepOverride], @@ -729,6 +734,7 @@ fn rewriter_groups<'a>( artifact_metadata: &'a BTreeMap, gradle_unreadable: &'a std::collections::BTreeSet, yarn_outer: &'a yarnrc::OuterYarnMirror, + cargo_outer: &'a CratesIoSource, ) -> Vec> { vec![ Box::new(move |result| { @@ -747,7 +753,7 @@ fn rewriter_groups<'a>( rewrite_uv_lock(files, overrides, artifact_metadata, result); poetry::rewrite_poetry(files, overrides, result); }), - Box::new(move |result| rewrite_cargo(files, overrides, result)), + Box::new(move |result| rewrite_cargo_with(files, overrides, cargo_outer, result)), Box::new(move |result| rewrite_composer_lock(files, overrides, result)), Box::new(move |result| rewrite_nuget(files, overrides, result)), Box::new(move |result| rewrite_gem(files, overrides, result)), @@ -1657,10 +1663,24 @@ fn rewrite_npm_entry( // while the manifest still says crates.io, or an inert `[registries.…]` block // with nothing referencing it) breaks `--locked` builds or silently drops the // patch while attesting it — the exact failure mode this shape forbids. +#[cfg(test)] fn rewrite_cargo( files: &BTreeMap, overrides: &[DepOverride], result: &mut RewriteResult, +) { + rewrite_cargo_with(files, overrides, &CratesIoSource::Registry, result); +} + +/// [`rewrite_cargo`] with `cargo_outer`: where the cargo configs read for a +/// build in the project (its own, every parent directory's and +/// `$CARGO_HOME`'s) take crates.io from. The project's own config is also +/// in `files`, and is checked first so the refusal can name it. +fn rewrite_cargo_with( + files: &BTreeMap, + overrides: &[DepOverride], + cargo_outer: &CratesIoSource, + result: &mut RewriteResult, ) { let cargo: Vec<&DepOverride> = overrides .iter() @@ -1732,6 +1752,52 @@ fn rewrite_cargo( .cloned() .map(|t| to_lf(cargo_config_key, t)) .unwrap_or_default(); + // A `cargo vendor` or `cargo local-registry` project: crates.io is + // replaced by an on-disk source, so cargo builds every crates.io + // dependency from it with no network. A Socket registry pin moves the + // crate onto a registry that source doesn't hold, so every `--frozen` + // `--offline` build breaks (#455) and the vendored copy stays + // unpatched: nothing is pinned. The replacement may sit in the + // project's config or in one cargo reads from outside it (a monorepo + // root, `$CARGO_HOME`). + let project_source = crates_io_source(&[ConfigLayer { + base: std::path::Path::new(""), + text: &cargo_config, + }]); + let offline = match project_source.offline_source() { + Some((kind, dir)) => Some(( + kind, + dir.to_path_buf(), + format!("the project's {cargo_config_key}"), + )), + None => cargo_outer.offline_source().map(|(kind, dir)| { + ( + kind, + dir.to_path_buf(), + "a cargo config outside the project (a parent directory's or $CARGO_HOME's)" + .to_string(), + ) + }), + }; + if let Some((kind, dir, origin)) = offline { + for dep in &cargo { + result.warnings.push(RewriteWarning { + code: "redirect_cargo_directory_source".into(), + detail: format!( + "{}@{}: {origin} replaces crates.io with the {kind} `{}`, so the \ + project builds offline from it; a Socket registry pin would move the \ + crate off it onto a registry it doesn't hold, and every \ + `cargo build --frozen --offline` would fail to find it — dependency \ + skipped (nothing rewritten). Use agent mode (`scan --mode agent`), which \ + patches the copy cargo builds in place", + dep.name, + dep.version, + dir.display() + ), + }); + } + return; + } let (mut lock_changed, mut config_changed) = (false, false); // The manifests as they arrived: a generation they pin that the final // manifests no longer do was superseded by this run (see below). @@ -12039,6 +12105,7 @@ mod tests { &std::collections::BTreeSet::new(), &std::collections::BTreeSet::new(), &refusing[3], + &CratesIoSource::Registry, ); assert!(!r.files.contains_key("yarn.lock")); assert!(r @@ -14195,6 +14262,116 @@ mod tests { assert_cargo_override_refused(&aliased, "lockless aliased config override"); } + /// #455: a `cargo vendor` project builds from the directory source its + /// cargo config replaces crates.io with, never from a registry, so a + /// Socket registry pin breaks every `cargo build --frozen --offline`. + /// Hosted mode refuses it with nothing written, in either config + /// spelling and through a multi-hop chain; a registry mirror is not a + /// directory source and is still redirected. + #[test] + fn cargo_directory_source_replacement_is_refused() { + let toml = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\nserde = \"1.0.190\"\n"; + let vendored = "[source.crates-io]\nreplace-with = \"vendored-sources\"\n\n\ + [source.vendored-sources]\ndirectory = \"vendor\"\n"; + let chained = + "[source.crates-io]\nreplace-with = \"a\"\n[source.a]\nreplace-with = \"b\"\n\ + [source.b]\ndirectory = \"third_party\"\n"; + let cases = [ + (".cargo/config.toml", vendored.to_string()), + (".cargo/config", vendored.to_string()), + (".cargo/config.toml", vendored.replace('\n', "\r\n")), + (".cargo/config.toml", chained.to_string()), + ]; + for (key, config) in cases { + let mut files = cargo_files(toml); + files.insert(key.to_string(), config.clone()); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!(r.files.is_empty(), "{key} {config:?}: {:?}", r.files); + assert!(r.edits.is_empty(), "{key} {config:?}: {:?}", r.edits); + assert!(r.confirmed_cargo_uuids.is_empty(), "{key} {config:?}"); + let codes: Vec<&str> = r.warnings.iter().map(|w| w.code.as_str()).collect(); + assert_eq!( + codes, + ["redirect_cargo_directory_source"], + "{key} {config:?}" + ); + let detail = &r.warnings[0].detail; + assert!( + detail.contains("serde@1.0.190") && detail.contains(key), + "{detail}" + ); + } + + let mut files = cargo_files(toml); + files.insert( + ".cargo/config.toml".to_string(), + "[source.crates-io]\nreplace-with = \"mirror\"\n\ + [source.mirror]\nregistry = \"sparse+https://mirror.test/\"\n" + .to_string(), + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); + } + + /// #455 review: the offline source may come from outside the project + /// files (a monorepo root's `.cargo/config.toml`, `$CARGO_HOME`'s), or + /// be a `cargo local-registry`; both are refused like a project + /// `cargo vendor` directory. An outer registry mirror is still pinned. + #[test] + fn cargo_offline_source_outside_the_project_or_local_registry_is_refused() { + let toml = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\nserde = \"1.0.190\"\n"; + let refused = |files: &BTreeMap, outer: &CratesIoSource, what: &str| { + let mut r = RewriteResult::default(); + rewrite_cargo_with(files, &[cargo_sparse_override()], outer, &mut r); + assert!(r.files.is_empty(), "{what}: {:?}", r.files); + assert!(r.edits.is_empty(), "{what}"); + 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, ["redirect_cargo_directory_source"], "{what}"); + r.warnings.remove(0).detail + }; + + let files = cargo_files(toml); + let detail = refused( + &files, + &CratesIoSource::Directory("/repo/vendor".into()), + "monorepo-root directory source", + ); + assert!( + detail.contains("outside the project") && detail.contains("/repo/vendor"), + "{detail}" + ); + let detail = refused( + &files, + &CratesIoSource::LocalRegistry("/ci/registry".into()), + "CARGO_HOME local registry", + ); + assert!(detail.contains("local registry"), "{detail}"); + + let mut local = cargo_files(toml); + local.insert( + ".cargo/config.toml".to_string(), + "[source.crates-io]\nreplace-with = \"local\"\n\ + [source.local]\nlocal-registry = \"registry\"\n" + .to_string(), + ); + let detail = refused(&local, &CratesIoSource::Registry, "project local registry"); + assert!(detail.contains(".cargo/config.toml"), "{detail}"); + + let mut r = RewriteResult::default(); + rewrite_cargo_with( + &files, + &[cargo_sparse_override()], + &CratesIoSource::Other("mirror".into()), + &mut r, + ); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); + } + /// Control for #480: an override of an UNRELATED crate leaves the /// patched crate on crates.io, so it is still redirected. #[test] diff --git a/crates/socket-patch-core/tests/crawler_cargo_e2e.rs b/crates/socket-patch-core/tests/crawler_cargo_e2e.rs index 7976e153f..4c4775353 100644 --- a/crates/socket-patch-core/tests/crawler_cargo_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_cargo_e2e.rs @@ -41,6 +41,23 @@ async fn stage_vendor_crate(src: &Path, name: &str, version: &str) -> std::path: pkg } +/// `/.cargo/config.toml` replacing crates.io with directory `dir`, +/// as `cargo vendor ` prints it. +async fn write_vendor_config(root: &Path, dir: &str) { + tokio::fs::create_dir_all(root.join(".cargo")) + .await + .unwrap(); + tokio::fs::write( + root.join(".cargo").join("config.toml"), + format!( + "[source.crates-io]\nreplace-with = \"vendored-sources\"\n\n\ + [source.vendored-sources]\ndirectory = \"{dir}\"\n" + ), + ) + .await + .unwrap(); +} + // ── package_name_version ────────────────────────────── #[test] @@ -412,13 +429,15 @@ async fn get_crate_source_paths_with_vendor_dir_returns_vendor() { tokio::fs::create_dir(&vendor).await.unwrap(); // `vendor/` is only treated as cargo sources once we've confirmed // this is a Rust project (`vendor/` is also Composer's and Go's - // convention) — so a root Cargo.toml is required. + // convention) — so a root Cargo.toml is required — and once a cargo + // config replaces crates.io with it, as `cargo vendor` asks (#338). tokio::fs::write( tmp.path().join("Cargo.toml"), "[package]\nname = \"root\"\nversion = \"0.1.0\"\n", ) .await .unwrap(); + write_vendor_config(tmp.path(), "vendor").await; let crawler = CargoCrawler; let paths = crawler @@ -835,3 +854,247 @@ async fn get_crate_source_paths_local_cargo_toml_with_registry_src() { // not merely "contains" it among arbitrary extras. assert_eq!(paths, vec![index_dir]); } + +// ── source replacement (#338, #455) ──────────────────────────── + +/// Points `CARGO_HOME` at a fresh dir whose registry cache holds `cfg-if` +/// 1.0.0, restoring the old value on drop. +struct CargoHomeGuard { + prev: Option, + home: tempfile::TempDir, +} + +impl CargoHomeGuard { + async fn new() -> Self { + let home = tempfile::tempdir().unwrap(); + stage_registry_crate(&Self::index(home.path()), "cfg-if", "1.0.0").await; + let prev = std::env::var("CARGO_HOME").ok(); + std::env::set_var("CARGO_HOME", home.path()); + Self { prev, home } + } + + fn index(home: &Path) -> std::path::PathBuf { + home.join("registry") + .join("src") + .join("index.crates.io-stub") + } +} + +impl Drop for CargoHomeGuard { + fn drop(&mut self) { + match self.prev.take() { + Some(v) => std::env::set_var("CARGO_HOME", v), + None => std::env::remove_var("CARGO_HOME"), + } + } +} + +async fn write_app(dir: &Path) { + tokio::fs::create_dir_all(dir).await.unwrap(); + tokio::fs::write( + dir.join("Cargo.toml"), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n", + ) + .await + .unwrap(); +} + +async fn crawled_paths(cwd: &Path) -> Vec { + CargoCrawler + .crawl_all(&options_at(cwd)) + .await + .into_iter() + .filter(|p| p.purl == "pkg:cargo/cfg-if@1.0.0") + .map(|p| p.path) + .collect() +} + +/// #338 shape 1: `cargo vendor third_party` wired by the project config. +/// The crawl must find the crate in `third_party/`, the copy cargo builds, +/// not in the registry cache. +#[tokio::test] +#[serial_test::serial] +async fn custom_vendor_dir_named_by_source_replacement_is_crawled() { + let cargo_home = CargoHomeGuard::new().await; + let tmp = tempfile::tempdir().unwrap(); + write_app(tmp.path()).await; + let vendored = stage_vendor_crate(&tmp.path().join("third_party"), "cfg-if", "1.0.0").await; + write_vendor_config(tmp.path(), "third_party").await; + + let paths = CargoCrawler + .get_crate_source_paths(&options_at(tmp.path())) + .await + .unwrap(); + assert_eq!(paths, vec![tmp.path().join("third_party")]); + assert_eq!(crawled_paths(tmp.path()).await, vec![vendored]); + drop(cargo_home); +} + +/// #338 shape 2: run from a workspace member, the root's `vendor/` wired +/// by the root's config is what cargo builds from. +#[tokio::test] +#[serial_test::serial] +async fn workspace_root_source_replacement_applies_to_a_member() { + let cargo_home = CargoHomeGuard::new().await; + let tmp = tempfile::tempdir().unwrap(); + tokio::fs::write( + tmp.path().join("Cargo.toml"), + "[workspace]\nmembers = [\"m\"]\n", + ) + .await + .unwrap(); + let member = tmp.path().join("m"); + write_app(&member).await; + let vendored = stage_vendor_crate(&tmp.path().join("vendor"), "cfg-if", "1.0.0").await; + write_vendor_config(tmp.path(), "vendor").await; + + assert_eq!(crawled_paths(&member).await, vec![vendored]); + drop(cargo_home); +} + +/// #338 shape 3, and its non-cargo `vendor/` variant: a `vendor/` no +/// source replacement names is not where cargo builds from, so the +/// registry cache is crawled. +#[tokio::test] +#[serial_test::serial] +async fn unwired_vendor_dir_falls_back_to_the_registry_cache() { + let cargo_home = CargoHomeGuard::new().await; + let cached = CargoHomeGuard::index(cargo_home.home.path()).join("cfg-if-1.0.0"); + + // A stale `cargo vendor` tree with no config. + let stale = tempfile::tempdir().unwrap(); + write_app(stale.path()).await; + stage_vendor_crate(&stale.path().join("vendor"), "cfg-if", "1.0.0").await; + assert_eq!(crawled_paths(stale.path()).await, vec![cached.clone()]); + + // C sources in `vendor/`. + let c_lib = tempfile::tempdir().unwrap(); + write_app(c_lib.path()).await; + tokio::fs::create_dir_all(c_lib.path().join("vendor/zlib")) + .await + .unwrap(); + tokio::fs::write(c_lib.path().join("vendor/zlib/zlib.c"), "int x;") + .await + .unwrap(); + assert_eq!(crawled_paths(c_lib.path()).await, vec![cached]); + drop(cargo_home); +} + +/// A source replacement naming a directory that isn't there (a checkout +/// that didn't commit it): cargo can't build from the registry cache +/// either, so nothing is crawled rather than a copy cargo ignores. +#[tokio::test] +#[serial_test::serial] +async fn missing_replacement_directory_crawls_nothing() { + let cargo_home = CargoHomeGuard::new().await; + let tmp = tempfile::tempdir().unwrap(); + write_app(tmp.path()).await; + write_vendor_config(tmp.path(), "third_party").await; + assert!(crawled_paths(tmp.path()).await.is_empty()); + drop(cargo_home); +} + +/// A replacement set in `$CARGO_HOME/config.toml` resolves its relative +/// directory against the parent of `$CARGO_HOME`, as cargo does. +#[tokio::test] +#[serial_test::serial] +async fn cargo_home_source_replacement_is_honoured() { + let cargo_home = CargoHomeGuard::new().await; + let home = cargo_home.home.path(); + let shared = home.parent().unwrap().join(format!( + "shared-vendor-{}", + home.file_name().unwrap().to_string_lossy() + )); + let vendored = stage_vendor_crate(&shared, "cfg-if", "1.0.0").await; + tokio::fs::write( + home.join("config.toml"), + format!( + "[source.crates-io]\nreplace-with = \"v\"\n[source.v]\ndirectory = \"{}\"\n", + shared.file_name().unwrap().to_string_lossy() + ), + ) + .await + .unwrap(); + let tmp = tempfile::tempdir().unwrap(); + write_app(tmp.path()).await; + + let got = crawled_paths(tmp.path()).await; + let _ = std::fs::remove_dir_all(&shared); + assert_eq!(got, vec![vendored]); + drop(cargo_home); +} + +/// A `..` in the cwd is folded before the config walk: `b/../app` reads +/// `app`'s ancestors, never `b/.cargo/config.toml`. +#[tokio::test] +#[serial_test::serial] +async fn parent_dir_in_cwd_does_not_read_a_sibling_config() { + let cargo_home = CargoHomeGuard::new().await; + let cached = CargoHomeGuard::index(cargo_home.home.path()).join("cfg-if-1.0.0"); + let tmp = tempfile::tempdir().unwrap(); + let app = tmp.path().join("app"); + write_app(&app).await; + let sibling = tmp.path().join("b"); + stage_vendor_crate(&sibling.join("vendor"), "cfg-if", "1.0.0").await; + write_vendor_config(&sibling, "vendor").await; + + let dotted = sibling.join("..").join("app"); + assert_eq!(crawled_paths(&dotted).await, vec![cached]); + drop(cargo_home); +} + +/// A directory source under a cwd given with `..` is reported under that +/// cwd as given: path-scoped commands strip it as a prefix. +#[tokio::test] +#[serial_test::serial] +async fn directory_source_keeps_the_given_cwd_prefix() { + let cargo_home = CargoHomeGuard::new().await; + let tmp = tempfile::tempdir().unwrap(); + let app = tmp.path().join("app"); + write_app(&app).await; + stage_vendor_crate(&app.join("vendor"), "cfg-if", "1.0.0").await; + write_vendor_config(&app, "vendor").await; + std::fs::create_dir_all(tmp.path().join("b")).unwrap(); + + let dotted = tmp.path().join("b").join("..").join("app"); + // The cwd as path-scoped commands see it: `absolute` keeps the `..` + // on Unix and folds it on Windows. + let given = std::path::absolute(&dotted).unwrap(); + let paths = CargoCrawler + .get_crate_source_paths(&options_at(&dotted)) + .await + .unwrap(); + assert_eq!(paths, vec![given.join("vendor")]); + let crawled = crawled_paths(&dotted).await; + assert_eq!(crawled, vec![given.join("vendor").join("cfg-if")]); + drop(cargo_home); +} + +/// #455 review: the process-level resolve the hosted scan uses sees a +/// monorepo root's source replacement from a nested Cargo project. +#[test] +#[serial_test::serial] +fn resolve_for_reads_a_monorepo_root_config() { + use socket_patch_core::formats::cargo::source_replacement::CratesIoSource; + let tmp = tempfile::tempdir().unwrap(); + let rust = tmp.path().join("rust"); + std::fs::create_dir_all(&rust).unwrap(); + std::fs::create_dir_all(tmp.path().join(".cargo")).unwrap(); + std::fs::write( + tmp.path().join(".cargo/config.toml"), + "[source.crates-io]\nreplace-with = \"v\"\n[source.v]\ndirectory = \"vendor\"\n", + ) + .unwrap(); + let empty_home = tempfile::tempdir().unwrap(); + let prev = std::env::var("CARGO_HOME").ok(); + std::env::set_var("CARGO_HOME", empty_home.path()); + let got = CargoCrawler::crates_io_source_for(&rust); + match prev { + Some(v) => std::env::set_var("CARGO_HOME", v), + None => std::env::remove_var("CARGO_HOME"), + } + assert_eq!( + got, + CratesIoSource::Directory(std::path::absolute(tmp.path()).unwrap().join("vendor")) + ); +} diff --git a/crates/socket-patch-core/tests/crawlers_empty_paths_e2e.rs b/crates/socket-patch-core/tests/crawlers_empty_paths_e2e.rs index bfc2084e4..78b5cc7b4 100644 --- a/crates/socket-patch-core/tests/crawlers_empty_paths_e2e.rs +++ b/crates/socket-patch-core/tests/crawlers_empty_paths_e2e.rs @@ -322,13 +322,23 @@ async fn cargo_crawler_crawl_all_empty_returns_empty() { ) .await .unwrap(); - // The vendor tree is only scanned when cwd is a Rust project. + // The vendor tree is only scanned when cwd is a Rust project whose + // cargo config replaces crates.io with it (#338). tokio::fs::write( populated.path().join("Cargo.toml"), "[package]\nname = \"root\"\nversion = \"0.1.0\"\n", ) .await .unwrap(); + tokio::fs::create_dir_all(populated.path().join(".cargo")) + .await + .unwrap(); + tokio::fs::write( + populated.path().join(".cargo/config.toml"), + "[source.crates-io]\nreplace-with = \"v\"\n[source.v]\ndirectory = \"vendor\"\n", + ) + .await + .unwrap(); let found = crawler.crawl_all(&options_at(populated.path())).await; assert!( found.iter().any(|p| p.purl == "pkg:cargo/serde@1.0.200"), diff --git a/docs/ecosystems.md b/docs/ecosystems.md index b5a82cb3e..b27cf0521 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`; 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 | +| 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; a `cargo vendor` or `cargo local-registry` project (crates.io replaced by an on-disk source in the project's, a parent directory's or `$CARGO_HOME`'s `.cargo/config*`) is refused, use agent mode; 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) |