diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 31eab2e4a..b7b3f6c12 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -161,7 +161,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Vendored entries and the rest of the CLI.** Because nothing is in the manifest, vendored patches are invisible to `apply` (nothing to apply in place) but fully visible to `list` (listed from the ledger, labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode, exit 0 on a vendored-only project), `vex` (attested from the embedded records while a lockfile still wires the artifact — see "Manifest-less VEX"), `repair` (health-checked and rebuilt from the ledger), and `scan --prune` (lockfile-driven reconcile). They are exempt from standalone `vendor`'s manifest reconcile (`reconcile_dropped` never touches `detached` entries) and exit via `remove ` (which reverts them), `vendor --revert`, or `rollback`, whose vendored leg reverts every in-scope ledger entry (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). -`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. Gradle is confirmed the same way (`confirmed_gradle_uuids`): only when the final files hold the owned script, the index row, the live apply line in every build's settings file and the suffixed version in every lock entry of the GA (see [Gradle builds](#gradle-builds-v50)). A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). A vendored PyPI package (requirements.txt, Poetry, Pipenv, uv, Hatch, PDM, pylock) is taken over the same way: its vendored wiring is restored to the recorded registry entry, its ledger entry and wheel are removed, and only then is it redirected. The Python rewriters treat any non-registry source as user-authored, so without the revert they refused socket-patch's own vendored source and left the project vendored. A takeover revert that leaves vendored wiring in place is refused with `redirect_vendored_revert_failed`. That covers a drift-skipped record (`vendor_lock_entry_drifted`) and a reverted file that still references the artifact (`vendor_revert_residual_reference`). The ledger entry and artifact are kept, and the package stays vendored and skipped. `--dry-run` predicts the same refusal from the same signals instead of previewing `redirect_would_revert_vendored`. The hosted requirements.txt rewriter only rewrites an existing pin in the root `requirements.txt`, so a vendored requirements.txt package whose wiring is a pin in a `-r` include or a `(transitive)` line vendored mode appended is refused BEFORE its revert, wet and `--dry-run` alike, with `redirect_requirements_takeover_unreachable` (`redirect.warnings[]`, and `redirect.skipped[].reason`). Its wiring, ledger entry and wheel are kept, so it stays vendored and patched (exit 0). A taken-over package whose wiring was reverted but that was then not pinned to hosted now installs the unpatched registry release in both modes. Causes include a refused lock, unavailable hosted wheel metadata, or a vendored ledger update that failed after the revert (refused with `redirect_vendored_revert_failed`). It is reported as `redirect_takeover_unpatched` with `status: "partial_failure"` and exit 1, never as success. That warning also prints under `--silent`. Human output prints no `Migrated …` progress line for the package and no "keep the hosted patches" next steps. Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — `BUNDLE_GEMFILE:` in the bundler app config, which outranks the environment variable as in `Bundler::Settings`, else the environment variable — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). +`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. Gradle is confirmed the same way (`confirmed_gradle_uuids`): only when the final files hold the owned script, the index row, the live apply line in every build's settings file and the suffixed version in every lock entry of the GA (see [Gradle builds](#gradle-builds-v50)). A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). A vendored PyPI package (requirements.txt, Poetry, Pipenv, uv, Hatch, PDM, pylock) is taken over the same way: its vendored wiring is restored to the recorded registry entry, its ledger entry and wheel are removed, and only then is it redirected. The Python rewriters treat any non-registry source as user-authored, so without the revert they refused socket-patch's own vendored source and left the project vendored. A takeover revert that leaves vendored wiring in place is refused with `redirect_vendored_revert_failed`. That covers a drift-skipped record (`vendor_lock_entry_drifted`) and a reverted file that still references the artifact (`vendor_revert_residual_reference`). The ledger entry and artifact are kept, and the package stays vendored and skipped. `--dry-run` predicts the same refusal from the same signals instead of previewing `redirect_would_revert_vendored`. The hosted requirements.txt rewriter only rewrites an existing pin in the root `requirements.txt`, so a vendored requirements.txt package whose wiring is a pin in a `-r` include or a `(transitive)` line vendored mode appended is refused BEFORE its revert, wet and `--dry-run` alike, with `redirect_requirements_takeover_unreachable` (`redirect.warnings[]`, and `redirect.skipped[].reason`). Its wiring, ledger entry and wheel are kept, so it stays vendored and patched (exit 0). A taken-over package whose wiring was reverted but that was then not pinned to hosted now installs the unpatched registry release in both modes. Causes include a refused lock, unavailable hosted wheel metadata, or a vendored ledger update that failed after the revert (refused with `redirect_vendored_revert_failed`). It is reported as `redirect_takeover_unpatched` with `status: "partial_failure"` and exit 1, never as success. That warning also prints under `--silent`. Human output prints no `Migrated …` progress line for the package and no "keep the hosted patches" next steps. Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — `BUNDLE_GEMFILE:` in the bundler app config, which outranks the environment variable as in `Bundler::Settings`, else the environment variable — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_gem_mirror_overrides_source` (gem: Bundler's all-source, exact patch-source or patch-hostname mirror can route the per-dep `source` block to an unpatched upstream gem. Intake reads the app config (`BUNDLE_APP_CONFIG`, where a set-but-empty value selects `/config`, honoring `BUNDLE_IGNORE_CONFIG`) and all `BUNDLE_MIRROR__...` variables visible to the scan; app config overrides the environment per encoded key, then `mirror.all` takes precedence over exact source, which takes precedence over hostname. URI matching follows Bundler's whole-URI case folding, default-port/trailing-slash normalization and single slash key alias, not URL prefixes. An exact-source fallback-timeout key without a mirror URL shadows the hostname mirror and fetches that source directly; a configured URL is conservatively refused even if a timeout could bypass an unreachable mirror at install time. Like `redirect_gem_bundle_gemfile_unsupported`, the gate leaves the Gemfile pair byte-identical and confirms no gem redirect. On an embedded `scan --vex`, rediscovered older hosted gem pins may attest only from verified installed bytes: a missing tree is not excused by the lockfile, and `--vex-no-verify` omits those hosted gems with `mirror_overrides_source` rather than trusting their intercepted source. Agent/vendored evidence, unrelated ecosystems and standalone VEX behavior are unchanged. Details identify the setting form and its app/environment origin without printing mirror values or source URLs, which may contain credentials. Remove the applicable all/source/hostname setting (including any slash alias) from that origin and reuse its existing mirror URL under `mirror.https://rubygems.org` to clear the refusal; an environment setting must be unset in the scan/install environment. User-global Bundler config and mirrors set only in a later install environment are not inspected; keep those mirrors scoped to the upstream source too), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index a04fa45d8..f6be5172e 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -1294,6 +1294,17 @@ pub(crate) async fn run_redirect_selected( // v5 keeps no hosted ledger: this run's fetched records are the // hosted record source of the in-run attestation. params.hosted_records = records.clone(); + // The gem intake gate withholds every gem file. VEX independently + // rediscovers older pins too, so a candidate-only set would miss some + // refused hosted gems. Keep their actual installed-byte verification, + // but do not infer applied status from the intercepted source. + params.hosted_gem_mirror_refused = rewrite + .warnings + .iter() + .any(|warning| warning.code == "redirect_gem_mirror_overrides_source"); + // The warning above exists only when this run had gem candidates; + // also check every hosted gem pin the VEX plan rediscovers. + params.hosted_gem_mirror_check = true; // Stale-flagged purls are EXCLUDED from assume_applied: the same-run // envelope carries a redirect_gem_stale_install warning proving the // installed materialization unpatched, so attesting that purl from diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 57bb78bdc..5a325a430 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -352,6 +352,7 @@ async fn embed_vex_into_json( manifest_path: &Path, base_code: i32, result: &mut serde_json::Value, + hosted: bool, ) -> i32 { if vex_args.vex.is_none() || base_code != 0 { return base_code; @@ -365,7 +366,10 @@ async fn embed_vex_into_json( result["vex"] = serde_json::json!({ "skipped": true, "reason": "dry_run" }); return base_code; } - let params = vex_args.to_build_params(); + let mut params = vex_args.to_build_params(); + // A hosted scan that redirected nothing (empty catalog / no grants) + // still attests older hosted gem pins: check them against the mirror. + params.hosted_gem_mirror_check = hosted; match generate_vex_from_manifest_path(common, ¶ms, manifest_path).await { Ok(summary) => { result["vex"] = serde_json::json!({ @@ -424,6 +428,7 @@ async fn embed_vex_human( vex_args: &VexEmbedArgs, manifest_path: &Path, base_code: i32, + hosted: bool, ) -> i32 { if vex_args.vex.is_none() || base_code != 0 { return base_code; @@ -438,7 +443,10 @@ async fn embed_vex_human( } return base_code; } - let params = vex_args.to_build_params(); + let mut params = vex_args.to_build_params(); + // A hosted scan that redirected nothing (empty catalog / no grants) + // still attests older hosted gem pins: check them against the mirror. + params.hosted_gem_mirror_check = hosted; match generate_vex_from_manifest_path(common, ¶ms, manifest_path).await { Ok(summary) => { if !common.silent { @@ -2075,8 +2083,15 @@ async fn run_scan( result["redirectState"] = state; } } - let code = - embed_vex_into_json(&args.common, &args.vex, &manifest_path, 0, &mut result).await; + let code = embed_vex_into_json( + &args.common, + &args.vex, + &manifest_path, + 0, + &mut result, + hosted, + ) + .await; print_json(&result); return code; } else if !args.common.silent { @@ -2093,7 +2108,7 @@ async fn run_scan( } policy.print_human(args.common.silent, args.common.verbose); } - return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; + return embed_vex_human(&args.common, &args.vex, &manifest_path, 0, hosted).await; } // Build ecosystem summary @@ -2635,6 +2650,7 @@ async fn run_scan( &manifest_path, apply_code, &mut result, + hosted, ) .await; print_json(&result); @@ -2666,7 +2682,7 @@ async fn run_scan( ) .await; } - embed_vex_human(&args_ref.common, &args_ref.vex, manifest_ref, code).await + embed_vex_human(&args_ref.common, &args_ref.vex, manifest_ref, code, hosted).await }; // Every mode stops on an empty discovery, vendored included (restoring @@ -3083,7 +3099,7 @@ async fn run_scan( ) .await; } - return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; + return embed_vex_human(&args.common, &args.vex, &manifest_path, 0, hosted).await; } // Vendor mode: pre-verify baselines so a content mismatch is reported @@ -3186,7 +3202,7 @@ async fn run_scan( .await; } - embed_vex_human(&args.common, &args.vex, &manifest_path, code).await + embed_vex_human(&args.common, &args.vex, &manifest_path, code, hosted).await } #[cfg(test)] diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index 6811740f2..558407ba0 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -677,8 +677,15 @@ async fn run_vendor_json_path( .await; } - let final_code = - embed_vex_into_json(&args.common, &args.vex, manifest_path, vendor_code, result).await; + let final_code = embed_vex_into_json( + &args.common, + &args.vex, + manifest_path, + vendor_code, + result, + false, + ) + .await; print_json(result); final_code } diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index 09b37c485..31a21f679 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -163,6 +163,8 @@ impl VexEmbedArgs { compact: self.vex_compact, assume_applied: Vec::new(), known_stale: Vec::new(), + hosted_gem_mirror_refused: false, + hosted_gem_mirror_check: false, // Embedded callers skip VEX entirely under `--dry-run`. dry_run: false, product_flag: "--vex-product", @@ -194,6 +196,19 @@ pub(crate) struct VexBuildParams { /// Hosted probes positively identified unpatched installed bytes. These /// PURLs cannot be attested by another interpreter or --no-verify. pub known_stale: Vec, + /// Embedded hosted scan refused gem routing because a Bundler mirror + /// captures a patch source. Rediscovered hosted gem pins (including older + /// pins outside this scan's candidates) then require verified installed + /// bytes: neither lockfile inference, assume_applied nor --no-verify may + /// attest them. Agent/vendored evidence and standalone VEX are unchanged. + pub hosted_gem_mirror_refused: bool, + /// Embedded hosted scan (`scan --mode hosted --vex`, `get --mode hosted + /// --vex`), on every terminal path: check each hosted gem pin in the + /// completed VEX plan against the project's Bundler mirror settings and + /// treat a captured pin like [`Self::hosted_gem_mirror_refused`]. Runs + /// independently of this run's grant candidates. Standalone `vex` and + /// agent/vendored embedded VEX leave it off. + pub hosted_gem_mirror_check: bool, /// `vex --dry-run`: build and verify, but write nothing to `output` and /// leave any previous document there alone. Printing to stdout is not a /// mutation, so it still happens. @@ -329,6 +344,8 @@ pub async fn run(args: VexArgs) -> i32 { compact: args.compact, assume_applied: Vec::new(), known_stale: Vec::new(), + hosted_gem_mirror_refused: false, + hosted_gem_mirror_check: false, dry_run: args.common.dry_run, product_flag: "--product", npm_prior: None, @@ -458,6 +475,41 @@ fn org_looks_like_path(org: Option<&str>) -> Option { }) } +/// [`VexBuildParams::hosted_gem_mirror_check`]: the plan's hosted gem pins +/// whose own Socket source (the lock's `GEM` remote) a Bundler mirror +/// captures. Checked from the completed plan, not from this run's grant +/// candidates, so older pins are covered when the scan found no gem to +/// redirect (empty catalog, paid-only gem, withdrawn offer). A pin with no +/// recovered source URL is still captured by `mirror.all`. +async fn hosted_gem_mirror_captured( + common: &GlobalArgs, + params: &VexBuildParams, + plan: &Plan, +) -> std::collections::HashSet { + let mut captured = std::collections::HashSet::new(); + if !params.hosted_gem_mirror_check { + return captured; + } + for (purl, wiring) in plan + .hosted + .iter() + .filter(|(purl, _)| purl.starts_with("pkg:gem/")) + { + let sources: Vec<&str> = wiring + .refs + .iter() + .filter_map(|r| r.url.as_deref()) + .collect(); + if socket_patch_core::crawlers::ruby_crawler::bundler_source_mirror(&common.cwd, &sources) + .await + .is_some() + { + captured.insert(purl.clone()); + } + } + captured +} + /// Core VEX pipeline shared by the standalone `vex` command and the /// embedded `apply`/`vendor`/`scan` `--vex` paths: resolve the product, verify the /// plan's record view against disk (unless `no_verify`), build the OpenVEX @@ -474,6 +526,13 @@ async fn generate_vex( warnings: &mut Vec, ) -> Result { let manifest = &plan.view; + let mirror_captured = hosted_gem_mirror_captured(common, params, &plan).await; + let mirror_refused = |purl: &str| { + mirror_captured.contains(purl) + || (params.hosted_gem_mirror_refused + && purl.starts_with("pkg:gem/") + && plan.hosted.contains_key(purl)) + }; let redirected: &[String] = &plan.redirected; let product_id = match resolve_product_id(common, params.product.as_deref(), warnings).await { Ok(id) => id, @@ -537,7 +596,21 @@ async fn generate_vex( .collect(); vendored.sort(); VerifyOutcome { - applied: manifest.patches.keys().cloned().collect(), + applied: manifest + .patches + .keys() + .filter(|purl| !mirror_refused(purl)) + .cloned() + .collect(), + failed: manifest + .patches + .keys() + .filter(|purl| mirror_refused(purl)) + .map(|purl| FailedPatch { + purl: purl.clone(), + reason: "mirror_overrides_source".into(), + }) + .collect(), vendored, ..Default::default() } @@ -619,6 +692,7 @@ async fn generate_vex( outcome.failed.retain(|f| { let excused = f.reason == "package_not_found" && plan.lockfile_basis.contains(&f.purl) + && !mirror_refused(&f.purl) && crawled(&f.purl) && !hidden(&f.purl); if excused { @@ -657,7 +731,8 @@ async fn generate_vex( .iter() .map(|s| strip_purl_qualifiers(s)) .collect(); - let is_exempt = |purl: &str| exempt.contains(strip_purl_qualifiers(purl)); + let is_exempt = + |purl: &str| exempt.contains(strip_purl_qualifiers(purl)) && !mirror_refused(purl); outcome.failed.retain(|f| !is_exempt(&f.purl)); for key in manifest.patches.keys() { if is_exempt(key) && !outcome.applied.iter().any(|p| p == key) { @@ -1455,6 +1530,9 @@ fn omission_phrase(reason: &str) -> &'static str { against" } "stale_install" => "the installed copy is not patched", + "mirror_overrides_source" => { + "a Bundler mirror overrides the hosted source and installed bytes were not verified" + } RECORD_UNAVAILABLE => { "a lockfile wires the patch, but no local record exists and the patch API could not \ supply one (offline, a network error, not found, or a paid patch without an API \ @@ -1647,6 +1725,7 @@ mod tests { "vendor_artifact_missing", "vendor_manifest_unverifiable", "stale_install", + "mirror_overrides_source", RECORD_UNAVAILABLE, RECORD_MISMATCH, VENDOR_UNWIRED, @@ -1902,6 +1981,8 @@ mod npm_prior_tests { compact: false, assume_applied: Vec::new(), known_stale: Vec::new(), + hosted_gem_mirror_refused: false, + hosted_gem_mirror_check: false, dry_run: false, product_flag: "--vex-product", npm_prior: prior, @@ -1973,3 +2054,208 @@ mod npm_prior_tests { } } } + +#[cfg(test)] +mod mirror_refusal_tests { + use super::*; + use crate::commands::vex_sources::HostedWiring; + use std::collections::{BTreeMap, HashSet}; + + /// The current scan can refuse routing while VEX rediscovers an older pin + /// outside its candidates. Only hosted gem inference is affected; actual + /// installed verification is covered by the native repeat-scan capstone. + #[tokio::test] + async fn mirror_refusal_denies_hosted_gem_inference_without_gating_other_evidence() { + let tmp = tempfile::tempdir().unwrap(); + let common = GlobalArgs { + cwd: tmp.path().to_path_buf(), + json: true, + no_telemetry: true, + ..Default::default() + }; + let purls = [ + "pkg:gem/captured@1.0.0?platform=ruby", + "pkg:gem/agent@1.0.0", + "pkg:npm/other@1.0.0", + "pkg:gem/vendor@1.0.0", + ]; + for refused in [false, true] { + for no_verify in [false, true] { + let mut view = PatchManifest::new(); + for (index, purl) in purls.iter().enumerate() { + let record: PatchRecord = serde_json::from_value(serde_json::json!({ + "uuid": format!("00000000-0000-4000-8000-00000000000{index}"), + "exportedAt": "2026-01-01T00:00:00Z", + "files": {"index.js": {"beforeHash": "a".repeat(64), "afterHash": "b".repeat(64)}}, + "vulnerabilities": {format!("GHSA-mirror-000{index}"): {"cves": [format!("CVE-2026-100{index}")], "summary": "test", "severity": "high", "description": "test"}}, + "description": "test", "license": "MIT", "tier": "free" + })).unwrap(); + view.patches.insert(purl.to_string(), record); + } + let hosted = BTreeMap::from([purls[0], purls[2]].map(|purl| { + ( + purl.to_string(), + HostedWiring { + uuid: view.patches[purl].uuid.clone(), + refs: Vec::new(), + }, + ) + })); + let vendor_entry = serde_json::from_value(serde_json::json!({ + "ecosystem": "gem", "basePurl": purls[3], "uuid": view.patches[purls[3]].uuid, + "artifact": {"path": ".socket/vendor/gem/test/gem"}, "wiring": [] + })) + .unwrap(); + let plan = Plan { + view, + vendor_entries: HashMap::from([(purls[3].to_string(), vendor_entry)]), + redirected: vec![purls[0].to_string(), purls[2].to_string()], + lockfile_basis: HashSet::from([purls[0].to_string(), purls[2].to_string()]), + hosted, + gated: Vec::new(), + notes: Vec::new(), + }; + let mut params = VexEmbedArgs { + vex: Some(tmp.path().join(format!("{refused}-{no_verify}.json"))), + vex_product: Some("pkg:generic/test@1.0.0".into()), + vex_no_verify: no_verify, + ..Default::default() + } + .to_build_params(); + params.hosted_gem_mirror_refused = refused; + // Even a supplied assumption cannot revive the refused pin; + // qualifier-insensitive exemption matching remains intact. + params.assume_applied = purls + .iter() + .map(|purl| purl.split('?').next().unwrap().to_string()) + .collect(); + let summary = generate_vex(&common, ¶ms, plan, &mut Vec::new()) + .await + .unwrap_or_else(|err| panic!("{}: {}", err.code, err.message)); + assert_eq!(summary.statements, if refused { 3 } else { 4 }); + let doc = serde_json::to_string(&summary.doc).unwrap(); + assert_eq!(doc.contains("pkg:gem/captured@"), !refused); + for purl in &purls[1..] { + assert!(doc.contains(purl), "{purl}: {doc}"); + } + assert_eq!(summary.failed.len(), usize::from(refused)); + if refused { + assert_eq!(summary.failed[0].purl, purls[0]); + assert_eq!( + summary.failed[0].reason, + if no_verify { + "mirror_overrides_source" + } else { + "package_not_found" + } + ); + } + } + } + } + + /// Bugbot (PR #684): a hosted scan with no gem candidate (empty catalog, + /// paid-only gem, withdrawn offer) emits no mirror refusal warning, yet + /// its embedded VEX still rediscovers older hosted gem pins. The check + /// runs over the plan's own pin sources, independently of candidates. + #[tokio::test] + async fn mirror_check_refuses_rediscovered_pins_without_gem_candidates() { + use socket_patch_core::vex::discover::PatchedRef; + let tmp = tempfile::tempdir().unwrap(); + let common = GlobalArgs { + cwd: tmp.path().to_path_buf(), + json: true, + no_telemetry: true, + ..Default::default() + }; + let gem = "pkg:gem/captured@1.0.0"; + let npm = "pkg:npm/other@1.0.0"; + let uuid = "00000000-0000-4000-8000-000000000000"; + let source = format!("https://patch.socket.dev/gem/tok/{uuid}/"); + std::fs::create_dir(tmp.path().join(".bundle")).unwrap(); + // (app config, check on, gem attested) + let cases = [ + (None, true, true), + (Some("BUNDLE_MIRROR__ALL"), false, true), + (Some("BUNDLE_MIRROR__ALL"), true, false), + (Some("BUNDLE_MIRROR__PATCH__SOCKET__DEV"), true, false), + (Some("BUNDLE_MIRROR__HTTPS://RUBYGEMS__ORG/"), true, true), + ]; + for (index, (key, check, attested)) in cases.into_iter().enumerate() { + let config = tmp.path().join(".bundle/config"); + match key { + Some(key) => { + std::fs::write(&config, format!("---\n{key}: \"https://m.example/\"\n")) + .unwrap() + } + None => { + let _ = std::fs::remove_file(&config); + } + } + let mut view = PatchManifest::new(); + for (n, purl) in [gem, npm].into_iter().enumerate() { + let record: PatchRecord = serde_json::from_value(serde_json::json!({ + "uuid": format!("00000000-0000-4000-8000-00000000000{n}"), + "exportedAt": "2026-01-01T00:00:00Z", + "files": {"index.js": {"beforeHash": "a".repeat(64), "afterHash": "b".repeat(64)}}, + "vulnerabilities": {format!("GHSA-mirror-100{n}"): {"cves": [format!("CVE-2026-200{n}")], "summary": "test", "severity": "high", "description": "test"}}, + "description": "test", "license": "MIT", "tier": "free" + })) + .unwrap(); + view.patches.insert(purl.to_string(), record); + } + let hosted = BTreeMap::from([ + ( + gem.to_string(), + HostedWiring { + uuid: uuid.into(), + refs: vec![PatchedRef::hosted( + gem.into(), + uuid.into(), + "Gemfile.lock", + Some(&source), + None, + false, + )], + }, + ), + ( + npm.to_string(), + HostedWiring { + uuid: view.patches[npm].uuid.clone(), + refs: Vec::new(), + }, + ), + ]); + let plan = Plan { + view, + vendor_entries: HashMap::new(), + redirected: vec![gem.to_string(), npm.to_string()], + lockfile_basis: HashSet::from([gem.to_string(), npm.to_string()]), + hosted, + gated: Vec::new(), + notes: Vec::new(), + }; + let mut params = VexEmbedArgs { + vex: Some(tmp.path().join(format!("{index}.json"))), + vex_product: Some("pkg:generic/test@1.0.0".into()), + vex_no_verify: true, + ..Default::default() + } + .to_build_params(); + // No candidate refused anything this run: only the plan check. + params.hosted_gem_mirror_check = check; + let summary = generate_vex(&common, ¶ms, plan, &mut Vec::new()) + .await + .unwrap_or_else(|err| panic!("{}: {}", err.code, err.message)); + let doc = serde_json::to_string(&summary.doc).unwrap(); + assert_eq!(doc.contains(gem), attested, "case {index}: {doc}"); + assert!(doc.contains(npm), "case {index}: {doc}"); + if !attested { + assert_eq!(summary.failed.len(), 1, "case {index}"); + assert_eq!(summary.failed[0].purl, gem); + assert_eq!(summary.failed[0].reason, "mirror_overrides_source"); + } + } + } +} diff --git a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs index bd737a2ca..bd2735850 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs @@ -167,6 +167,11 @@ fn run_socket_env(cwd: &Path, args: &[&str], envs: &[(&str, &str)]) -> (i32, Str cmd.env_remove(&k); } } + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("BUNDLE_MIRROR__") { + cmd.env_remove(key); + } + } cmd.env_remove("VIRTUAL_ENV"); for (k, v) in envs { cmd.env(k, v); @@ -445,6 +450,16 @@ enum Driver { /// environment, so bundler still loads `Gemfile.next` and the run must /// still redirect and attest nothing. ScanVexDualBootEnvGemfile, + /// [`Driver::ScanVex`] on a project whose committed `.bundle/config` + /// sets `mirror.all` (#681): bundler fetches the patch-registry source + /// from the mirror, which serves the upstream gem. The run must refuse, + /// write nothing and attest nothing; the fixture asserts that and + /// yields `None`. + ScanVexMirrorAll, + /// Hostname app-config and exact/all environment mirrors use the same gate. + ScanVexMirrorHost, + ScanVexMirrorSourceEnv, + ScanVexMirrorAllEnv, /// [`Driver::ScanVex`] on a Gemfile that pulls the gem from a custom /// `git_source(:local)` key (#652): moved into a Socket source block the /// key still overrides it, so bundler keeps loading the unpatched git @@ -461,6 +476,10 @@ impl Driver { Driver::ScanVexDualBoot => "scan --mode hosted (BUNDLE_GEMFILE=Gemfile.next)", Driver::ScanVexDuplicateDeclaration => "scan --mode hosted (gem in two groups)", Driver::ScanVexEvalGemfile => "scan --mode hosted (gem via eval_gemfile)", + Driver::ScanVexMirrorAll => "scan --mode hosted (bundler mirror.all)", + Driver::ScanVexMirrorHost => "scan --mode hosted (bundler hostname mirror)", + Driver::ScanVexMirrorSourceEnv => "scan --mode hosted (bundler source mirror env)", + Driver::ScanVexMirrorAllEnv => "scan --mode hosted (bundler mirror.all env)", Driver::ScanVexMultiLineDeclaration => "scan --mode hosted (multi-line gem line)", Driver::ScanVexConditionalDeclaration => "scan --mode hosted (gem line with `if`)", Driver::ScanVexScopedConstantModifier => "scan --mode hosted (gem line with `if::ENV`)", @@ -919,8 +938,34 @@ async fn redirect_scanned_project( String::from_utf8_lossy(&cfg.stderr) ); } + // Synthetic credentials must never appear in the scan's automatic + // diagnostics. The loopback mirror itself serves the unpatched gem. + let mirror = format!("{}/upstream/", server.uri()).replacen( + "http://", + "http://review-user:review-secret@", + 1, + ); + if matches!(driver, Driver::ScanVexMirrorAll | Driver::ScanVexMirrorHost) { + let setting = if driver == Driver::ScanVexMirrorAll { + "mirror.all" + } else { + "mirror.127.0.0.1" + }; + let args = bundler.config_local_args(setting, &mirror); + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + let cfg = bundle(&proj, &args); + assert!( + cfg.status.success(), + "bundle config set --local mirror.all failed:\n{}", + String::from_utf8_lossy(&cfg.stderr) + ); + } let argv: Vec<&str> = match driver { Driver::ScanVex + | Driver::ScanVexMirrorAll + | Driver::ScanVexMirrorHost + | Driver::ScanVexMirrorSourceEnv + | Driver::ScanVexMirrorAllEnv | Driver::ScanVexDualBoot | Driver::ScanVexDualBootEnvGemfile | Driver::ScanVexDuplicateDeclaration @@ -966,8 +1011,17 @@ async fn redirect_scanned_project( "fake", ], }; + let mirror_source_key = format!( + "BUNDLE_MIRROR__{}", + index_url + .replace('.', "__") + .replace('-', "___") + .to_uppercase() + ); let socket_env: &[(&str, &str)] = match driver { Driver::ScanVexDualBootEnvGemfile => &[("BUNDLE_GEMFILE", "Gemfile")], + Driver::ScanVexMirrorSourceEnv => &[(&mirror_source_key, &mirror)], + Driver::ScanVexMirrorAllEnv => &[("BUNDLE_MIRROR__ALL", &mirror)], _ => &[], }; let (code, stdout, stderr) = run_socket_env(&proj, &argv, socket_env); @@ -987,6 +1041,10 @@ async fn redirect_scanned_project( if let Some(warning) = match driver { Driver::ScanVexDuplicateDeclaration => Some("redirect_gem_declared_more_than_once"), Driver::ScanVexEvalGemfile => Some("redirect_gem_declaration_not_visible"), + Driver::ScanVexMirrorAll + | Driver::ScanVexMirrorHost + | Driver::ScanVexMirrorSourceEnv + | Driver::ScanVexMirrorAllEnv => Some("redirect_gem_mirror_overrides_source"), Driver::ScanVexCustomGitSource => Some("redirect_gem_source_option"), Driver::ScanVexMultiLineDeclaration | Driver::ScanVexConditionalDeclaration @@ -997,6 +1055,12 @@ async fn redirect_scanned_project( } _ => None, } { + for secret in ["review-user", "review-secret"] { + assert!( + !stdout.contains(secret) && !stderr.contains(secret), + "mirror credential disclosed" + ); + } assert_unwirable_declaration_redirects_nothing( &proj, &bundler, @@ -1084,6 +1148,10 @@ async fn redirect_scanned_project( | Driver::ScanVexDualBootEnvGemfile | Driver::ScanVexDuplicateDeclaration | Driver::ScanVexEvalGemfile + | Driver::ScanVexMirrorAll + | Driver::ScanVexMirrorHost + | Driver::ScanVexMirrorSourceEnv + | Driver::ScanVexMirrorAllEnv | Driver::ScanVexCustomGitSource | Driver::ScanVexMultiLineDeclaration | Driver::ScanVexConditionalDeclaration @@ -1946,6 +2014,135 @@ async fn gem_hosted_bundle_gemfile_config_outranks_env_redirects_nothing() { assert!(fx.is_none(), "the dual-boot driver asserts in place"); } +/// #681: bundler's `mirror.all` sends the per-dep patch-registry `source` +/// block to the mirror, which serves the upstream gem. The hosted scan used +/// to report the gem redirected and attest it while the next install was +/// unpatched (or failed CHECKSUMS); it must refuse, leave the pair +/// untouched and attest nothing. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "host capstone: shells out to a real ruby/gem/bundler (>= 1.17; CHECKSUMS arm >= 2.6); \ + the unpinned `test` job skips it, an e2e job with a pinned toolchain runs it via --ignored"] +async fn gem_hosted_bundler_mirror_all_redirects_nothing() { + let fx = redirect_scanned_project( + "mirror-all", + Spelling::Gemfile, + false, + true, + None, + Driver::ScanVexMirrorAll, + ) + .await; + assert!(fx.is_none(), "the mirror.all driver asserts in place"); +} + +/// Native hostname and environment mirrors must refuse before writing or +/// attesting, and credentialed values must stay out of JSON and stderr. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "host capstone: shells out to real ruby/gem/bundler; pinned e2e job runs --ignored"] +async fn gem_hosted_bundler_host_and_environment_mirrors_redirect_nothing() { + for (label, driver) in [ + ("mirror-host", Driver::ScanVexMirrorHost), + ("mirror-source-env", Driver::ScanVexMirrorSourceEnv), + ("mirror-all-env", Driver::ScanVexMirrorAllEnv), + ] { + let fx = + redirect_scanned_project(label, Spelling::Gemfile, false, true, None, driver).await; + assert!(fx.is_none(), "the mirror driver asserts in place"); + } +} + +/// A repeat scan can rediscover an older hosted pin even though mirror intake +/// refused it. That pin cannot attest without installed, hash-verified bytes. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "host capstone: shells out to real ruby/gem/bundler; pinned e2e job runs --ignored"] +async fn gem_hosted_mirror_rescan_requires_verified_installed_bytes() { + let Some(fx) = redirect_scanned_project( + "mirror-rescan", + Spelling::Gemfile, + true, + true, + None, + Driver::ScanVex, + ) + .await + else { + return; + }; + let (fresh, install) = fresh_checkout_bundle_install(&fx); + assert!( + install.status.success(), + "{}", + String::from_utf8_lossy(&install.stderr) + ); + assert_patched_install(&fx, &fresh); + let api = fx._server.uri(); + for (root, installed) in [(&fx.proj, false), (&fresh, true)] { + let mirror = format!("{api}/upstream/"); + let args = fx.bundler.config_local_args("mirror.127.0.0.1", &mirror); + let args: Vec<_> = args.iter().map(String::as_str).collect(); + assert!(bundle(root, &args).status.success()); + let gemfile = std::fs::read(root.join(fx.gemfile_name)).unwrap(); + let lock = std::fs::read(root.join(fx.lock_name)).unwrap(); + for no_verify in [false, true] { + let output = if no_verify { + "mirror-no-verify.vex.json" + } else { + "mirror-verified.vex.json" + }; + let mut args = vec![ + "scan", + "--mode", + "hosted", + "--json", + "--yes", + "--cwd", + root.to_str().unwrap(), + "--api-url", + &api, + "--patch-server-url", + &api, + "--org", + ORG, + "--api-token", + "fake", + "--vex", + output, + "--vex-product", + PRODUCT, + ]; + if no_verify { + args.push("--vex-no-verify"); + } + let (code, stdout, stderr) = run_socket(root, &args); + let env: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert!( + stdout.contains("redirect_gem_mirror_overrides_source"), + "{env}" + ); + assert_eq!(env["redirect"]["redirected"], 0, "{env}"); + if installed && !no_verify { + assert_eq!(code, 0, "{env}\n{stderr}"); + assert_eq!( + env["vex"]["statements"], 1, + "verified installed bytes remain evidence: {env}" + ); + assert_patched_install(&fx, root); + } else { + assert_ne!(code, 0, "mirror-refused wiring cannot attest: {env}"); + assert!( + !root.join(output).exists(), + "refused VEX output was written" + ); + if no_verify { + assert!(stdout.contains("mirror_overrides_source"), "{env}"); + } + } + assert_eq!(std::fs::read(root.join(fx.gemfile_name)).unwrap(), gemfile); + assert_eq!(std::fs::read(root.join(fx.lock_name)).unwrap(), lock); + } + } +} + /// The compact-index DEPENDENCY contract, pinned from the red side: a patch /// registry whose `/info` omits the gem's runtime deps (production's /// HISTORICAL behavior until the 2026-08-18 republish fixed the served index) diff --git a/crates/socket-patch-core/src/crawlers/gradle_cache.rs b/crates/socket-patch-core/src/crawlers/gradle_cache.rs index ef295ee27..afd7c4fba 100644 --- a/crates/socket-patch-core/src/crawlers/gradle_cache.rs +++ b/crates/socket-patch-core/src/crawlers/gradle_cache.rs @@ -70,8 +70,7 @@ pub fn hash_eq(dir_name: &str, sha1_hex: &str) -> bool { /// Whether `bytes` are the pristine download Gradle stored in the hash /// directory `dir_name` (their sha1 names it). pub fn pristine(dir_name: &str, bytes: &[u8]) -> bool { - use sha1::{Digest, Sha1}; - hash_eq(dir_name, &hex::encode(Sha1::digest(bytes))) + hash_eq(dir_name, &crate::utils::digest::sha1_hex_of(bytes)) } /// Whether `path` is a version directory of a `files-2.1` tree @@ -432,8 +431,6 @@ impl DerivedIndex { /// The [`DerivedCopies`] of the jar `jar_leaf` whose pristine bytes /// hash to `pristine_sha1`. pub fn query(&self, jar_leaf: &str, pristine_sha1: &str) -> DerivedCopies { - use sha1::{Digest, Sha1}; - let instrumented = format!("instrumented-{jar_leaf}"); let mut out = DerivedCopies { incomplete: self.incomplete, @@ -460,7 +457,9 @@ impl DerivedIndex { out.stale.push(path.clone()); } else if name == jar_leaf || name == instrumented { match crate::utils::fs::read_regular_to_bytes_sync(path) { - Ok(bytes) if hash_eq(&hex::encode(Sha1::digest(&bytes)), pristine_sha1) => { + Ok(bytes) + if hash_eq(&crate::utils::digest::sha1_hex_of(&bytes), pristine_sha1) => + { out.stale.push(path.clone()) } Ok(_) => out.unknown.push(path.clone()), diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index 2f8c66b6d..ee125a748 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -1085,6 +1085,50 @@ pub async fn bundler_loaded_manifest_with_env( ) } +/// The Bundler mirror setting that captures one of the patch-registry +/// `sources` (see [`crate::formats::gem::mirror`]), described for the +/// refusal's detail. Reads the app config (honoring `BUNDLE_APP_CONFIG` +/// and `BUNDLE_IGNORE_CONFIG`) and ambient `BUNDLE_MIRROR__...` settings. +pub async fn bundler_source_mirror( + root: &Path, + sources: &[&str], +) -> Option { + let environment: Vec<_> = std::env::vars_os() + .filter_map(|(key, value)| { + let key = key.into_string().ok()?; + if !key.starts_with("BUNDLE_MIRROR__") { + return None; + } + Some((key, value.into_string().ok()?)) + }) + .collect(); + let mirrors: Vec<_> = environment + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())) + .collect(); + bundler_source_mirror_with_env( + root, + sources, + &mirrors, + std::env::var_os("BUNDLE_APP_CONFIG").as_deref(), + bundler_ignores_config(), + ) + .await +} + +/// [`bundler_source_mirror`] with the environment passed explicitly +/// (hermetic tests). +pub async fn bundler_source_mirror_with_env( + root: &Path, + sources: &[&str], + mirror_environment: &[(&str, &str)], + app_config_env: Option<&OsStr>, + ignore_config: bool, +) -> Option { + let config = read_app_config(root, app_config_env, ignore_config).await; + crate::formats::gem::mirror::capturing_mirror(config.as_deref(), mirror_environment, sources) +} + /// Whether bundler skips its config files: `Bundler::Settings#ignore_config?` /// is `ENV["BUNDLE_IGNORE_CONFIG"]`, so any value set (even an empty one) /// switches them off and every setting comes from the environment alone. @@ -1198,9 +1242,10 @@ pub(crate) fn ambient_bundler_global_config_file(root: &Path) -> Option /// exactly: `$BUNDLE_APP_CONFIG` when set (a relative value resolves against /// the project root, NOT the process cwd), else `/.bundle` — e.g. the /// official ruby Docker images export `BUNDLE_APP_CONFIG=/usr/local/bundle`. +/// A set-but-empty value is truthy in Ruby and selects `/config`. pub(crate) fn bundler_app_config_dir(root: &Path, env_value: Option<&OsStr>) -> PathBuf { match env_value { - Some(v) if !v.is_empty() => { + Some(v) => { let p = PathBuf::from(v); if p.is_absolute() { p @@ -1208,7 +1253,7 @@ pub(crate) fn bundler_app_config_dir(root: &Path, env_value: Option<&OsStr>) -> root.join(p) } } - _ => root.join(".bundle"), + None => root.join(".bundle"), } } @@ -1559,6 +1604,131 @@ mod tests { ); } + /// The app-config path/ignore controls apply equally to all mirror forms. + #[tokio::test] + async fn source_mirror_reads_the_app_config_and_the_environment() { + let dir = tempfile::tempdir().unwrap(); + let src = ["https://patch.test/gem/tok/uuid/"]; + assert_eq!( + bundler_source_mirror_with_env(dir.path(), &src, &[], None, false).await, + None + ); + std::fs::create_dir(dir.path().join(".bundle")).unwrap(); + for key in [ + "BUNDLE_MIRROR__ALL", + "BUNDLE_MIRROR__PATCH__TEST", + "BUNDLE_MIRROR__HTTPS://PATCH__TEST/GEM/TOK/UUID/", + ] { + std::fs::write( + dir.path().join(".bundle/config"), + format!("---\n{key}: \"https://m.example/\"\n"), + ) + .unwrap(); + let local = bundler_source_mirror_with_env(dir.path(), &src, &[], None, false) + .await + .unwrap(); + assert!(local.setting.contains("project's Bundler config")); + assert_eq!( + bundler_source_mirror_with_env(dir.path(), &src, &[], None, true).await, + None + ); + let env = [(key, "https://env.example/")]; + let capture = bundler_source_mirror_with_env(dir.path(), &src, &env, None, true) + .await + .unwrap(); + assert!(capture.setting.contains("environment")); + assert_eq!( + bundler_source_mirror_with_env( + dir.path(), + &src, + &[], + Some(OsStr::new("elsewhere")), + false + ) + .await, + None + ); + } + std::fs::create_dir(dir.path().join("elsewhere")).unwrap(); + std::fs::write( + dir.path().join("elsewhere/config"), + "BUNDLE_MIRROR__PATCH__TEST: \"https://m.example/\"\n", + ) + .unwrap(); + assert!(bundler_source_mirror_with_env( + dir.path(), + &src, + &[], + Some(OsStr::new("elsewhere")), + false + ) + .await + .is_some()); + } + + /// Ruby treats an empty BUNDLE_APP_CONFIG as set: config lives directly + /// in the project root, and the usual .bundle/config must not shadow it. + #[tokio::test] + async fn empty_app_config_selects_root_config_and_respects_ignore() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let src = ["https://patch.test/gem/tok/uuid/"]; + std::fs::create_dir(root.join(".bundle")).unwrap(); + let capture = "BUNDLE_MIRROR__ALL: \"https://mirror.example/\"\n"; + let scoped = "BUNDLE_MIRROR__HTTPS://RUBYGEMS__ORG/: \"https://mirror.example/\"\n"; + for root_captures in [true, false] { + let (root_config, usual_config) = if root_captures { + (capture, scoped) + } else { + (scoped, capture) + }; + std::fs::write(root.join("config"), root_config).unwrap(); + std::fs::write(root.join(".bundle/config"), usual_config).unwrap(); + assert_eq!( + bundler_source_mirror_with_env(root, &src, &[], Some(OsStr::new("")), false) + .await + .is_some(), + root_captures + ); + assert_eq!( + bundler_source_mirror_with_env(root, &src, &[], None, false) + .await + .is_some(), + !root_captures + ); + assert!( + bundler_source_mirror_with_env(root, &src, &[], Some(OsStr::new("")), true) + .await + .is_none() + ); + assert!(bundler_source_mirror_with_env( + root, + &src, + &[("BUNDLE_MIRROR__ALL", "https://env.example/")], + Some(OsStr::new("")), + true + ) + .await + .is_some()); + } + // Existing manifest/cache consumers share the same config location. + std::fs::write( + root.join("config"), + "BUNDLE_GEMFILE: \"Gemfile.next\"\nBUNDLE_CACHE_PATH: \"root-cache\"\n", + ) + .unwrap(); + assert!(matches!( + bundler_loaded_manifest_with_env(root, None, Some(OsStr::new("")), false, None).await, + crate::formats::gem::manifest::LoadedManifest::Unsupported { .. } + )); + assert_eq!( + bundler_app_cache_dir_with_env(root, None, Some(OsStr::new("")), false, None).await, + root.join("root-cache") + ); + assert_eq!(bundler_app_config_dir(root, Some(OsStr::new(""))), root); + assert_eq!(bundler_app_config_dir(root, None), root.join(".bundle")); + } + #[test] fn bundle_config_setting_matches_the_exact_key() { let text = "---\nBUNDLE_PATH__SYSTEM: \"true\"\nBUNDLE_PATH: 'vendor/bundle'\n\ diff --git a/crates/socket-patch-core/src/formats/gem/mirror.rs b/crates/socket-patch-core/src/formats/gem/mirror.rs new file mode 100644 index 000000000..dba0367d4 --- /dev/null +++ b/crates/socket-patch-core/src/formats/gem/mirror.rs @@ -0,0 +1,473 @@ +//! Whether a Bundler mirror captures a hosted gem's patch-registry source. +//! +//! Bundler selects `mirror.all`, then an exact source URI, then its hostname. +//! App config overrides the environment per encoded setting key, not per tier: +//! an environment `mirror.all` still outranks a local source-specific mirror. +//! This pure model reads the flat keys written by `bundle config`; the crawler +//! owns app-config/environment intake. User-global config is not modeled. +//! +//! Refuse a configured mirror conservatively even if a fallback timeout might +//! let a particular install bypass it. We do not probe mirror reachability. +//! An exact-source timeout without a mirror URL, however, deterministically +//! shadows the host mirror and selects the original source in Bundler. + +use std::collections::BTreeMap; + +use crate::crawlers::ruby_crawler::unquote_bundle_config_value; + +const MIRROR_PREFIX: &str = "BUNDLE_MIRROR__"; +const FALLBACK_SUFFIX: &str = ".fallback_timeout"; + +#[derive(Clone, Copy)] +enum Origin { + AppConfig, + Environment, +} + +/// Normalize the HTTP(S) URI operations Bundler uses here: add a trailing +/// slash, omit the scheme's default port, and case-fold the whole lookup key. +/// Keep path segments/escapes intact (a web URL parser would normalize more). +fn normalize_source(source: &str) -> String { + let mut uri = source.to_lowercase(); + if !uri.ends_with('/') { + uri.push('/'); + } + let Some((scheme, rest)) = uri.split_once("://") else { + return uri; + }; + let default_port = match scheme { + "http" => 80, + "https" => 443, + _ => return uri, + }; + let authority_start = scheme.len() + 3; + let authority_end = authority_start + rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let authority = &uri[authority_start..authority_end]; + if let Some((host, port)) = authority.rsplit_once(':') { + // The last colon of an IPv6 address is not a port separator. + if !port.contains(']') && port.parse::() == Ok(default_port) { + let port_start = authority_start + host.len(); + uri.replace_range(port_start..authority_end, ""); + } + } + uri +} + +fn source_host(source: &str) -> Option<&str> { + let (_, rest) = source.split_once("://")?; + let authority = rest.split(['/', '?', '#']).next()?; + let host_port = authority.rsplit('@').next()?; + if host_port.starts_with('[') { + Some(&host_port[..=host_port.find(']')?]) + } else { + host_port.split(':').next() + } +} + +/// `Settings.key_for` normalizes HTTP(S) keys before re-encoding them. A +/// malformed noncanonical environment key is not an alternative valid key. +fn encoded_key(setting: &str) -> String { + let setting = if setting.starts_with("http:") || setting.starts_with("https:") { + if let Some(source) = setting.strip_suffix(FALLBACK_SUFFIX) { + format!("{}{FALLBACK_SUFFIX}", normalize_source(source)) + } else { + normalize_source(setting) + } + } else { + setting.to_owned() + }; + format!( + "{MIRROR_PREFIX}{}", + setting + .replace('.', "__") + .replace('-', "___") + .to_uppercase() + ) +} + +/// A mirror setting that captures a patch source, without either URL in its +/// diagnostic: values and source keys can both contain credentials/tokens. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MirrorCapture { + pub setting: String, + pub remedy: String, +} + +fn capture(kind: &str, origin: Origin) -> MirrorCapture { + let (location, remedy) = match (kind, origin) { + ("mirror.all", Origin::AppConfig) => ( + "the project's Bundler config", + "scope the existing mirror URL to `mirror.https://rubygems.org` instead, then remove \ + `mirror.all` (including any slash alias) from the project's Bundler config", + ), + ("mirror.all", Origin::Environment) => ( + "the environment (BUNDLE_MIRROR__ALL or its slash alias)", + "unset the BUNDLE_MIRROR__ALL environment setting (including any slash alias) and \ + scope its existing mirror URL to `mirror.https://rubygems.org` instead", + ), + (_, Origin::AppConfig) => ( + "the project's Bundler config", + "remove the applicable patch-source or hostname mirror from the project's Bundler \ + config and scope its existing mirror URL to `mirror.https://rubygems.org` instead", + ), + (_, Origin::Environment) => ( + "the environment", + "unset the applicable patch-source or hostname BUNDLE_MIRROR__ environment setting \ + and scope its existing mirror URL to `mirror.https://rubygems.org` instead", + ), + }; + MirrorCapture { + setting: format!("Bundler's {kind} setting in {location}"), + remedy: remedy.into(), + } +} + +/// Detect a capturing mirror in the app config and explicit environment +/// settings. `config` is absent when `BUNDLE_IGNORE_CONFIG` is set. Environment +/// keys use Bundler's encoded `BUNDLE_MIRROR__...` spelling. +pub fn capturing_mirror( + config: Option<&str>, + environment: &[(&str, &str)], + sources: &[&str], +) -> Option { + let mut settings = BTreeMap::new(); + for &(key, value) in environment { + if key.starts_with(MIRROR_PREFIX) { + settings.insert(key.to_owned(), (value, Origin::Environment)); + } + } + if let Some(config) = config { + for line in config.lines() { + // URI keys contain colons, so only a YAML mapping separator ends + // the key. Preserve empty values so a local key still shadows env. + if let Some(index) = line.char_indices().find_map(|(index, ch)| { + (ch == ':' + && (line[index + 1..].is_empty() || line[index + 1..].starts_with([' ', '\t']))) + .then_some(index) + }) { + // Settings#load_config also accepts legacy literal dots and + // dashes, and appends a missing slash to HTTP(S) source keys. + // Environment keys do not get this file-only normalization. + let mut key = line[..index].to_owned(); + let lower = key.to_ascii_lowercase(); + if (lower.contains("http:") || lower.contains("https:")) + && !key.ends_with('/') + && !key.ends_with("__FALLBACK_TIMEOUT") + { + key.push('/'); + } + let key = key.replace('.', "__").replace('-', "___"); + if !key.starts_with(MIRROR_PREFIX) { + continue; + } + settings.insert( + key, + ( + unquote_bundle_config_value(&line[index + 1..]), + Origin::AppConfig, + ), + ); + } + } + } + // Settings#all decodes, downcases and sorts names before Mirrors#parse. + let mut names: Vec<_> = settings + .keys() + .map(|key| { + key[MIRROR_PREFIX.len()..] + .replace("___", "-") + .replace("__", ".") + .to_lowercase() + }) + .collect(); + names.sort(); + let mut mirrors = BTreeMap::>::new(); + for name in names { + let Some(&(value, origin)) = settings.get(&encoded_key(&name)) else { + continue; + }; + // MirrorConfig accepts precisely one optional trailing slash alias. + let name = name.strip_suffix('/').unwrap_or(&name); + let (name, timeout_only) = name + .strip_suffix(FALLBACK_SUFFIX) + .map_or((name, false), |name| (name, true)); + let name = if name.contains("://") { + normalize_source(name) + } else { + name.to_owned() + }; + let mirror = mirrors.entry(name).or_default(); + if !timeout_only { + *mirror = (!value.trim().is_empty()).then_some(origin); + } + } + if let Some(Some(origin)) = mirrors.get("all") { + return Some(capture("mirror.all", *origin)); + } + for source in sources { + let source = normalize_source(source); + if let Some(mirror) = mirrors.get(&source) { + if let Some(origin) = mirror { + return Some(capture("exact patch-source mirror", *origin)); + } + // A URL-less exact mirror shadows the hostname fallback. + continue; + } + if let Some(Some(origin)) = source_host(&source).and_then(|host| mirrors.get(host)) { + return Some(capture("patch-registry hostname mirror", *origin)); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + const SRC: &str = "https://patch.socket.dev/gem/tok-1/0a1b-2c/"; + const URL: &str = "https://mirror.example/"; + + fn config(setting: &str) -> String { + format!("{}: \"{URL}\"\n", encoded_key(setting)) + } + + #[test] + fn mirror_all_in_the_app_config_captures_every_source() { + let cfg = config("all"); + let c = capturing_mirror(Some(&cfg), &[], &[SRC]).unwrap(); + assert!(c.setting.contains("mirror.all"), "{c:?}"); + assert!(c.setting.contains("project's Bundler config"), "{c:?}"); + assert!(capturing_mirror(Some(&cfg), &[], &[]).is_some()); + } + + #[test] + fn mirror_all_from_the_environment_captures_every_source() { + let c = capturing_mirror(None, &[("BUNDLE_MIRROR__ALL", URL)], &[SRC]).unwrap(); + assert!(c.setting.contains("BUNDLE_MIRROR__ALL"), "{c:?}"); + assert_eq!( + capturing_mirror(None, &[("BUNDLE_MIRROR__ALL", "")], &[SRC]), + None + ); + } + + #[test] + fn source_and_hostname_matching_follows_bundler_in_both_tiers() { + // Native Bundler 2.6.9 and 4.0.17 controls: one slash alias, full + // case-folding/default ports, exact source vs host (not URL prefixes). + let cases = [ + ("all", SRC, true), + ("all/", SRC, true), + ("all//", SRC, false), + (SRC, SRC, true), + ("https://patch.socket.dev/gem/tok-1/0a1b-2c//", SRC, true), + ("https://patch.socket.dev/gem/tok-1/0a1b-2c///", SRC, false), + ("https://PATCH.socket.dev/GEM/TOK-1/0A1B-2C/", SRC, true), + ("https://patch.socket.dev:443/gem/tok-1/0a1b-2c/", SRC, true), + (SRC, "https://PATCH.socket.dev:443/GEM/TOK-1/0A1B-2C/", true), + ("http://patch.test:80/gem/", "http://patch.test/gem", true), + ("http://[::1]:8443/gem/", "http://[::1]:8443/gem/", true), + ( + "https://patch.socket.dev:8443/gem/tok-1/0a1b-2c/", + SRC, + false, + ), + ("https://patch.socket.dev/", SRC, false), + ("https://patch.socket.dev/gem/other/", SRC, false), + ("patch.socket.dev", SRC, true), + ("PATCH.socket.dev/", SRC, true), + ("patch.socket.dev//", SRC, false), + ( + "patch.socket.dev", + "http://patch.socket.dev:8443/another/", + true, + ), + ( + "patch.socket.dev:8443", + "http://patch.socket.dev:8443/another/", + false, + ), + ("other.socket.dev", SRC, false), + ("https://rubygems.org", SRC, false), + ("rubygems.org", SRC, false), + ]; + for (setting, source, captures) in cases { + let cfg = config(setting); + let key = encoded_key(setting); + for actual in [ + capturing_mirror(Some(&cfg), &[], &[source]), + capturing_mirror(None, &[(&key, URL)], &[source]), + ] { + assert_eq!( + actual.is_some(), + captures, + "{setting:?} for {source:?}: {actual:?}" + ); + } + } + } + + #[test] + fn legacy_app_key_normalization_does_not_apply_to_the_environment() { + let key = "BUNDLE_MIRROR__HTTPS://PATCH.SOCKET.DEV/GEM/TOK-1/0A1B-2C"; + let cfg = format!("{key}: \"{URL}\"\n"); + assert!(capturing_mirror(Some(&cfg), &[], &[SRC]).is_some()); + assert_eq!(capturing_mirror(None, &[(key, URL)], &[SRC]), None); + let empty = format!("{key}: \"\"\n"); + // File normalization happens before the per-key local/env overlay. + let canonical = encoded_key(SRC); + assert_eq!( + capturing_mirror(Some(&empty), &[(&canonical, URL)], &[SRC]), + None + ); + } + + #[test] + fn precedence_is_per_key_then_all_exact_host() { + let exact = encoded_key(SRC); + let host = encoded_key("patch.socket.dev"); + let local_host = config("patch.socket.dev"); + let local_exact = config(SRC); + let local_all = config("all"); + let cases = [ + ( + local_all.as_str(), + exact.as_str(), + "mirror.all", + "project's Bundler config", + ), + ( + local_exact.as_str(), + "BUNDLE_MIRROR__ALL", + "mirror.all", + "environment", + ), + ( + local_host.as_str(), + exact.as_str(), + "exact patch-source", + "environment", + ), + ( + local_exact.as_str(), + exact.as_str(), + "exact patch-source", + "project's Bundler config", + ), + ( + local_exact.as_str(), + host.as_str(), + "exact patch-source", + "project's Bundler config", + ), + ]; + for (cfg, env_key, kind, origin) in cases { + let c = capturing_mirror(Some(cfg), &[(env_key, URL)], &[SRC]).unwrap(); + assert!( + c.setting.contains(kind) && c.setting.contains(origin), + "{c:?}" + ); + } + let both = format!("{local_exact}{local_host}"); + assert!(capturing_mirror(Some(&both), &[], &[SRC]) + .unwrap() + .setting + .contains("exact patch-source")); + // Empty values remain present: don't uncover a lower-tier setting. + assert_eq!( + capturing_mirror( + Some("BUNDLE_MIRROR__ALL: \"\"\n"), + &[("BUNDLE_MIRROR__ALL", URL)], + &[SRC] + ), + None + ); + } + + #[test] + fn fallback_only_exact_mirror_shadows_host_but_all_does_not() { + let exact_timeout = encoded_key(&format!("{SRC}{FALLBACK_SUFFIX}")); + let host = config("patch.socket.dev"); + let cfg = format!("{host}{exact_timeout}: \"true\"\n"); + assert_eq!(capturing_mirror(Some(&cfg), &[], &[SRC]), None); + // The exact shadow only applies to that source, not other candidates. + assert!(capturing_mirror( + Some(&cfg), + &[], + &[SRC, "https://patch.socket.dev/gem/other/"] + ) + .is_some()); + assert_eq!( + capturing_mirror(Some(&host), &[(&exact_timeout, "true")], &[SRC]), + None + ); + assert!(capturing_mirror( + Some(&host), + &[("BUNDLE_MIRROR__ALL__FALLBACK_TIMEOUT", "true")], + &[SRC] + ) + .is_some()); + let both = format!("{cfg}{}", config(SRC)); + assert!(capturing_mirror(Some(&both), &[], &[SRC]).is_some()); + assert_eq!( + capturing_mirror( + Some("BUNDLE_MIRROR__ALL__FALLBACK_TIMEOUT: \"3\"\n"), + &[], + &[SRC] + ), + None + ); + } + + #[test] + fn each_remedy_converges_without_printing_the_mirror_value() { + let scoped = config("https://rubygems.org"); + for setting in ["all", SRC, "patch.socket.dev"] { + let cfg = config(setting); + let key = encoded_key(setting); + for c in [ + capturing_mirror(Some(&cfg), &[], &[SRC]).unwrap(), + capturing_mirror(None, &[(&key, URL)], &[SRC]).unwrap(), + ] { + assert!(c.remedy.contains("mirror.https://rubygems.org"), "{c:?}"); + assert!(!c.remedy.contains(URL), "{c:?}"); + } + assert_eq!(capturing_mirror(Some(&scoped), &[], &[SRC]), None); + assert_eq!(capturing_mirror(None, &[], &[SRC]), None); + } + } + + #[test] + fn mirror_diagnostics_do_not_disclose_configured_credentials() { + let mirror = "https://review-user:review-secret@mirror.example/private?token=review-token"; + let source = "https://source-user:source-secret@patch.socket.dev/gem/source-token/"; + for setting in ["all", source, "patch.socket.dev"] { + let key = encoded_key(setting); + let cfg = format!("{key}: \"{mirror}\"\n"); + for c in [ + capturing_mirror(Some(&cfg), &[], &[source]).unwrap(), + capturing_mirror(None, &[(&key, mirror)], &[source]).unwrap(), + ] { + let diagnostic = format!("{} {}", c.setting, c.remedy); + for secret in [ + "review-user", + "review-secret", + "review-token", + "source-user", + "source-secret", + "source-token", + ] { + assert!( + !diagnostic.contains(secret), + "diagnostic disclosed a synthetic credential" + ); + } + } + } + } + + #[test] + fn a_host_mirror_captures_the_patch_registry() { + let config = "BUNDLE_MIRROR__PATCH__SOCKET__DEV: \"https://mirror.example/\"\n"; + assert!(capturing_mirror(Some(config), &[], &[SRC]).is_some()); + } +} diff --git a/crates/socket-patch-core/src/formats/gem/mod.rs b/crates/socket-patch-core/src/formats/gem/mod.rs index 3a8f17af2..91f0e71b9 100644 --- a/crates/socket-patch-core/src/formats/gem/mod.rs +++ b/crates/socket-patch-core/src/formats/gem/mod.rs @@ -21,6 +21,7 @@ pub(crate) mod gemfile; pub(crate) mod hosted; pub(crate) mod manifest; +pub(crate) mod mirror; use std::collections::{BTreeSet, HashMap}; diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index f84f61b1a..bf2efb8aa 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -308,10 +308,12 @@ pub struct CandidateFiles { /// instead of taking them for absent (and creating a settings file over /// one). pub gradle_unreadable: BTreeSet, - /// Set when bundler is configured (`BUNDLE_GEMFILE`) to load a manifest - /// the gem rewriter cannot edit: every gem manifest and lock was left - /// out of `files`, and the rewrite reports this instead of a redirect. - pub gem_manifest_unsupported: Option, + /// Set when bundler is configured to load a manifest the gem rewriter + /// cannot edit (`BUNDLE_GEMFILE`), or to fetch the patch-registry + /// source through a mirror (`mirror.all`, #681): every gem manifest and + /// lock was left out of `files`, and the rewrite reports this instead + /// of a redirect. + pub gem_refusal: Option, } impl CandidateFiles { @@ -546,7 +548,7 @@ pub async fn read_candidate_files( } } if candidates.iter().any(|c| c.dep.ecosystem == "gem") { - keep_bundler_loaded_gem_files(view, &mut out).await; + keep_bundler_loaded_gem_files(view, candidates, &mut out).await; } // A Gradle build: every script, catalog and lock file its script graph // reaches, for the hosted Gradle planner. @@ -660,30 +662,69 @@ const GEM_MANIFEST_FILES: [&str; 4] = ["Gemfile", "Gemfile.lock", "gems.rb", "ge /// - `BUNDLE_GEMFILE` naming the root `Gemfile` / `gems.rb`: the other /// spelling is dropped; /// - `BUNDLE_GEMFILE` naming anything else: every spelling is dropped and -/// [`CandidateFiles::gem_manifest_unsupported`] says why. +/// [`CandidateFiles::gem_refusal`] says why; +/// - a bundler mirror capturing the patch-registry source (`mirror.all`, +/// or `mirror.`; see [`crate::formats::gem::mirror`]): every +/// spelling is dropped the same way (#681). /// /// A memory view has no environment: only its own app config is read. -async fn keep_bundler_loaded_gem_files(view: &ProjectView<'_>, out: &mut CandidateFiles) { +async fn keep_bundler_loaded_gem_files( + view: &ProjectView<'_>, + candidates: &[Candidate], + out: &mut CandidateFiles, +) { use crate::formats::gem::manifest::LoadedManifest; + let sources: Vec<&str> = candidates + .iter() + .filter_map(|c| c.dep.registry_override.as_ref()) + .filter(|ov| ov.kind == "rubygems-compact-index") + .map(|ov| ov.index_url.as_str()) + .collect(); let loaded = crate::crawlers::ruby_crawler::bundler_loaded_manifest_in(view).await; - let keep: &[&str] = match &loaded { - LoadedManifest::Default => return, - LoadedManifest::Configured { .. } => { + let mirror = match view { + ProjectView::Disk(root) + | ProjectView::Snapshot(crate::vendor::lock_inventory::DiskSnapshot { root, .. }) => { + crate::crawlers::ruby_crawler::bundler_source_mirror(root, &sources).await + } + ProjectView::Memory(_) => { + let config = view.read_text(".bundle/config").await.ok(); + crate::formats::gem::mirror::capturing_mirror(config.as_deref(), &[], &sources) + } + }; + let refusal = if let Some(detail) = loaded.unsupported_detail() { + Some(RewriteWarning { + code: "redirect_gem_bundle_gemfile_unsupported".into(), + detail, + }) + } else { + // #681: a mirror serves the upstream gem for the redirected source, + // so bundler would install unpatched bytes while the run (and its + // VEX) reported the gem redirected. Refuse every gem redirect. + mirror.map(|capture| RewriteWarning { + code: "redirect_gem_mirror_overrides_source".into(), + detail: format!( + "{} routes the Socket patch-registry source to that mirror, which serves \ + the unpatched upstream gem; no gem was redirected. To fix, {} and re-run \ + the scan", + capture.setting, capture.remedy + ), + }) + }; + let keep: &[&str] = match (&loaded, &refusal) { + (_, Some(_)) | (LoadedManifest::Unsupported { .. }, _) => &[], + (LoadedManifest::Default, None) => return, + (LoadedManifest::Configured { .. }, None) => { let (gemfile, lock) = loaded .pair(out.files.contains_key("gems.rb")) .expect("a configured default spelling has a pair"); &[gemfile, lock] } - LoadedManifest::Unsupported { .. } => &[], }; let dropped = |rel: &str| GEM_MANIFEST_FILES.contains(&rel) && !keep.contains(&rel); out.files.retain(|rel, _| !dropped(rel)); out.symlinked_reads.retain(|rel| !dropped(rel)); out.unreadable_reads.retain(|rel| !dropped(rel)); - out.gem_manifest_unsupported = loaded.unsupported_detail().map(|detail| RewriteWarning { - code: "redirect_gem_bundle_gemfile_unsupported".into(), - detail, - }); + out.gem_refusal = refusal; } /// The pypi wheels whose metadata a native lock rewrite needs, in @@ -998,7 +1039,7 @@ pub async fn rewrite( symlinked_reads, unreadable_reads, gradle_unreadable, - gem_manifest_unsupported, + gem_refusal, } = read; // The rewriters' override slice — materialized ONCE, after the last // candidate filter, so it can never disagree with `candidates`. @@ -1063,7 +1104,7 @@ pub async fn rewrite( (files, rewrite) }; // The gem files were withheld on purpose: say why, not "no Gemfile". - if let Some(warning) = gem_manifest_unsupported { + if let Some(warning) = gem_refusal { rewrite .warnings .retain(|w| w.code != "redirect_gem_no_gemfile"); @@ -2499,6 +2540,109 @@ mod tests { assert!(!codes.contains(&"redirect_gem_no_gemfile"), "{codes:?}"); } + fn warning_codes(done: &Rewritten) -> Vec<&str> { + done.rewrite + .warnings + .iter() + .map(|w| w.code.as_str()) + .collect() + } + + /// #681: `bundle config set --local mirror.all ` sends the + /// patch-registry `source` block to the mirror, which serves the + /// upstream gem. The redirect used to be written and attested; now no + /// gem file is a candidate and the run says why. + #[tokio::test] + async fn bundler_mirror_all_redirects_nothing() { + let mut p = MemoryProject::new(); + p.insert_text("Gemfile", GEMFILE); + p.insert_text("Gemfile.lock", GEM_LOCK); + p.insert_text( + ".bundle/config", + "---\nBUNDLE_MIRROR__ALL: \"https://artifactory.example/api/gems/rubygems/\"\n", + ); + let (read, done) = gem_rewrite(&p).await; + assert!(!read.files.contains_key("Gemfile")); + assert!(!read.files.contains_key("Gemfile.lock")); + assert!( + done.rewrite.files.is_empty(), + "{:?}", + done.rewrite.files.keys() + ); + let codes = warning_codes(&done); + assert!( + codes.contains(&"redirect_gem_mirror_overrides_source"), + "{codes:?}" + ); + assert!(!codes.contains(&"redirect_gem_no_gemfile"), "{codes:?}"); + let w = done + .rewrite + .warnings + .iter() + .find(|w| w.code == "redirect_gem_mirror_overrides_source") + .unwrap(); + assert!(!w.detail.contains("artifactory.example"), "{}", w.detail); + assert!( + w.detail.contains("mirror.https://rubygems.org"), + "{}", + w.detail + ); + } + + /// Exact-source and hostname mirrors both refuse intake and confirmation, + /// with sensitive mirror values excluded from the rendered warning. + #[tokio::test] + async fn bundler_mirror_for_the_patch_source_redirects_nothing() { + for key in [ + "BUNDLE_MIRROR__HTTPS://PATCH__TEST/GEM/TOK/UUID/", + "BUNDLE_MIRROR__PATCH__TEST", + "BUNDLE_MIRROR__PATCH__TEST/", + ] { + let mut p = MemoryProject::new(); + p.insert_text("Gemfile", GEMFILE); + p.insert_text("Gemfile.lock", GEM_LOCK); + p.insert_text(".bundle/config", format!("---\n{key}: \"https://review-user:review-secret@m.example/?token=review-token\"\n")); + let (read, done) = gem_rewrite(&p).await; + assert!(!read.files.contains_key("Gemfile")); + assert!(!read.files.contains_key("Gemfile.lock")); + assert!( + done.rewrite.files.is_empty(), + "{key}: {:?}", + done.rewrite.files.keys() + ); + let warning = done + .rewrite + .warnings + .iter() + .find(|warning| warning.code == "redirect_gem_mirror_overrides_source") + .unwrap(); + for secret in ["review-user", "review-secret", "review-token"] { + assert!(!warning.detail.contains(secret)); + } + } + } + + /// A mirror scoped to rubygems.org leaves the patch-registry source + /// alone: the redirect still lands. + #[tokio::test] + async fn bundler_mirror_scoped_to_rubygems_org_still_redirects() { + let mut p = MemoryProject::new(); + p.insert_text("Gemfile", GEMFILE); + p.insert_text("Gemfile.lock", GEM_LOCK); + p.insert_text( + ".bundle/config", + "---\nBUNDLE_MIRROR__HTTPS://RUBYGEMS__ORG/: \"https://m.example/\"\n", + ); + let (_read, done) = gem_rewrite(&p).await; + assert!( + done.rewrite.files.contains_key("Gemfile"), + "{:?} {:?}", + done.rewrite.files.keys(), + warning_codes(&done) + ); + assert!(!warning_codes(&done).contains(&"redirect_gem_mirror_overrides_source")); + } + /// `BUNDLE_GEMFILE: Gemfile` beside a `gems.rb`: bundler loads the /// Gemfile pair, so that is the pair the redirect edits (the rewriter's /// own filename rule would have picked gems.rb). diff --git a/crates/socket-patch-core/src/patch/jvm_jar.rs b/crates/socket-patch-core/src/patch/jvm_jar.rs index 82d679406..f38a84403 100644 --- a/crates/socket-patch-core/src/patch/jvm_jar.rs +++ b/crates/socket-patch-core/src/patch/jvm_jar.rs @@ -25,8 +25,6 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use crate::crawlers::gradle_cache; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; @@ -353,12 +351,11 @@ fn unpatched_members( } fn sha256_hex(bytes: &[u8]) -> String { - use sha2::Digest as _; - hex::encode(sha2::Sha256::digest(bytes)) + crate::utils::digest::sha256_hex_of(bytes) } fn sha1_hex(bytes: &[u8]) -> String { - hex::encode(sha1::Sha1::digest(bytes)) + crate::utils::digest::sha1_hex_of(bytes) } /// `/jvm-originals/.jar`. diff --git a/crates/socket-patch-core/src/patch/sidecars/maven.rs b/crates/socket-patch-core/src/patch/sidecars/maven.rs index f2f5a2466..8798bfce6 100644 --- a/crates/socket-patch-core/src/patch/sidecars/maven.rs +++ b/crates/socket-patch-core/src/patch/sidecars/maven.rs @@ -17,8 +17,6 @@ use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use super::{ SidecarAdvisory, SidecarAdvisoryCode, SidecarError, SidecarFile, SidecarFileAction, SidecarPayload, SidecarSeverity, @@ -44,7 +42,7 @@ impl Algo { fn digest(self, bytes: &[u8]) -> String { match self { - Algo::Sha1 => hex::encode(sha1::Sha1::digest(bytes)), + Algo::Sha1 => crate::utils::digest::sha1_hex_of(bytes), Algo::Md5 => hex::encode(md5(bytes)), } } diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 2c739d742..1855a08f2 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -17,7 +17,7 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. | npm (`npm`) — pnpm / yarn / berry / bun / vlt | ✅ any install layout, vlt's `node_modules/.vlt` store included (every store copy, copy-on-write) | ✅ seven lockfile flavors: package-lock, yarn classic, yarn berry (node-modules linker; PnP refused), pnpm v9, pnpm legacy v5.4/v6.0 (`pnpm 7/8` — frozen installs are path-bound because those majors absolutize `file:` override specifiers; moved checkouts run one `pnpm install --offline --no-frozen-lockfile`, surfaced as `vendor_pnpm_legacy_absolute_specifier`), bun text `bun.lock` lockfileVersion 0/1/2 and native binary `bun.lockb` revisions 1/2/3 (binary locks stay binary; text workspace vendoring requires lockfileVersion 2 — see [Bun compatibility](testing/bun-compatibility.md)), vlt `vlt-lock.json` lockfileVersion 0/1 (patched package directories for direct dependencies of the root or a workspace member; transitive targets refused — see [vlt notes](#npm-vlt-notes)). Rush monorepos refused (`vendor_rush_unsupported`) — see [Rush notes](#npm-rush-monorepos) | ✅ package-lock / npm-shrinkwrap, pnpm-lock.yaml and legacy shrinkwrap.yaml (pnpm majors 1–12; block and flow resolutions), yarn classic, yarn berry, bun, vlt (`vlt-lock.json` without `lockfileVersion`, 0 or 1) — pnpm, berry, bun and vlt carry constraints, see [npm hosted-mode notes](#npm-hosted-mode-notes) and [vlt notes](#npm-vlt-notes) | | PyPI (`pypi`) — uv / poetry / pdm / pipenv / pip | ✅ in place | ✅ uv project/script locks, PEP 751 `pylock.toml` / `pylock..toml`, poetry, pdm, pipenv (Pipenv 2018 or later — every `Pipfile.lock` category is rewired, lock-only checkouts included; Pipenv 2023+ does not hash-check local wheels — `vendor_integrity_unverified`; a venv still holding the upstream release is reported as `pypi_pipenv_stale_install`; see [Pipenv compatibility](testing/pipenv-compatibility.md)), and requirements.txt. Native uv vendoring requires uv ≥ 0.2.35 (the `[[package]]` lock grammar); hosted mode covers native `uv.lock` from uv 0.1.45 (the first release whose `uv lock` writes one) and requirements from uv 0.0.5; see [uv compatibility](testing/uv-compatibility.md). | ✅ requirements.txt including hash continuations, uv project/script locks, and PEP 751 locks. Version/source ambiguity is refused; see [uv compatibility](testing/uv-compatibility.md). Poetry 1.x and 2.x locks are supported; Poetry 0.x ignores URL sources and is refused. See [Poetry compatibility](testing/poetry-compatibility.md). Pipenv `Pipfile.lock` (pipfile-spec 6 — Pipenv 7 and later; `path` references for 7–11, `file` from 2018; lock-only checkouts and Pipenv's out-of-tree venv are discovered; a warm venv that Pipenv will not reinstall over warns `redirect_pypi_stale_install`; see [Pipenv compatibility](testing/pipenv-compatibility.md)). `pdm.lock` is supported for the lock formats PDM 0.12–1.4 and 2.8.1+ write (`lock_version` 2 / 4.3–4.5.1); the identity-losing 3.1 / 4.0–4.2 formats (PDM 1.8–2.7) are refused. PDM 2.8.0 writes an indistinguishable `4.3` lock but shares that identity-loss bug, so a rewritten 2.8.0 lock crashes `pdm sync` — upgrade to ≥ 2.8.1. See [PDM compatibility](testing/pdm-compatibility.md). | | Cargo (`cargo`) | ✅ in-place + `.cargo-checksum.json` rewrite (shared registry-cache caveat — see [Cargo: shared registry cache](#cargo-shared-registry-cache)) | ✅ `[patch.crates-io]` path entry in the root `Cargo.toml` (v5; per-version Socket keys; pre-v5 `.cargo/config*` wiring migrates on re-run) | ✅ per-patch sparse registry (`[registries.socket-patch-]` + Cargo.lock source/checksum); direct dependencies only — a crate another dependency also pulls in is refused, use `--mode vendored`; with no `Cargo.lock` the graph is unknown, so only a project whose sole dependency is the patched crate is redirected | -| RubyGems (`gem`) | ✅ in place | ✅ Gemfile + Gemfile.lock path pair (`Gemfile` spelling only — a `gems.rb` twin, which bundler ≥ 2 loads instead, or a `BUNDLE_GEMFILE`-configured manifest makes vendoring refuse with `gemfile_not_loaded` before any write) | ✅ per-dep `source` block — edits `gems.rb` + `gems.locked` when present (bundler prefers them over `Gemfile`; spellings that diverge beyond Socket's own edits fail closed with `redirect_gem_gemfile_spellings_diverge`; `BUNDLE_GEMFILE` from `.bundle/config` (which outranks the environment, as in bundler), the environment, or the global `~/.bundle/config` / `$BUNDLE_USER_CONFIG` (lowest, as in bundler) is followed when it names the project's `Gemfile` / `gems.rb`, and any other configured manifest is refused with `redirect_gem_bundle_gemfile_unsupported`); the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning); a stale pre-redirect materialization that `bundle install` would reuse instead of refetching is flagged `redirect_gem_stale_install` with a prescriptive remedy (see CLI_CONTRACT.md's "Gem stale-install guard") | +| RubyGems (`gem`) | ✅ in place | ✅ Gemfile + Gemfile.lock path pair (`Gemfile` spelling only — a `gems.rb` twin, which bundler ≥ 2 loads instead, or a `BUNDLE_GEMFILE`-configured manifest makes vendoring refuse with `gemfile_not_loaded` before any write) | ✅ per-dep `source` block — edits `gems.rb` + `gems.locked` when present (bundler prefers them over `Gemfile`; spellings that diverge beyond Socket's own edits fail closed with `redirect_gem_gemfile_spellings_diverge`; `BUNDLE_GEMFILE` from `.bundle/config` (which outranks the environment, as in bundler), the environment, or the global `~/.bundle/config` / `$BUNDLE_USER_CONFIG` (lowest, as in bundler) is followed when it names the project's `Gemfile` / `gems.rb`, and any other configured manifest is refused with `redirect_gem_bundle_gemfile_unsupported`); a Bundler all-source, exact-source or hostname mirror in app config or the scan environment can capture the patch registry, so the redirect is refused with `redirect_gem_mirror_overrides_source` without printing mirror URLs (scope mirrors to `mirror.https://rubygems.org`; user-global config and mirrors set only in a later install environment are not inspected); the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning); a stale pre-redirect materialization that `bundle install` would reuse instead of refetching is flagged `redirect_gem_stale_install` with a prescriptive remedy (see CLI_CONTRACT.md's "Gem stale-install guard") | | Go (`golang`) | ✅ `go.mod` `replace` → `.socket/go-patches/` — see [Go: directory replaces and go.sum](#go-directory-replaces-and-gosum) | ✅ `replace` → the committed vendor tree | ✅ (free tier) fork-style `replace` → `patch.socket.dev/gopatch/` + committed `go.sum` pin; see [Go notes](#go-directory-replaces-and-gosum). Paid hosted patches are unsupported; `redirect_golang_unsupported` names the vendored remedy | | Maven (`maven`) — Maven and Gradle | ✅ in place in every copy the build consumes: each `~/.m2` copy it reads and each Gradle `files-2.1` copy; `~/.m2` `.sha1`/`.md5` sidecars are rewritten, Gradle copies get advisories; jar-member records swap in the patch service's whole jar — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) and [Gradle](#gradle) | ✅ single-POM repository, suffixed Maven reactor repository, or Gradle 6.8+ same-GAV repository with settings wiring and SHA-256 checks (a root with both `pom.xml` and a Gradle build wires both); see [JVM vendoring](design/maven-vendoring.md) and [Gradle](#gradle) | ✅ fail-closed by a Socket-only `-socket.` suffix: pom projects get a pinned `` (`${property}` versions are refused); Gradle 6.8+ builds get an owned settings script, lock-entry rewrites and a resolution tripwire — see [Maven & NuGet caveats](#maven--nuget-caveats) and [Gradle](#gradle) | | NuGet (`nuget`) | ✅ in-place patching deletes `.nupkg.metadata` and advises on the `.nupkg.sha512` tamper-evidence sidecar — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) | ✅ committed folder feed + `packageSourceMapping` + `packages.lock.json` contentHash pin | ✅ `nuget.config` source + source-mapping, `packages.lock.json` contentHash rewrite. See the locked-mode note in [Maven & NuGet caveats](#maven--nuget-caveats) |