From ad8d0678784a21c4ece78308c6b6044c6776e99c Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 24 Sep 2026 12:21:31 -0400 Subject: [PATCH 1/6] Stop hosted Go redirects claiming unpatched deps Hosted mode counted a Go module as redirected whenever any project file held the patch-server origin (an already hosted package-lock.json) or leftover gopatch go.sum lines, even when the go.mod rewrite was refused, so VEX attested modules that still built unpatched. Go deps now count only when the rewriter reports the replace and both go.sum lines in place. A module that go.mod does not require and go.sum does not list (another project's module in the shared module cache) is refused instead of getting an inert replace. Switching a vendored Go module to hosted mode now reverts its vendored copy and ledger entry first, as cargo and npm already did, so the next vendor run no longer switches it back. Replaces the CLI did not write are left alone: only ./.socket/... paths and exactly patch.socket.dev/gopatch/ are socket-owned, not a sibling checkout's .socket/vendor copy or a deeper gopatch path. Duplicate socket replaces for one module collapse to one, a refreshed directive keeps its trailing comment, quoted module paths are recognized, and %2B-encoded +incompatible versions resolve in vendor and apply. Assisted-by: Claude Code:claude-opus-5-5 --- CHANGELOG.md | 35 ++ crates/socket-patch-cli/CLI_CONTRACT.md | 4 +- crates/socket-patch-cli/src/commands/apply.rs | 1 + .../src/commands/scan/hosted.rs | 28 +- .../tests/e2e_golang_hosted_state.rs | 298 ++++++++++++++++++ .../src/crawlers/go_crawler.rs | 26 ++ .../src/patch/redirect/golang_local.rs | 15 +- .../src/patch/redirect/mod.rs | 110 ++++++- crates/socket-patch-core/src/utils/purl.rs | 35 +- .../src/vendor/go_mod_edit.rs | 259 ++++++++++++--- .../src/vendor/go_sum_edit.rs | 11 + crates/socket-patch-core/src/vendor/golang.rs | 2 + 12 files changed, 757 insertions(+), 67 deletions(-) create mode 100644 crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6de7b16c..8915ce7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -345,6 +345,41 @@ into the new version's section — see docs/releasing.md. ### Fixed +- **Hosted Go redirects no longer claim patches that did not land.** + `scan`/`get --mode hosted` counted a Go module as redirected (recorded + it in the redirect ledger, so `vex` attested it) whenever any project + file contained the patch-server origin — e.g. an already hosted + `package-lock.json` — or leftover `patch.socket.dev/gopatch/…` go.sum + lines, even when the go.mod rewrite was refused. A Go module now counts + only when its go.mod `replace` and both go.sum lines are in place. A + module that go.mod does not require and go.sum does not list at the + patched version (another project's module found in the shared module + cache) is refused with `redirect_golang_not_in_module_graph` instead of + getting an inert `replace`. +- **Switching a vendored Go module to hosted mode cleans up the vendored + copy.** `scan`/`get --mode hosted` rewrote the vendored `replace` but + left `.socket/vendor/golang//` and its vendor-ledger entry + behind, so the next `vendor` run switched the module back. The hosted + run now reverts the vendored state first, as it already did for cargo + and npm. +- **Go `replace` directives the CLI did not write are left alone.** A + replace onto another checkout's vendored copy + (`../other/.socket/vendor/golang/…`) or onto a module under + `patch.socket.dev/gopatch/` other than `` was treated as + socket-owned and could be rewritten or removed. Only + `./.socket/vendor/golang/…`, `./.socket/go-patches/…` and exactly + `patch.socket.dev/gopatch/` are now owned. +- **Go replace edits keep go.mod valid and readable.** A go.mod carrying + two socket-owned `replace` lines for one module (for example after a + merge) is collapsed to one instead of leaving a duplicate go rejects; + refreshing a directive keeps its trailing `// comment`; and quoted + module paths (`"github.com/x/y"`) are recognized, so a quoted user + replace is no longer duplicated and a quoted `require` still gets the + version check. +- **`+incompatible` Go modules resolve in vendor and apply.** Purls that + spell the version `v2.0.0%2Bincompatible` are now percent-decoded, so + the module is found in the module cache and the `replace` and copy + directory carry the real `+incompatible` version. - **A vendoring-service outage no longer re-vendors packages.** An npm re-run (every lock flavor, `bun.lockb` included) re-acquired its tarball from whichever source answered — the service's prebuilt, or a local pack diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 736e04ae..f8ab04cc 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -120,7 +120,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), `repair` (health-checked and rebuilt from the ledger), `scan --prune` (lockfile-driven reconcile) and `setup --check`'s patch-consistency property (consulted from the embedded records). They are exempt from standalone `vendor`'s manifest reconcile (`reconcile_dropped` never touches embedded-record 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). The hidden `--detached` flag (`scan --vendor --detached`) names exactly this — the only — vendored posture and is accepted as a no-op for compatibility. -`scan --mode hosted` (== `--redirect`) 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, or — for golang — the `patch.socket.dev/gopatch/` module path) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither recorded nor attested. Re-runs over already-rewritten output record zero new edits. **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/` (and never quarantine: a `--dry-run` or a zero-grant wet run that finds a malformed `redirect-state.json` reports it as the hard error it is — exit 1, the repair-or-move-aside remedy — but moves nothing; only a run holding the lock moves it aside to `redirect-state.json.corrupt`); 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 the redirect ledger is read or 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)**: `scan --mode hosted` prints the results table and update detection like the other modes and confirms once — `Redirect N packages to the hosted patch server?` (singular for one), default yes, skipped by `--yes`/`--json`, on `--dry-run` (the engine honors the preview itself; nothing mutates), and when the detail fetch leaves nothing to redirect (that run enters the engine as a no-op — `Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/` — without prompting); without `--yes` on a non-TTY stdin the shared prompt prints `Non-interactive mode detected, proceeding automatically.` to stderr (unless `--silent`) and proceeds — before rewriting anything (parity with the agent/vendored arms and with `get --mode hosted`). 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 (previously `Redirected 0 packages; rewrote 0 files.`); 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). A malformed redirect ledger on a human hosted run that returns before the engine (empty discovery, nothing downloadable, a detail-fetch failure, a declined confirm) is surfaced there as the read-only `Warning: the redirect ledger … is malformed …` advisory (muted by `--silent`), never moved; the `--json` arm always enters the engine and hard-errors instead. JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang where it used to skip silently, and the ledger's `redirect_nuget_source` edit records `action: "added"` when `nuget.config` was authored from scratch (`rewritten` otherwise). 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. +`scan --mode hosted` (== `--redirect`) 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 recorded nor attested. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). Re-runs over already-rewritten output record zero new edits. **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/` (and never quarantine: a `--dry-run` or a zero-grant wet run that finds a malformed `redirect-state.json` reports it as the hard error it is — exit 1, the repair-or-move-aside remedy — but moves nothing; only a run holding the lock moves it aside to `redirect-state.json.corrupt`); 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 the redirect ledger is read or 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)**: `scan --mode hosted` prints the results table and update detection like the other modes and confirms once — `Redirect N packages to the hosted patch server?` (singular for one), default yes, skipped by `--yes`/`--json`, on `--dry-run` (the engine honors the preview itself; nothing mutates), and when the detail fetch leaves nothing to redirect (that run enters the engine as a no-op — `Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/` — without prompting); without `--yes` on a non-TTY stdin the shared prompt prints `Non-interactive mode detected, proceeding automatically.` to stderr (unless `--silent`) and proceeds — before rewriting anything (parity with the agent/vendored arms and with `get --mode hosted`). 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 (previously `Redirected 0 packages; rewrote 0 files.`); 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). A malformed redirect ledger on a human hosted run that returns before the engine (empty discovery, nothing downloadable, a detail-fetch failure, a declined confirm) is surfaced there as the read-only `Warning: the redirect ledger … is malformed …` advisory (muted by `--silent`), never moved; the `--json` arm always enters the engine and hard-errors instead. JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang where it used to skip silently, and the ledger's `redirect_nuget_source` edit records `action: "added"` when `nuget.config` was authored from scratch (`rewritten` otherwise). 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. 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 and `bun.lock` / `bun.lockb`), `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), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`). **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`); 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). @@ -135,7 +135,7 @@ The rewriter reads a fixed set of candidate files from the project root: the npm **get --mode and installed narrowing (v3.6).** `get --mode hosted|vendored` consumes the resolved patch(es) through the SAME engines as `scan --mode hosted|vendored`, so for the same selected (purl, uuid) set the on-disk result is identical by construction — this is the per-advisory selector hosted/vendored previously lacked (the old workaround, `get --save-only` then `vendor`, still works but is superseded). **Agent mode (v5.0 lock + residue rules)**: the download phase runs under `<.socket>/apply.lock` and hands the guard to the nested apply, so download → manifest write → apply is one lock window (the nested apply never re-acquires and inherits every caller flag — `--lock-timeout` and `--verbose` included); a failed acquire is `{status: "error", errorCode: "lock_held" | "lock_io", error}` on get's legacy envelope, exit 1, before any fetch (a read-only `.socket/` fails here, naming the lock path). `.socket/` and `.socket/blobs/` are created only when a record is actually persisted — an all-skipped or all-failed run leaves no `.socket/` on a fresh project — and a same-uuid `get ` re-run rewrites neither the manifest nor the blobs. Semantics: -* **Hosted** (`get GHSA-… --mode hosted`): resolves the advisory, then hands the selected (purl, uuid) pairs to scan's hosted engine — reference grants, cross-mode takeover pre-revert, lockfile rewrite, `redirect-state.json` ledger (merge-never-clobber), gem stale-install probe, warnings, confirmation rules (cargo via `confirmed_cargo_uuids` only) all identical to `scan --mode hosted`, and (v5.0) under the same `apply.lock` acquisition — taken around the first wet write, never on `--dry-run` or when nothing would be written; a failed acquire folds as top-level `errorCode: "lock_held" | "lock_io"` + string `error` (exit 1), and `--dry-run` under a held lock still exits 0. **No manifest write, no blobs** — the ledger is the persistence. JSON: get's legacy envelope gains the same nested `redirect` sub-object as scan's (`{mode:"hosted", redirected, rewrittenFiles, skipped, warnings, dryRun}`); the top-level shape is `{status, found, patches:[], warnings?}` — `downloaded`/`applied` are absent (nothing is downloaded into `.socket/`). Exit codes follow scan's hosted semantics: skipped grants and rewriter warnings never flip the exit; infra errors (reference fetch, corrupt/unwritable ledger, file writes) exit 1. Human prompt: `Redirect N packages to the hosted patch server?` (singular for one; get keeps its confirm gate, `--yes`/`--json`/non-TTY auto-accept as usual; as of v5.0 human `scan --mode hosted` prompts too — see the hosted section above). +* **Hosted** (`get GHSA-… --mode hosted`): resolves the advisory, then hands the selected (purl, uuid) pairs to scan's hosted engine — reference grants, cross-mode takeover pre-revert, lockfile rewrite, `redirect-state.json` ledger (merge-never-clobber), gem stale-install probe, warnings, confirmation rules (cargo via `confirmed_cargo_uuids`, golang via `confirmed_golang_uuids` only) all identical to `scan --mode hosted`, and (v5.0) under the same `apply.lock` acquisition — taken around the first wet write, never on `--dry-run` or when nothing would be written; a failed acquire folds as top-level `errorCode: "lock_held" | "lock_io"` + string `error` (exit 1), and `--dry-run` under a held lock still exits 0. **No manifest write, no blobs** — the ledger is the persistence. JSON: get's legacy envelope gains the same nested `redirect` sub-object as scan's (`{mode:"hosted", redirected, rewrittenFiles, skipped, warnings, dryRun}`); the top-level shape is `{status, found, patches:[], warnings?}` — `downloaded`/`applied` are absent (nothing is downloaded into `.socket/`). Exit codes follow scan's hosted semantics: skipped grants and rewriter warnings never flip the exit; infra errors (reference fetch, corrupt/unwritable ledger, file writes) exit 1. Human prompt: `Redirect N packages to the hosted patch server?` (singular for one; get keeps its confirm gate, `--yes`/`--json`/non-TTY auto-accept as usual; as of v5.0 human `scan --mode hosted` prompts too — see the hosted section above). * **Vendored** (`get GHSA-… --mode vendored`): the download phase is scan's vendored posture — **manifest-free (v5.0)**: the selected records are fetched into memory (`download_patch_records`; blobs held in memory; nothing under `.socket/` is written; the nested apply never runs), then scan's vendor step runs under the apply lock over exactly the selected records, like `scan --mode vendored` (no whole-manifest scope and no `[note]` about other records — that blast radius is retired with the manifest; a legacy manifest record for a vendored purl is migrated out of `.socket/manifest.json` the same way scan does it). JSON: get's envelope takes the detached download envelope's shape — `{status, found, downloaded, skipped, failed, detached: true, patches: [{purl, uuid, action: "downloaded" | "skipped" | "failed", …}], warnings?}` (`applied` is absent; `detached: true` is pinned; a `downloaded` record for a purl the vendor ledger holds at another uuid carries the additive `oldUuid`, derived from the ledger — the human `[fetch]` line reads ` (replacing )`) — and gains the nested `vendor` Envelope exactly like scan's `result["vendor"]`; a vendor-step error folds the partial envelope + `{status:"error", error:{code,message}}` in (a pre-failure takeover reconcile may have already mutated the ledger — its events must reach the consumer). Exit: download failures or vendor `has_errors` → `partial_failure`/1. Human prompt: `Download and vendor N patches?`; `--dry-run` prints `[dry-run] Would download and vendor N patches. No changes made.` on both identifier paths (uuid and search). Telemetry mirrors scan's vendored arms (`track_outcomes_for_vendor` / `track_patch_vendor_failed`). **Bun vendored preflight (additive)** — shared by `get --mode vendored` on both its paths and `scan --mode vendored`: before ANY patch download, and only when the selection holds a `pkg:npm/` purl, the download phase reads `bun.lock`/`bun.lockb` once (`preflight_vendor`) and, when the vendor backend would refuse the project — a malformed, unreadable or unsupported `bun.lockb` → `vendor_bun_lockb_invalid`; an unreadable `bun.lock` → `vendor_lockfile_missing`; a `lockfileVersion` other than 0/1/2 or a non-canonical `packages` grammar → `vendor_lockfile_version_unsupported`; `workspace:` packages in a lock below version 2 → `vendor_bun_workspace_unsupported` — every `pkg:npm/` result becomes `{action:"failed", errorCode:, error:}` with NO fetch (the patch view is never requested) and no patch record; other ecosystems' results are untouched. **Search path** (`get --mode vendored`) and `scan --mode vendored`: the records ride `patches[]` / `download.patches[]` with `downloaded: 0`, the download phase writes nothing under `.socket/` (v5.0 — a pre-existing `.socket/manifest.json`, including a record seeded for another purl, is left byte-untouched; previously the run re-serialized the manifest), the vendor step still runs over the remaining records (no event for the refused purl), exit `partial_failure`/1. **uuid path** (`get --mode vendored`): the uuid lookup is the only fetch; the run exits 1 BEFORE the vendor step with exactly `{status:"error", found:1, downloaded:0, skipped:0, failed:1, error:{code, message}, patches:[{purl, uuid, action:"failed", errorCode, error}]}` (the `error` OBJECT is the vendored-mode error shape of the vendor-step fold-in above) and writes nothing — no `.socket/` on a fresh project; human mode prints `Error (): ` on stderr. **Already-vendored exemption**: a purl is exempt from the workspace refusal only when every instance of its `name@version` in `bun.lock` is already a `.socket/vendor/npm/…` local tuple (any uuid; the digest-less 2-tuple counts) — the engine's own criterion — so in-sync re-runs, `repair`, and a superseding patch uuid on a project vendored before it grew a workspace member all flow to the engine (re-pinning an already-local tuple adds no workspace-relative exposure); a wiped ledger alone is not a refusal (the engine path decides). UUID equality in the ledger alone never exempts a purl: `rollback --preserve-state` retains its record after unwiring. Dry-run refusal takes priority over `already_vendored`. **Unreadable vendor ledger**: a `.socket/vendor/state.json` the preflight cannot read or parse is itself the refusal — `vendor_state_unreadable` with the io/parse detail, fail-closed (nothing is exempt) — on the uuid path, the search / `scan` path and the `--dry-run` preview alike; never a Bun lock code. **`--silent`** is "errors only" and never mutes the refusal: the code-tagged `[error] (): ` (per-patch paths) / `Error (): …` (uuid path) line stays on stderr with an empty stdout. **`--dry-run`** previews the refusal as the additive `would_refuse` action (see `--dry-run` below). Agent-mode `get --save-only` is NOT preflighted (record-only intent has no consumption precondition). Pinned by `tests/in_process_vendor_bun.rs` (exact uuid-path envelope, seeded-manifest survival, `--silent`, `--dry-run`) and `tests/scan_vendor_e2e.rs`. * **Installed-version narrowing** (all modes, `get`'s search path): a CVE/GHSA fan-out returns one patch record per patched VERSION; get keeps only versions present here and emits calm `skipped` records (`errorCode: "package_not_installed"`) for the rest — never an error exit. Presence = installed on disk (qualified-aware resolver) ∪ already tracked in the manifest (record maintenance keeps working on hosts without an installed copy); hosted/vendored modes additionally count lockfile-resolved deps and vendor-ledger purls (mirroring scan's discovery supplements, including their `--global` gate). **Exempt** (no narrowing): UUID identifiers, exact-versioned PURL identifiers (explicit intent), `--save-only` runs (record-only has no installation precondition — the fresh-clone record→vendor flow keeps working), `--all-releases`, and the package-name path (already installed-derived). When EVERY found patch is filtered out, get exits 0 with the additive status **`not_installed`** (`{status:"not_installed", found:N, downloaded:0, applied:0, patches:[], warnings?}`) — never `no_match`, which remains pinned to the fuzzy package-name path. PnP layouts are surfaced, not misreported: yarn-PnP npm results skip with `errorCode: "yarn_pnp_unsupported"` in every mode; pnpm-PnP skips carry `pnpm_pnp_unsupported` in agent/vendored modes; hosted mode — the refusal's own remedy — keeps ONLY the versions the raw `pnpm-lock.yaml` text actually resolves (boundary-anchored probe over the v5/v6/v9 key spellings, so a large fan-out never requests grants for every version ever patched), labels a JUDGED miss `package_not_installed` exactly like a non-PnP project (the layout blocked nothing — the lock was read and the version isn't resolved), and reserves the layout code for an unreadable lock (no judgment possible). When EVERY narrowed-out result is a PnP refusal, the human terminal names the layout instead of claiming "not installed" and never advises `--all-releases` (which cannot make PnP patchable); the JSON status stays `not_installed` — consumers dispatch on the per-record `errorCode`. Hosted mode also runs the per-release VARIANT filter (`filter_to_installed_releases`) on its search path before requesting grants — agent/vendored runs get it inside the download engines — with the same keep-all-plus-warning fallbacks (surfaced as `(release_narrowing)`-prefixed strings in `warnings[]`). An ecosystem this binary has no crawler for is likewise never judged: its results are KEPT (absence from a crawl that never looked carries no information — the same fail-safe as scan's prune GC). The human `Found N patches:` listing shows only the patches whose package version survived the narrowing (the narrowing is judged over every result, so an installed package's paid fix a free user cannot download still lists as `[PAID] (no access)`, while skip records and counts cover only accessible patches), sorted by PURL in natural version order (`4.17.2` before `4.17.10`); the narrowed-out ones are summarized on stderr in one line per reason (`Skipped N patches for M package versions not installed here (use --all-releases to include them).`), and `--verbose` adds one `[skip] ()` line per skipped version after that summary, in natural version order. When the candidates hold more patches than were selected and the pick was made without a menu (a paid user's auto-pick, `--yes`, a non-TTY run), a `Selected:` block names the patch (purl, tier, short uuid, advisories) that will be installed before the prompt. Machine output (the prompt count, the JSON envelope) uses the kept set, unchanged. The finer per-release variant narrowing (`filter_to_installed_releases`) is unchanged and still runs inside the download engines (and before an agent-mode `--dry-run` preview, so the preview names only the variants a wet run would fetch). * **Deliberate divergences from scan** (documented, not drift): get keeps its `selection_required` JSON posture for free multi-patch PURLs (scan auto-picks); get has no `--vex` (an ambient `SOCKET_VEX` is ignored by get's modes), no `--detached` (moot — `get --mode vendored` is manifest-free by construction), no `--prune`; get does not run scan's pre-confirm vendor baseline annotation; and an all-narrowed-out run exits `not_installed` without entering the vendor step (heal-after-wipe re-vendoring stays `scan --mode vendored`'s job). Agent-mode `get` honors `--dry-run` too (v5.x; it used to download, save and apply anyway): the search and uuid paths classify each selected patch against the manifest (read-only; an unreadable manifest fails closed like the wet run) and stop before the prompt, the download, any `.socket/` write and the apply — human `[would-add]` / `[would-update] … (replacing )` / `[skip] … (already in manifest)` lines then `[dry-run] Would download and apply N patches. No changes made.`; JSON `{status:"success", dryRun:true, found, downloaded:0, skipped, applied:0, patches:[{purl, uuid, action:"would_add"|"would_update"(+oldUuid)|"skipped"}, ], warnings?}`, exit 0. diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 57bf4130..d3de61d9 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -414,6 +414,7 @@ async fn try_local_go_apply( // `pkg_path` is the pristine, case-encoded module-cache dir; `module`/ // `version` are the decoded PURL components keying the copy + `replace`. let (module, version) = parse_golang_purl(purl)?; + let (module, version) = (&*module, &*version); Some( apply_go_redirect( purl, diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 5584c409..f8159e1e 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -947,10 +947,9 @@ pub(crate) async fn run_redirect_selected( /// records and the confirmation probe key on; everything the probe /// needs AFTER the rewrite to decide whether the dep was actually /// redirected (artifact URL, registry index URL, fail-closed maven's - /// suffixed version, golang's content-addressed module path) already - /// rides the override. The single vector is filtered in place by every - /// withhold/refusal step, and the rewriters' `overrides` slice is - /// materialized from it once, after the last filter. + /// suffixed version) already rides the override. The single vector is + /// filtered in place by every withhold/refusal step, and the rewriters' + /// `overrides` slice is materialized from it once, after the last filter. struct Candidate { purl: String, dep: DepOverride, @@ -1176,7 +1175,10 @@ pub(crate) async fn run_redirect_selected( // VENDORED — for cargo a committed `[patch.crates-io]` path entry, a // detached Cargo.lock entry, a committed copy, and a vendored ledger // entry; for the npm family a `file:./.socket/vendor/…` lock resolution - // (plus a berry `resolutions` pin) and its committed tarball. The hosted + // (plus a berry `resolutions` pin) and its committed tarball; for golang + // the vendor-owned go.mod `replace`, its committed module copy, and its + // ledger entry (left behind, a later `vendor` run takes the module back + // and the modes flip-flop). The hosted // rewriters know nothing about that wiring: cargo then refuses every // `--locked` build over the now-unused `[patch]` entry while this run // reports success, and the npm rewriters either hijack the vendored @@ -1192,7 +1194,9 @@ pub(crate) async fn run_redirect_selected( // whose vendored state cannot be cleanly reverted (revert failure, or // vendored wiring with a missing/corrupt ledger) is REFUSED — skipped // with an actionable error — never half-migrated. - let takeover_capable = |p: &str| p.starts_with("pkg:cargo/") || p.starts_with("pkg:npm/"); + let takeover_capable = |p: &str| { + p.starts_with("pkg:cargo/") || p.starts_with("pkg:npm/") || p.starts_with("pkg:golang/") + }; let mut takeover_pre_warnings: Vec = Vec::new(); // Dry-run takeover previews: `(purl, uuid)` pairs whose vendored state // the wet run would revert and then redirect. Withheld from the @@ -2010,17 +2014,20 @@ pub(crate) async fn run_redirect_selected( if purl.starts_with("pkg:cargo/") { return rewrite.confirmed_cargo_uuids.contains(uuid); } + // Golang likewise: the goproxy `indexUrl` is the bare + // patch-server origin (present in any other hosted lock), and + // the socket module's go.sum lines outlive a removed replace. + if purl.starts_with("pkg:golang/") { + return rewrite.confirmed_golang_uuids.contains(uuid); + } // The override's own targets: artifact URL; per-dependency // registry index URL; fail-closed maven's globally-unique - // `-socket.` suffixed version (never the `.pom` URL); - // golang's content-addressed `patch.socket.dev/gopatch/` - // module path (go.mod + go.sum never carry a URL). + // `-socket.` suffixed version (never the `.pom` URL). let artifact_url = c.dep.artifact_url.as_str(); let registry = c.dep.registry_override.as_ref(); let index_url = registry.map(|o| o.index_url.as_str()); let suffixed_version = registry.and_then(|o| o.identifiers.maven_suffixed_version.as_deref()); - let go_module_path = registry.and_then(|o| o.identifiers.go_module_path.as_deref()); let encoded = socket_patch_core::utils::uri::encode_uri_component(artifact_url); final_texts.iter().any(|text| { // The rewriters' own predicate — raw, or the `\/`-escaped @@ -2036,7 +2043,6 @@ pub(crate) async fn run_redirect_selected( || text.contains(encoded.as_str()) || index_url.is_some_and(|iu| text.contains(iu)) || suffixed_version.is_some_and(|sv| text.contains(sv)) - || go_module_path.is_some_and(|gm| text.contains(gm)) }) }) .map(|c| (c.purl.clone(), c.dep.patch_uuid.clone())) diff --git a/crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs b/crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs new file mode 100644 index 00000000..06b39bdd --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs @@ -0,0 +1,298 @@ +#![cfg(unix)] +//! Hosted golang redirects must claim (and attest) exactly what the +//! committed go.mod/go.sum enforce, and a vendored→hosted mode switch must +//! leave the project fully hosted. +//! +//! Drives the real binary (`get --mode hosted`) against a wiremock +//! patch API. No go toolchain is needed: every assertion is on the files +//! the CLI writes and on its JSON envelope. + +use std::path::Path; + +#[path = "common/mod.rs"] +mod common; + +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const ORG: &str = "test-org"; +const UMOD: &str = "example.com/upstream"; +const UVER: &str = "v1.0.0"; +const UPURL: &str = "pkg:golang/example.com/upstream@v1.0.0"; +const UUID_H: &str = "55555555-5555-4555-8555-555555555555"; +const UUID_V: &str = "3c4d5e6f-7081-4a1b-8c2d-0123456789ab"; +const SVER: &str = "v1.0.0-socketpatch.1"; +const ZIP_H1: &str = "h1:mU9vN/n1hbXktM62lJ6MbRKOk3aI8NDH+szCf62RXtE="; +const GOMOD_H1: &str = "h1:XgagPTRZSCprrzR+3Ro36/XJpibdovhAbsKThYI8bxg="; +const UPSTREAM_SUM: &str = "example.com/upstream v1.0.0 h1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n\ + example.com/upstream v1.0.0/go.mod h1:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\n"; +const PRISTINE_LIB: &str = "package upstream\n\nfunc Greeting() string { return \"PRISTINE\" }\n"; +const PATCHED_LIB: &str = "package upstream\n\nfunc Greeting() string { return \"PATCHED\" }\n"; +/// The goproxy override's `indexUrl` is the bare patch-server origin. +const INDEX_URL: &str = "https://patch.socket.dev"; + +fn smod() -> String { + format!("patch.socket.dev/gopatch/{UUID_H}") +} + +async fn mount_hosted_grant(server: &MockServer) { + let artifact_url = format!("{INDEX_URL}/{}/@v/{SVER}.zip", smod()); + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID_H}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": UUID_H, + "purl": UPURL, + "publishedAt": "2026-01-01T00:00:00Z", + "files": { "lib.go": { + "beforeHash": compute_git_sha256_from_bytes(PRISTINE_LIB.as_bytes()), + "afterHash": compute_git_sha256_from_bytes(PATCHED_LIB.as_bytes()), + }}, + "vulnerabilities": { "GHSA-gogo-host-stat": { + "cves": ["CVE-2026-4243"], "summary": "s", "severity": "high", "description": "d", + }}, + "description": "golang hosted state fixture", + "license": "MIT", + "tier": "free", + }))) + .mount(server) + .await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "results": { UUID_H: { + "status": "granted", + "url": &artifact_url, + "purl": UPURL, + "artifacts": [{ "kind": "tarball", "url": &artifact_url, "integrity": {} }], + "registryOverride": { + "kind": "goproxy", + "indexUrl": INDEX_URL, + "identifiers": { + "name": UMOD, + "version": UVER, + "goModulePath": smod(), + "goModuleVersion": SVER, + "goZipDirhashH1": ZIP_H1, + "goModH1": GOMOD_H1, + } + } + }} + }))) + .mount(server) + .await; +} + +fn get_hosted(consumer: &Path, server: &MockServer, modcache: &Path) -> serde_json::Value { + let (code, stdout, stderr) = common::run_with_env( + consumer, + &[ + "get", + UUID_H, + "--mode", + "hosted", + "--json", + "--yes", + "--cwd", + consumer.to_str().unwrap(), + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake", + ], + &[("GOMODCACHE", modcache.to_str().unwrap())], + ); + assert_eq!( + code, 0, + "get --mode hosted failed\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + serde_json::from_str(&stdout).unwrap_or_else(|e| panic!("not JSON: {e}\n{stdout}")) +} + +fn write_consumer(consumer: &Path, go_mod_tail: &str, go_sum: &str) { + std::fs::create_dir_all(consumer).unwrap(); + std::fs::write( + consumer.join("go.mod"), + format!("module example.com/consumer\n\ngo 1.21\n\n{go_mod_tail}"), + ) + .unwrap(); + std::fs::write(consumer.join("go.sum"), go_sum).unwrap(); +} + +/// A polyglot repo whose npm lock is already hosted-redirected contains the +/// bare patch-server origin. A golang dep whose go.mod rewrite was REFUSED +/// must not be confirmed by that unrelated text: nothing redirects it, so it +/// must not be counted, recorded in the redirect ledger, or attested by VEX. +#[tokio::test(flavor = "multi_thread")] +async fn refused_go_rewrite_is_not_confirmed_by_another_lockfile() { + let tmp = tempfile::tempdir().unwrap(); + let consumer = tmp.path().join("consumer"); + write_consumer( + &consumer, + &format!("require {UMOD} {UVER}\n\nreplace {UMOD} {UVER} => ../my-fork\n"), + UPSTREAM_SUM, + ); + std::fs::write( + consumer.join("package-lock.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "name": "consumer", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { "name": "consumer", "dependencies": { "left-pad": "1.3.0" } }, + "node_modules/left-pad": { + "version": "1.3.0", + "resolved": format!("{INDEX_URL}/patch-registry/npm/left-pad/-/left-pad-1.3.0.tgz"), + "integrity": "sha512-AAAA", + } + } + })) + .unwrap(), + ) + .unwrap(); + let server = MockServer::start().await; + mount_hosted_grant(&server).await; + + let env = get_hosted(&consumer, &server, &tmp.path().join("modcache")); + assert_eq!(env["redirect"]["redirected"], 0, "envelope: {env}"); + assert!( + env["redirect"]["warnings"] + .as_array() + .unwrap() + .iter() + .any(|w| w["code"] == "redirect_golang_replace_conflict"), + "the refusal is reported: {env}" + ); + let ledger = std::fs::read_to_string(consumer.join(".socket/vendor/redirect-state.json")) + .unwrap_or_default(); + assert!( + !ledger.contains(UPURL), + "no redirect record for the refused module: {ledger}" + ); +} + +/// go.sum lines for the socket module outlive a hand-removed replace. When +/// the rewrite is refused (the graph moved to another version), those +/// leftover lines pin nothing and must not confirm the dep. +#[tokio::test(flavor = "multi_thread")] +async fn leftover_go_sum_lines_do_not_confirm_a_refused_rewrite() { + let tmp = tempfile::tempdir().unwrap(); + let consumer = tmp.path().join("consumer"); + write_consumer( + &consumer, + &format!("require {UMOD} v1.1.0\n"), + &format!( + "{UMOD} v1.1.0 h1:CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=\n\ + {UMOD} v1.1.0/go.mod h1:DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=\n\ + {} {SVER} {ZIP_H1}\n{} {SVER}/go.mod {GOMOD_H1}\n", + smod(), + smod() + ), + ); + let server = MockServer::start().await; + mount_hosted_grant(&server).await; + + let env = get_hosted(&consumer, &server, &tmp.path().join("modcache")); + assert_eq!(env["redirect"]["redirected"], 0, "envelope: {env}"); + assert!( + env["redirect"]["warnings"] + .as_array() + .unwrap() + .iter() + .any(|w| w["code"] == "redirect_golang_version_mismatch"), + "{env}" + ); + let ledger = std::fs::read_to_string(consumer.join(".socket/vendor/redirect-state.json")) + .unwrap_or_default(); + assert!(!ledger.contains(UPURL), "{ledger}"); +} + +/// vendored → hosted takeover: the hosted run must revert the vendored +/// state (ledger entry, committed copy, marker) before redirecting, so the +/// project is fully hosted — never a hosted go.mod beside a vendor ledger +/// that still claims the module. +#[tokio::test(flavor = "multi_thread")] +async fn hosted_takeover_of_vendored_module_removes_vendored_state() { + let tmp = tempfile::tempdir().unwrap(); + let consumer = tmp.path().join("consumer"); + write_consumer(&consumer, &format!("require {UMOD} {UVER}\n"), UPSTREAM_SUM); + let modcache = tmp.path().join("modcache"); + let module_dir = modcache.join(format!("{UMOD}@{UVER}")); + std::fs::create_dir_all(&module_dir).unwrap(); + std::fs::write( + module_dir.join("go.mod"), + format!("module {UMOD}\n\ngo 1.21\n"), + ) + .unwrap(); + std::fs::write(module_dir.join("lib.go"), PRISTINE_LIB).unwrap(); + + let socket = consumer.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let after = compute_git_sha256_from_bytes(PATCHED_LIB.as_bytes()); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "patches": { UPURL: { + "uuid": UUID_V, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { "lib.go": { + "beforeHash": compute_git_sha256_from_bytes(PRISTINE_LIB.as_bytes()), + "afterHash": &after, + }}, + "vulnerabilities": {}, + "description": "vendored first", + "license": "MIT", + "tier": "free", + }} + })) + .unwrap(), + ) + .unwrap(); + std::fs::write(socket.join("blobs").join(&after), PATCHED_LIB).unwrap(); + + let (code, stdout, stderr) = common::run_with_env( + &consumer, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + consumer.to_str().unwrap(), + ], + &[("GOMODCACHE", modcache.to_str().unwrap())], + ); + assert_eq!( + code, 0, + "vendor failed\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let vendor_dir = consumer.join(format!(".socket/vendor/golang/{UUID_V}")); + assert!(vendor_dir.is_dir(), "precondition: module vendored"); + let state_path = consumer.join(".socket/vendor/state.json"); + assert!( + std::fs::read_to_string(&state_path) + .unwrap() + .contains(UPURL), + "precondition: vendor ledger records the module" + ); + + let server = MockServer::start().await; + mount_hosted_grant(&server).await; + let env = get_hosted(&consumer, &server, &modcache); + assert_eq!(env["redirect"]["redirected"], 1, "envelope: {env}"); + + let go_mod = std::fs::read_to_string(consumer.join("go.mod")).unwrap(); + assert_eq!( + go_mod.matches(&format!("{UMOD} {UVER} =>")).count(), + 1, + "{go_mod}" + ); + assert!(go_mod.contains(&format!("replace {UMOD} {UVER} => {} {SVER}", smod()))); + assert!(!vendor_dir.exists(), "the vendored copy is removed"); + let state = std::fs::read_to_string(&state_path).unwrap_or_default(); + assert!( + !state.contains(UPURL), + "the vendor ledger no longer claims the module: {state}" + ); +} diff --git a/crates/socket-patch-core/src/crawlers/go_crawler.rs b/crates/socket-patch-core/src/crawlers/go_crawler.rs index 96bac1bd..98d5f137 100644 --- a/crates/socket-patch-core/src/crawlers/go_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/go_crawler.rs @@ -181,6 +181,7 @@ impl GoCrawler { for purl in purls { if let Some((module_path, version)) = crate::utils::purl::parse_golang_purl(purl) { + let (module_path, version) = (&*module_path, &*version); // SECURITY: `module_path`/`version` come straight from the // (untrusted) manifest PURL and are joined onto the cache root // below. In global mode the resolved directory is patched IN @@ -945,6 +946,31 @@ mod tests { assert!(result.is_none(), "empty version must yield None"); } + /// Canonical purls percent-encode `+` in a version + /// (`v2.0.0%2Bincompatible`); the lookup must decode it before + /// case-escaping, or a `+incompatible` module is never found. + #[tokio::test] + async fn test_find_by_purls_percent_encoded_version() { + let dir = tempfile::tempdir().unwrap(); + let module_dir = dir + .path() + .join("github.com") + .join("foo") + .join("bar@v2.0.0+incompatible"); + tokio::fs::create_dir_all(&module_dir).await.unwrap(); + + let crawler = GoCrawler::new(); + let purl = "pkg:golang/github.com/foo/bar@v2.0.0%2Bincompatible".to_string(); + let result = crawler + .find_by_purls(dir.path(), std::slice::from_ref(&purl)) + .await + .unwrap(); + + assert_eq!(result.len(), 1, "encoded purl must resolve: {result:?}"); + assert_eq!(result[&purl].version, "v2.0.0+incompatible"); + assert_eq!(result[&purl].path, module_dir); + } + #[tokio::test] async fn test_find_by_purls_qualified_purl_keys_by_input() { // A PURL carrying `?` qualifiers must still resolve the on-disk diff --git a/crates/socket-patch-core/src/patch/redirect/golang_local.rs b/crates/socket-patch-core/src/patch/redirect/golang_local.rs index d72efc08..84c0dea5 100644 --- a/crates/socket-patch-core/src/patch/redirect/golang_local.rs +++ b/crates/socket-patch-core/src/patch/redirect/golang_local.rs @@ -325,6 +325,7 @@ pub async fn remove_go_redirect( format!("not a golang purl: {purl}"), ) })?; + let (module, version) = (&*module, &*version); // SECURITY: the copy dir is `/@/` and is about // to be `remove_tree`d. Unsafe coordinates (`..` segment / separator / @@ -360,7 +361,7 @@ pub async fn reconcile_go_redirects( desired: &HashSet, dry_run: bool, ) -> Vec { - let desired_modules: HashSet<&str> = desired + let desired_modules: HashSet> = desired .iter() .filter_map(|p| parse_golang_purl(p).map(|(m, _)| m)) .collect(); @@ -407,6 +408,7 @@ pub async fn reconcile_go_redirects( // Path-exact on purpose: a directive already repointed at the // desired version's copy is never touched. if let Some((module, version)) = parse_golang_purl(&purl) { + let (module, version) = (&*module, &*version); let target = replace_target_path(GO_PATCHES_DIR, module, version); if entries.iter().any(|e| { e.owner == Some(ReplaceOwner::GoPatches) @@ -456,7 +458,7 @@ pub async fn verify_go_redirect_state( // Required versions from go.mod (None ⇒ no go.mod ⇒ skip the version // cross-check). Read once, project-local, offline. let required = read_required_versions(project_root).await; - let desired_modules: HashSet<&str> = desired + let desired_modules: HashSet> = desired .iter() .filter_map(|p| parse_golang_purl(p).map(|(m, _)| m)) .collect(); @@ -465,6 +467,7 @@ pub async fn verify_go_redirect_state( let Some((module, version)) = parse_golang_purl(purl) else { continue; }; + let (module, version) = (&*module, &*version); let Some(record) = manifest.patches.get(purl) else { continue; }; @@ -781,7 +784,7 @@ mod tests { let gomod = "module example.com/app\n\ngo 1.21\n\n\ require github.com/foo/bar v1.4.2\n\n\ replace github.com/foo/bar v1.4.2 => \ - patch.socket.dev/gopatch/some-uuid v1.4.2-socketpatch.1\n"; + patch.socket.dev/gopatch/55555555-5555-4555-8555-555555555555 v1.4.2-socketpatch.1\n"; tokio::fs::write(root.join("go.mod"), gomod).await.unwrap(); let sources = PatchSources::blobs_only(&blobs); @@ -1855,8 +1858,8 @@ mod tests { let (module, version) = parse_golang_purl(qualified).unwrap(); let result = apply_go_redirect( qualified, - module, - version, + &module, + &version, &pristine, root, GO_PATCHES_DIR, @@ -2147,7 +2150,7 @@ mod tests { "module example.com/app\n\ngo 1.21\n\n\ require github.com/foo/bar v1.4.2\n\n\ replace github.com/foo/bar v1.4.2 => \ - patch.socket.dev/gopatch/some-uuid v1.4.2-socketpatch.1\n", + patch.socket.dev/gopatch/55555555-5555-4555-8555-555555555555 v1.4.2-socketpatch.1\n", ) .await .unwrap(); diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index bce1ecdf..d10e1b3e 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -182,6 +182,13 @@ pub struct RewriteResult { /// presence in rewritten files (a `[registries.…]` config block alone /// pins nothing). pub confirmed_cargo_uuids: std::collections::BTreeSet, + /// Patch uuids whose golang redirect landed: the go.mod replace plus the + /// socket module's go.sum pair, written by this run or already in place. + /// Like cargo, hosted confirmation keys off this set — substring presence + /// cannot prove it (the goproxy `indexUrl` is the bare patch-server + /// origin, which any other hosted lockfile contains, and go.sum lines + /// outlive a removed replace). + pub confirmed_golang_uuids: std::collections::BTreeSet, pub confirmed_pipenv_uuids: std::collections::BTreeSet, pub refused_pipenv_uuids: std::collections::BTreeSet, /// Patch uuids whose `pdm.lock` redirect fully landed (written by this run @@ -5186,12 +5193,12 @@ fn rewrite_golang( // Fail closed on a module path outside the socket namespace: the // prefix is the ONLY ownership signal — a directive we couldn't // recognize later would be unremovable, and go.sum removal keys on it. - if !rhs_module.starts_with(HOSTED_GO_MODULE_PREFIX) { + if !go_mod_edit::is_hosted_module_path(rhs_module) { result.warnings.push(RewriteWarning { code: "redirect_golang_untrusted_module_path".into(), detail: format!( - "{fname}@{}: refusing hosted module path `{rhs_module}` outside \ - `{HOSTED_GO_MODULE_PREFIX}`", + "{fname}@{}: refusing hosted module path `{rhs_module}`: not \ + `{HOSTED_GO_MODULE_PREFIX}`", dep.version ), }); @@ -5262,7 +5269,8 @@ fn rewrite_golang( // left in place, its module path keeps confirming the dep as // redirected (ledger + VEX attestation) while go links the unpatched // version. - if let Some(required) = go_mod_edit::parse_required_versions(&go_mod).get(&fname) { + let required = go_mod_edit::parse_required_versions(&go_mod); + if let Some(required) = required.get(&fname) { if required != &dep.version { result.warnings.push(RewriteWarning { code: "redirect_golang_version_mismatch".into(), @@ -5311,6 +5319,25 @@ fn rewrite_golang( } continue; } + } else if !go_sum_edit::has_module_version(&go_sum, &fname, &dep.version) + && prior + .as_ref() + .is_none_or(|e| e.version.as_deref() != Some(dep.version.as_str())) + { + // Not required, not in go.sum at this version, and not already + // redirected by us: the module is outside this project's graph + // (local discovery crawls the whole module cache). Its replace + // would be inert, and confirming it would attest a patch no + // build links. + result.warnings.push(RewriteWarning { + code: "redirect_golang_not_in_module_graph".into(), + detail: format!( + "{fname}@{}: not required by go.mod and absent from go.sum — the \ + module is not in this project's build graph; nothing redirected", + dep.version + ), + }); + continue; } match go_mod_edit::upsert_hosted_replace_entry( @@ -5387,6 +5414,7 @@ fn rewrite_golang( new: None, }); } + result.confirmed_golang_uuids.insert(dep.patch_uuid.clone()); } if mod_changed { @@ -11640,6 +11668,80 @@ packages: assert!(out.files["go.mod"].contains("replace github.com/foo/bar v1.4.2 =>")); } + /// Local-mode discovery crawls the WHOLE module cache, so a module another + /// project downloaded can be granted here. Absent from `require` AND from + /// go.sum at the patched version, it is not in this module's graph: a + /// replace for it is inert, and confirming it would attest a patch no + /// build links. + #[test] + fn golang_module_outside_the_graph_is_refused() { + let mut files = golang_files(); + files.insert( + "go.mod".to_string(), + "module example.com/app\n\ngo 1.21\n\nrequire example.com/direct v2.0.0\n".to_string(), + ); + files.insert( + "go.sum".to_string(), + "example.com/direct v2.0.0 h1:DIRECT=\nexample.com/direct v2.0.0/go.mod h1:DIRECTM=\n\ + github.com/foo/bar v1.3.0/go.mod h1:OLDER=\n" + .to_string(), + ); + let ovr = golang_override(); + let out = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert!(out.files.is_empty(), "nothing written: {:?}", out.files); + assert_eq!( + out.warnings + .iter() + .map(|w| w.code.as_str()) + .collect::>(), + ["redirect_golang_not_in_module_graph"] + ); + } + + /// Hosted confirmation keys off `confirmed_golang_uuids`: set when the + /// redirect lands or is already in place, never for a refused dep. + #[test] + fn golang_confirms_only_landed_redirects() { + let files = golang_files(); + let ovr = golang_override(); + let first = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert!(first.confirmed_golang_uuids.contains(GO_UUID)); + let mut again = files.clone(); + again.extend(first.files.clone()); + let second = rewrite_registry_redirect(&again, std::slice::from_ref(&ovr)); + assert!(second.files.is_empty()); + assert!(second.confirmed_golang_uuids.contains(GO_UUID)); + + let mut conflict = golang_files(); + conflict.insert( + "go.mod".to_string(), + "module example.com/app\n\ngo 1.21\n\nrequire github.com/foo/bar v1.4.2\n\nreplace github.com/foo/bar v1.4.2 => ../my-fork\n" + .to_string(), + ); + let refused = rewrite_registry_redirect(&conflict, std::slice::from_ref(&ovr)); + assert!(refused.confirmed_golang_uuids.is_empty()); + } + + /// Only `patch.socket.dev/gopatch/` is a hosted module; + /// anything deeper would be written but never recognized as ours again. + #[test] + fn golang_module_path_with_extra_segments_refused() { + let files = golang_files(); + let mut ovr = golang_override(); + ovr.registry_override + .as_mut() + .unwrap() + .identifiers + .go_module_path = Some(format!("{}/extra", golang_socket_module())); + let out = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert!(out.files.is_empty()); + assert_eq!( + out.warnings[0].code, + "redirect_golang_untrusted_module_path" + ); + assert!(out.confirmed_golang_uuids.is_empty()); + } + #[test] fn golang_user_authored_replace_conflict_warns() { let mut files = golang_files(); diff --git a/crates/socket-patch-core/src/utils/purl.rs b/crates/socket-patch-core/src/utils/purl.rs index a453b056..a268251c 100644 --- a/crates/socket-patch-core/src/utils/purl.rs +++ b/crates/socket-patch-core/src/utils/purl.rs @@ -211,11 +211,17 @@ pub(crate) fn build_maven_purl(group_id: &str, artifact_id: &str, version: &str) format!("pkg:maven/{group_id}/{artifact_id}@{version}") } -/// Parse a Go module PURL to extract module path and version. +/// Parse a Go module PURL to extract module path and version, each +/// percent-decoded (the API's canonical purls spell `+incompatible` as +/// `%2Bincompatible`). /// /// e.g., `"pkg:golang/github.com/gin-gonic/gin@v1.9.1"` -> `Some(("github.com/gin-gonic/gin", "v1.9.1"))` -pub fn parse_golang_purl(purl: &str) -> Option<(&str, &str)> { - parse_name_version(purl, "pkg:golang/") +pub fn parse_golang_purl(purl: &str) -> Option<(Cow<'_, str>, Cow<'_, str>)> { + let (module, version) = parse_name_version(purl, "pkg:golang/")?; + Some(( + percent_decode_purl_component(module), + percent_decode_purl_component(version), + )) } /// Build a Go module PURL from components. @@ -560,17 +566,32 @@ mod tests { fn test_parse_golang_purl() { assert_eq!( parse_golang_purl("pkg:golang/github.com/gin-gonic/gin@v1.9.1"), - Some(("github.com/gin-gonic/gin", "v1.9.1")) + Some(("github.com/gin-gonic/gin".into(), "v1.9.1".into())) ); assert_eq!( parse_golang_purl("pkg:golang/golang.org/x/text@v0.14.0"), - Some(("golang.org/x/text", "v0.14.0")) + Some(("golang.org/x/text".into(), "v0.14.0".into())) ); assert_eq!(parse_golang_purl("pkg:npm/lodash@4.17.21"), None); assert_eq!(parse_golang_purl("pkg:golang/@v1.0.0"), None); assert_eq!(parse_golang_purl("pkg:golang/github.com/foo/bar@"), None); } + /// The API's canonical purls percent-encode `+` (`%2B`); every consumer + /// of the parsed coordinates (crawler lookup, go.mod replace, vendor + /// copy dir) needs the decoded `+incompatible` version Go uses. + #[test] + fn test_parse_golang_purl_percent_decodes() { + let (module, version) = + parse_golang_purl("pkg:golang/github.com/foo/bar@v2.0.0%2Bincompatible").unwrap(); + assert_eq!(module, "github.com/foo/bar"); + assert_eq!(version, "v2.0.0+incompatible"); + assert_eq!( + parse_golang_purl("pkg:golang/github.com/Azure/x@v1.0.0"), + Some(("github.com/Azure/x".into(), "v1.0.0".into())) + ); + } + #[test] fn test_build_golang_purl() { assert_eq!( @@ -791,7 +812,7 @@ mod tests { // version is split off. A trailing qualifier is ignored. assert_eq!( parse_golang_purl("pkg:golang/github.com/gin-gonic/gin@v1.9.1?type=module"), - Some(("github.com/gin-gonic/gin", "v1.9.1")) + Some(("github.com/gin-gonic/gin".into(), "v1.9.1".into())) ); } @@ -867,7 +888,7 @@ mod tests { // version must remain clean. assert_eq!( parse_golang_purl("pkg:golang/github.com/gin-gonic/gin@v1.9.1#middleware"), - Some(("github.com/gin-gonic/gin", "v1.9.1")) + Some(("github.com/gin-gonic/gin".into(), "v1.9.1".into())) ); } diff --git a/crates/socket-patch-core/src/vendor/go_mod_edit.rs b/crates/socket-patch-core/src/vendor/go_mod_edit.rs index b13bcaae..9d5fe7e7 100644 --- a/crates/socket-patch-core/src/vendor/go_mod_edit.rs +++ b/crates/socket-patch-core/src/vendor/go_mod_edit.rs @@ -9,11 +9,12 @@ //! //! ## Ownership model (no sidecar manifest) //! A `replace` directive is *socket-owned* iff its right-hand side is a -//! filesystem path under one of the two socket-managed prefixes: -//! `.socket/go-patches/` (the `apply` redirect backend, [`ReplaceOwner::GoPatches`]) -//! or `.socket/vendor/golang/` (the `vendor` backend, [`ReplaceOwner::Vendor`]) — -//! or a module path under the socket-hosted namespace -//! [`HOSTED_GO_MODULE_PREFIX`] (the `scan --mode hosted` backend, +//! filesystem path under one of the two socket-managed prefixes at the +//! project root: +//! `./.socket/go-patches/` (the `apply` redirect backend, [`ReplaceOwner::GoPatches`]) +//! or `./.socket/vendor/golang/` (the `vendor` backend, [`ReplaceOwner::Vendor`]) — +//! or exactly a socket-hosted module `patch.socket.dev/gopatch/` +//! ([`is_hosted_module_path`]; the `scan --mode hosted` backend, //! [`ReplaceOwner::Hosted`]). Any other module-to-module replacement //! (`=> example.com/fork v1.2.3`) or path is user-authored and is never //! modified or removed. The prefix is the entire ownership signal; there is no @@ -49,8 +50,9 @@ pub const GO_PATCHES_DIR: &str = ".socket/go-patches"; const GO_VENDOR_DIR: &str = ".socket/vendor/golang"; /// Module-path namespace of Socket's hosted patched Go modules. A -/// module-to-module `replace` whose RIGHT-hand module path starts with this -/// prefix is owned by [`ReplaceOwner::Hosted`] (`scan --mode hosted`). The +/// module-to-module `replace` whose RIGHT-hand module path is this prefix +/// plus a canonical patch uuid ([`is_hosted_module_path`]) is owned by +/// [`ReplaceOwner::Hosted`] (`scan --mode hosted`). The /// namespace is grant-free and content-addressed /// (`patch.socket.dev/gopatch/`): one build-once artifact per /// patch, fetchable anonymously over the standard GOPROXY protocol. This @@ -76,6 +78,10 @@ pub enum ReplaceOwner { /// for a user-authored path. The two prefixes don't overlap, but `Vendor` is /// tested first to keep the intent explicit (`.socket/vendor/golang/` is more /// specific than a hypothetical future `.socket/` catch-all). +/// +/// Anchored at the project root: the CLI only ever writes `.//…`, so a +/// user's replace onto another checkout's copy (`../sibling/.socket/…`, an +/// absolute path) is user-authored and never rewritten or dropped. pub(crate) fn detect_owner(path: &str) -> Option { let norm = path.replace('\\', "/"); let norm = norm.strip_prefix("./").unwrap_or(&norm); @@ -83,14 +89,23 @@ pub(crate) fn detect_owner(path: &str) -> Option { (ReplaceOwner::Vendor, GO_VENDOR_DIR), (ReplaceOwner::GoPatches, GO_PATCHES_DIR), ] { - let prefix = format!("{dir}/"); - if norm.starts_with(&prefix) || norm.contains(&format!("/{prefix}")) { + if norm.starts_with(&format!("{dir}/")) { return Some(owner); } } None } +/// True iff `module` is exactly a Socket-hosted patched module, +/// `patch.socket.dev/gopatch/` — the only shape the +/// server publishes (`gopatchModulePath`). A deeper path or a non-uuid leaf +/// under the namespace is not ours. +pub fn is_hosted_module_path(module: &str) -> bool { + module + .strip_prefix(HOSTED_GO_MODULE_PREFIX) + .is_some_and(crate::patch::path_safety::is_canonical_uuid) +} + /// The (project-root-relative) `replace` target path for a copy that lives at /// `/@`. Always `./`-prefixed and forward-slashed: /// Go treats a replacement target as a *filesystem path* only when it begins @@ -271,6 +286,17 @@ fn for_each_directive_body( Ok(()) } +/// A go.mod token with its quotes removed: the lexer accepts `"…"` and +/// `` `…` `` strings anywhere a module path or version may appear. +fn unquote(tok: &str) -> &str { + for q in ['"', '`'] { + if let Some(inner) = tok.strip_prefix(q).and_then(|t| t.strip_suffix(q)) { + return inner; + } + } + tok +} + /// True if a replacement RHS token is a filesystem path (vs a module path). /// Go's rule: a path begins with `./`, `../`, `/`, or a Windows drive/`\`. fn rhs_is_path(tok: &str) -> bool { @@ -287,8 +313,8 @@ fn rhs_is_path(tok: &str) -> bool { /// block). Returns `None` if there is no `=>` (not a replace body). fn parse_replace_body(body: &str) -> Option { let (lhs, rhs) = body.split_once("=>")?; - let lhs: Vec<&str> = lhs.split_whitespace().collect(); - let rhs: Vec<&str> = rhs.split_whitespace().collect(); + let lhs: Vec<&str> = lhs.split_whitespace().map(unquote).collect(); + let rhs: Vec<&str> = rhs.split_whitespace().map(unquote).collect(); let module = (*lhs.first()?).to_string(); let version = lhs.get(1).map(|s| s.to_string()); let first_rhs = rhs.first()?; @@ -300,9 +326,7 @@ fn parse_replace_body(body: &str) -> Option { // Module-to-module replacement: socket-owned iff the RHS module lives // in the hosted namespace (the sole ownership signal — see module doc). let m = (*first_rhs).to_string(); - let owner = m - .starts_with(HOSTED_GO_MODULE_PREFIX) - .then_some(ReplaceOwner::Hosted); + let owner = is_hosted_module_path(&m).then_some(ReplaceOwner::Hosted); (None, Some(m), rhs.get(1).map(|s| s.to_string()), owner) }; Some(ReplaceEntry { @@ -334,7 +358,7 @@ pub fn parse_replace_entries(content: &str) -> Vec { pub fn parse_required_versions(content: &str) -> HashMap { let mut out = HashMap::new(); let _ = for_each_directive_body(content, "require", |_, body| { - let mut toks = body.split_whitespace(); + let mut toks = body.split_whitespace().map(unquote); if let (Some(m), Some(v)) = (toks.next(), toks.next()) { out.insert(m.to_string(), v.to_string()); } @@ -372,7 +396,7 @@ pub fn upsert_hosted_replace_entry( rhs_module: &str, rhs_version: &str, ) -> Result, String> { - debug_assert!(rhs_module.starts_with(HOSTED_GO_MODULE_PREFIX)); + debug_assert!(is_hosted_module_path(rhs_module)); upsert_socket_replace( content, module, @@ -391,33 +415,57 @@ fn upsert_socket_replace( ) -> Result, String> { let want_line = format!("replace {module} {version} => {target}"); - // Locate an existing socket-owned replace line for `module`, and detect a - // conflicting user-authored replace pinning the same module+version. - let mut socket_line: Option = None; + // Locate the existing socket-owned replace lines for `module`, and detect + // a conflicting user-authored replace pinning the same module+version. + let mut socket_lines: Vec = Vec::new(); for_each_directive_body(content, "replace", |i, body| { - inspect_existing(body, module, version, target, i, &mut socket_line) + inspect_existing(body, module, version, target, i, &mut socket_lines) })?; - if let Some(idx) = socket_line { - // Rewrite the existing socket-owned line in place, preserving whether it - // was a block member (`\tmodule … => …`) or a single-line `replace …`. - let mut lines: Vec = content.lines().map(str::to_string).collect(); - let raw = &lines[idx]; + if let Some((&idx, duplicates)) = socket_lines.split_first() { + // Rewrite the first socket-owned line in place, preserving whether it + // was a block member (`\tmodule … => …`) or a single-line `replace …` + // and any trailing `// comment`. + let mut lines: Vec> = content.lines().map(|l| Some(l.to_string())).collect(); + let raw = lines[idx].as_deref().unwrap_or_default(); let indent: String = raw.chars().take_while(|c| c.is_whitespace()).collect(); let is_block_member = !strip_comment(raw) .trim_start() .strip_prefix("replace") .is_some_and(|rest| rest.starts_with(char::is_whitespace)); - let new = if is_block_member { + let mut new = if is_block_member { format!("{indent}{module} {version} => {target}") } else { format!("{indent}{want_line}") }; - if lines[idx] == new { + if let Some(comment) = raw.find("//").map(|i| &raw[i..]) { + new = format!("{new} {comment}"); + } + let changed = lines[idx].as_deref() != Some(new.as_str()) || !duplicates.is_empty(); + lines[idx] = Some(new); + // Any further socket-owned line for the module (a merge that kept + // both a vendored and a hosted directive) would duplicate the one + // just written — go rejects that go.mod — so drop it, with the blank + // separator the upsert put in front of a single-line directive. + for &dup in duplicates { + lines[dup] = None; + let next_blank = lines + .get(dup + 1) + .is_none_or(|l| l.as_deref().is_some_and(|l| l.trim().is_empty())); + if dup > 0 + && next_blank + && lines[dup - 1] + .as_deref() + .is_some_and(|l| l.trim().is_empty()) + { + lines[dup - 1] = None; + } + } + if !changed { return Ok(None); } - lines[idx] = new; - return Ok(Some(join_preserving_trailing_newline(&lines, content))); + let kept: Vec = lines.into_iter().flatten().collect(); + return Ok(Some(join_preserving_trailing_newline(&kept, content))); } // No socket-owned entry yet → append a single-line directive, separated @@ -447,7 +495,7 @@ fn inspect_existing( version: &str, want_target: &str, line_idx: usize, - socket_line: &mut Option, + socket_lines: &mut Vec, ) -> Result<(), String> { let Some(e) = parse_replace_body(body) else { return Ok(()); @@ -460,9 +508,7 @@ fn inspect_existing( // place. The cross-owner rewrite is the takeover mechanism — a single // atomic go.mod write repoints e.g. a go-patches redirect at the // vendor copy (or a hosted module) with no remove+add window. - if socket_line.is_none() { - *socket_line = Some(line_idx); - } + socket_lines.push(line_idx); return Ok(()); } // A user-authored replace for the same module. Only the *same version* @@ -609,10 +655,7 @@ mod tests { Some(GoPatches) ); assert_eq!(detect_owner(".socket/go-patches/x@v1.0.0"), Some(GoPatches)); - assert_eq!( - detect_owner("sub/.socket/go-patches/x@v1.0.0"), - Some(GoPatches) - ); + assert_eq!(detect_owner("sub/.socket/go-patches/x@v1.0.0"), None); assert_eq!( detect_owner("./.socket/vendor/golang/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/github.com/x/y@v1.0.0"), Some(Vendor) @@ -1564,6 +1607,148 @@ replace ( assert!(bumped.contains("bar@v1.5.0")); } + /// Ownership is anchored at the project root: the CLI only ever writes + /// `./.socket/vendor/golang/…` / `./.socket/go-patches/…`, so a user's + /// replace onto ANOTHER checkout's vendored copy (`../sibling/.socket/…`, + /// an absolute path) is user-authored and must never be rewritten or + /// dropped by this project's vendor/apply/rollback. + #[test] + fn test_detect_owner_ignores_foreign_socket_dirs() { + let foreign = format!("../sibling/{VENDOR_BASE}/github.com/foo/bar@v1.4.2"); + assert_eq!(detect_owner(&foreign), None); + assert_eq!( + detect_owner("/abs/repo/.socket/go-patches/github.com/foo/bar@v1.4.2"), + None + ); + let gomod = format!("module m\n\nreplace github.com/foo/bar v1.4.2 => {foreign}\n"); + assert!( + upsert_replace_entry(&gomod, "github.com/foo/bar", "v1.4.2", VENDOR_BASE).is_err(), + "a same-version user replace onto a sibling repo is a conflict, not a takeover" + ); + assert!( + remove_replace_entry(&gomod, "github.com/foo/bar", ReplaceOwner::Vendor) + .unwrap() + .is_none(), + "vendor revert must not drop a user replace onto a sibling repo" + ); + } + + /// Hosted ownership is the exact content-addressed module + /// `patch.socket.dev/gopatch/` — a deeper path or a + /// non-uuid leaf under the namespace is not something the CLI wrote. + #[test] + fn test_hosted_owner_requires_exact_uuid_module() { + for rhs in [ + format!("{HOSTED_MOD}/extra"), + "patch.socket.dev/gopatch/not-a-uuid".to_string(), + "patch.socket.dev/gopatch/9F6B2C4E-1D3A-4F6B-8C2D-7E5A9B1C3D5F".to_string(), + ] { + assert!(!is_hosted_module_path(&rhs)); + let gomod = format!("module m\n\nreplace github.com/foo/bar v1.4.2 => {rhs} v1.4.2\n"); + let entries = parse_replace_entries(&gomod); + assert_eq!(entries[0].owner, None, "{rhs} must be user-authored"); + } + assert!(is_hosted_module_path(HOSTED_MOD)); + } + + /// A merge that leaves two socket-owned directives for one module (e.g. + /// vendor on one branch, hosted on the other) must collapse to ONE on the + /// next upsert — refreshing only the first leaves a duplicate, and go + /// rejects the go.mod. + #[test] + fn test_upsert_collapses_duplicate_socket_directives() { + let gomod = format!( + "module m\n\n\ + replace github.com/foo/bar v1.4.2 => ./{VENDOR_BASE}/github.com/foo/bar@v1.4.2\n\n\ + replace github.com/foo/bar v1.4.2 => {HOSTED_MOD} v1.4.2-socketpatch.1\n" + ); + let out = upsert_hosted_replace_entry( + &gomod, + "github.com/foo/bar", + "v1.4.2", + HOSTED_MOD, + "v1.4.2-socketpatch.1", + ) + .unwrap() + .expect("the duplicate must be removed"); + let entries = parse_replace_entries(&out); + assert_eq!( + entries + .iter() + .filter(|e| e.module == "github.com/foo/bar") + .count(), + 1, + "exactly one directive for the module: {out:?}" + ); + assert_eq!(entries[0].owner, Some(ReplaceOwner::Hosted)); + // A duplicate inside a block is collapsed too. + let block = format!( + "module m\n\nreplace (\n\ + \tgithub.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2\n\ + \tgithub.com/foo/bar v1.0.0 => {HOSTED_MOD} v1.0.0-socketpatch.1\n\ + \texample.com/baz v2.0.0 => ../local-baz\n)\n" + ); + let out = upsert_replace_entry(&block, "github.com/foo/bar", "v1.4.2", VENDOR_BASE) + .unwrap() + .unwrap(); + let entries = parse_replace_entries(&out); + assert_eq!( + entries + .iter() + .filter(|e| e.module == "github.com/foo/bar") + .count(), + 1, + "{out:?}" + ); + assert!(out.contains("example.com/baz v2.0.0 => ../local-baz")); + } + + /// An in-place refresh keeps the user's trailing `// comment` on the + /// directive (go mod tidy preserves it; so must we). + #[test] + fn test_upsert_refresh_keeps_trailing_comment() { + let gomod = "module m\n\nreplace github.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2 // CVE-2026-1\n"; + let out = upsert_replace_entry(gomod, "github.com/foo/bar", "v1.4.2", VENDOR_BASE) + .unwrap() + .unwrap(); + assert!( + out.contains(&format!( + "replace github.com/foo/bar v1.4.2 => ./{VENDOR_BASE}/github.com/foo/bar@v1.4.2 // CVE-2026-1\n" + )), + "{out:?}" + ); + // Idempotent with the comment in place. + assert!( + upsert_replace_entry(&out, "github.com/foo/bar", "v1.4.2", VENDOR_BASE) + .unwrap() + .is_none() + ); + } + + /// go.mod allows quoted module paths (`"github.com/foo/bar"`). The + /// parsers must unquote them, or a user's quoted same-version replace is + /// invisible to the conflict check (a duplicate directive is appended) + /// and a quoted `require` escapes the stale-pin cross-check. + #[test] + fn test_quoted_module_paths_are_unquoted() { + let gomod = "module m\n\nrequire \"github.com/foo/bar\" v1.4.2\n\nreplace \"github.com/foo/bar\" v1.4.2 => ../fork\n"; + assert_eq!( + parse_required_versions(gomod) + .get("github.com/foo/bar") + .map(String::as_str), + Some("v1.4.2") + ); + assert_eq!(parse_replace_entries(gomod)[0].module, "github.com/foo/bar"); + assert!( + upsert_replace_entry(gomod, "github.com/foo/bar", "v1.4.2", VENDOR_BASE).is_err(), + "the quoted user replace must be seen as a conflict" + ); + let hosted = format!("module m\n\nreplace github.com/foo/bar v1.4.2 => \"{HOSTED_MOD}\" v1.4.2-socketpatch.1\n"); + let e = &parse_replace_entries(&hosted)[0]; + assert_eq!(e.owner, Some(ReplaceOwner::Hosted)); + assert_eq!(e.rhs_module.as_deref(), Some(HOSTED_MOD)); + } + #[tokio::test] async fn test_ensure_missing_go_mod_errors() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/vendor/go_sum_edit.rs b/crates/socket-patch-core/src/vendor/go_sum_edit.rs index 660c9c52..1ec30ba0 100644 --- a/crates/socket-patch-core/src/vendor/go_sum_edit.rs +++ b/crates/socket-patch-core/src/vendor/go_sum_edit.rs @@ -96,6 +96,17 @@ pub fn upsert_module_lines( Some(joined) } +/// True when `go.sum` carries a line (zip or `/go.mod` form) for exactly +/// `module@version` — go records one for every module version its build +/// graph loads. +pub fn has_module_version(content: &str, module: &str, version: &str) -> bool { + let zip_key = format!("{module} {version} "); + let gomod_key = format!("{module} {version}/go.mod "); + content + .lines() + .any(|l| l.starts_with(&zip_key) || l.starts_with(&gomod_key)) +} + /// Remove the lines for exactly `module@version` (both the zip and `/go.mod` /// forms). Used to prune the REPLACED original's lines: once a version-pinned /// `replace` covers the resolved version, go never fetches (or verifies) the diff --git a/crates/socket-patch-core/src/vendor/golang.rs b/crates/socket-patch-core/src/vendor/golang.rs index 9a4aec1e..6711ef2d 100644 --- a/crates/socket-patch-core/src/vendor/golang.rs +++ b/crates/socket-patch-core/src/vendor/golang.rs @@ -69,6 +69,7 @@ pub async fn vendor_go_module( let Some((module, version)) = parse_golang_purl(purl) else { return refused("unsafe_coordinates", format!("not a golang purl: {purl}")); }; + let (module, version) = (&*module, &*version); // SECURITY: `module`+`version` key the on-disk copy dir // (`.socket/vendor/golang//@/`) and the `replace` // target path. A `..` segment / absolute path / backslash from a tampered @@ -705,6 +706,7 @@ pub async fn revert_go_vendor_opts( let Some((module, version)) = parse_golang_purl(&entry.base_purl) else { return RevertOutcome::failed(format!("not a golang purl: {}", entry.base_purl)); }; + let (module, version) = (&*module, &*version); if !are_safe_redirect_coords(module, version) { return RevertOutcome::failed(format!( "refusing to revert unsafe golang coordinates `{module}`/`{version}`" From 943bbfdfa5b75d18f42c17b6ed7e5d0792d7ef43 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 24 Sep 2026 16:33:21 +0000 Subject: [PATCH 2/6] Fix reconcile to use canonical_purl for percent-decoded Go version comparison The reconcile_go_redirects function was using strip_purl_qualifiers to build desired_bases, which doesn't decode percent-encoded characters. However, parse_golang_purl now decodes characters like %2B to +, so on-disk copies use the decoded version (+incompatible) while manifest keys still have the encoded form (%2Bincompatible). This mismatch caused reconciliation to treat valid copies as orphans and remove them along with their go.mod replace directives. The fix uses canonical_purl instead, which both strips qualifiers and decodes, ensuring proper matching between manifest keys and disk-based PURLs. Added test_reconcile_keeps_percent_encoded_version to prevent regression. --- .../src/patch/redirect/golang_local.rs | 64 ++++++++++++++++++- 1 file changed, 61 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/golang_local.rs b/crates/socket-patch-core/src/patch/redirect/golang_local.rs index 84c0dea5..10a5d436 100644 --- a/crates/socket-patch-core/src/patch/redirect/golang_local.rs +++ b/crates/socket-patch-core/src/patch/redirect/golang_local.rs @@ -31,7 +31,7 @@ use crate::patch::apply::{ MismatchPolicy, PatchSources, }; use crate::patch::file_hash::compute_file_git_sha256; -use crate::utils::purl::{build_golang_purl, parse_golang_purl, strip_purl_qualifiers}; +use crate::utils::purl::{build_golang_purl, canonical_purl, parse_golang_purl}; use crate::vendor::common::{ already_patched_result, copy_matches_after_hashes, synthesized_result, }; @@ -394,12 +394,12 @@ pub async fn reconcile_go_redirects( // key may carry `?qualifiers`/`#subpath` (raw API PURL), while the PURL // reconstructed from the copy dir is the canonical base — compare bases, or // a qualified key's freshly applied copy is pruned as an orphan. - let desired_bases: HashSet<&str> = desired.iter().map(|p| strip_purl_qualifiers(p)).collect(); + let desired_bases: HashSet = desired.iter().map(|p| canonical_purl(p)).collect(); // Re-read after (a)'s drops so the dangling-directive probe below sees the // current file. let entries = read_replace_entries(project_root).await; for (purl, dir) in collect_copy_modules(&project_root.join(GO_PATCHES_DIR)).await { - if !desired_bases.contains(purl.as_str()) { + if !desired_bases.contains(&purl) { // A go-patches directive still targeting THIS copy dangles once the // copy is pruned — loop (a) keeps it whenever the module is desired // at ANOTHER version (a bump whose apply hasn't succeeded), and a @@ -1892,6 +1892,64 @@ mod tests { ); } + /// Regression: reconcile must match by canonical (decoded) base PURL — + /// the API serves `%2B` but `parse_golang_purl` decodes to `+`, so the + /// on-disk copy carries `+incompatible` and `collect_copy_modules` rebuilds + /// the PURL with `+`. The desired manifest key still has `%2B` as received + /// from the API. Without decoding in the comparison, the fresh copy looks + /// like an orphan and is pruned, dropping the socket-owned `replace`. + #[tokio::test] + async fn test_reconcile_keeps_percent_encoded_version() { + let (dir, blobs, pristine, files, _after) = fixture().await; + let root = dir.path(); + let sources = PatchSources::blobs_only(&blobs); + // API form: percent-encoded `+`. + let encoded_purl = "pkg:golang/github.com/foo/bar@v2.0.0%2Bincompatible"; + let (module, version) = parse_golang_purl(encoded_purl).unwrap(); + // After parsing, version is `v2.0.0+incompatible` (decoded). + assert_eq!(version, "v2.0.0+incompatible"); + + let result = apply_go_redirect( + encoded_purl, + &module, + &version, + &pristine, + root, + GO_PATCHES_DIR, + &files, + &sources, + None, + false, + MismatchPolicy::Warn, + ) + .await; + assert!(result.success, "apply failed: {:?}", result.error); + + // Reconcile with the same encoded manifest key — canonical_purl + // decodes both sides, so they match. + let desired: HashSet = [encoded_purl.to_string()].into_iter().collect(); + let removed = reconcile_go_redirects(root, &desired, false).await; + assert!( + removed.is_empty(), + "a desired encoded version must not be pruned: {removed:?}" + ); + // The on-disk copy uses the decoded `+`. + assert!( + root.join(".socket/go-patches/github.com/foo/bar@v2.0.0+incompatible") + .exists(), + "copy with decoded version must survive reconcile" + ); + assert!( + read_replace_entries(root) + .await + .iter() + .any(|e| e.module == "github.com/foo/bar" + && e.version.as_deref() == Some("v2.0.0+incompatible") + && e.socket_owned()), + "socket-owned replace for the decoded version must survive" + ); + } + /// SECURITY regression: a tampered manifest PURL with `..` in the module path /// must NOT let `apply` copy + write the patched tree outside /// `.socket/go-patches/`. Without the guard `copy_dir_for` would resolve to From 0f19e6d0aa2cfa8c02c35e82b05a1bdb6201b039 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 24 Sep 2026 13:10:11 -0400 Subject: [PATCH 3/6] Log the +incompatible reconcile fix The previous commit stopped apply reconcile from deleting a freshly applied +incompatible go-patches copy (manifest keys spell it %2Bincompatible, the copy directory uses the decoded version). Record it under Unreleased > Fixed. Assisted-by: Claude Code:claude-opus-5-5 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8915ce7b..c541363c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -380,6 +380,10 @@ into the new version's section — see docs/releasing.md. spell the version `v2.0.0%2Bincompatible` are now percent-decoded, so the module is found in the module cache and the `replace` and copy directory carry the real `+incompatible` version. +- **`+incompatible` Go copies survive `apply` reconcile.** A manifest key + spelled `%2Bincompatible` no longer makes the freshly applied + `.socket/go-patches/…@v2.0.0+incompatible` copy look orphaned, so it and + its `replace` are kept. - **A vendoring-service outage no longer re-vendors packages.** An npm re-run (every lock flavor, `bun.lockb` included) re-acquired its tarball from whichever source answered — the service's prebuilt, or a local pack From 5db4858c1335e0515383ca2be794e13f09c977ac Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 24 Sep 2026 13:11:20 -0400 Subject: [PATCH 4/6] Restore go.sum exactly on hosted Go rollback Rolling back a hosted Go redirect appended the pruned upstream go.sum lines to the end of the file, so go.sum did not come back byte for byte and stayed out of go's order until the next go mod tidy. The lines now go back where go sorts them: module path, then semver version, then the /go.mod suffix. CRLF projects also unwind cleanly. The socket module's go.sum lines are removed line by line instead of as an LF-joined fragment, and removing the replace line takes its CRLF with it, so no socket lines or blank lines are left behind. Assisted-by: Claude Code:claude-opus-5-5 --- CHANGELOG.md | 5 + .../tests/e2e_golang_hosted_state.rs | 44 +++++++ .../src/patch/redirect/replay.rs | 122 +++++++++++++++--- .../src/vendor/go_sum_edit.rs | 82 ++++++++++++ 4 files changed, 235 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c541363c..a6696c3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -384,6 +384,11 @@ into the new version's section — see docs/releasing.md. spelled `%2Bincompatible` no longer makes the freshly applied `.socket/go-patches/…@v2.0.0+incompatible` copy look orphaned, so it and its `replace` are kept. +- **Hosted Go rollback restores go.sum byte for byte.** The upstream + module's go.sum lines that the redirect pruned went back at the end of + the file; they now return to the position `go mod tidy` sorts them to + (semver order within a module). CRLF go.mod/go.sum files also unwind + cleanly, with no leftover socket lines or blank lines. - **A vendoring-service outage no longer re-vendors packages.** An npm re-run (every lock flavor, `bun.lockb` included) re-acquired its tarball from whichever source answered — the service's prebuilt, or a local pack diff --git a/crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs b/crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs index 06b39bdd..38b5bca9 100644 --- a/crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs +++ b/crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs @@ -296,3 +296,47 @@ async fn hosted_takeover_of_vendored_module_removes_vendored_state() { "the vendor ledger no longer claims the module: {state}" ); } + +const TEXT_SUM: &str = "golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=\n\ + golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=\n"; + +/// Hosted `rollback` puts the pruned upstream go.sum pair back where go +/// sorts it, so go.mod and go.sum return byte for byte. +#[tokio::test(flavor = "multi_thread")] +async fn hosted_rollback_restores_go_sum_byte_for_byte() { + let tmp = tempfile::tempdir().unwrap(); + let consumer = tmp.path().join("consumer"); + let go_sum = format!("{UPSTREAM_SUM}{TEXT_SUM}"); + write_consumer(&consumer, &format!("require {UMOD} {UVER}\n"), &go_sum); + let go_mod = std::fs::read_to_string(consumer.join("go.mod")).unwrap(); + let modcache = tmp.path().join("modcache"); + let server = MockServer::start().await; + mount_hosted_grant(&server).await; + let env = get_hosted(&consumer, &server, &modcache); + assert_eq!(env["redirect"]["redirected"], 1, "envelope: {env}"); + + let (code, stdout, stderr) = common::run_with_env( + &consumer, + &[ + "rollback", + "--json", + "--yes", + "--offline", + "--cwd", + consumer.to_str().unwrap(), + ], + &[("GOMODCACHE", modcache.to_str().unwrap())], + ); + assert_eq!( + code, 0, + "rollback failed\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert_eq!( + std::fs::read_to_string(consumer.join("go.mod")).unwrap(), + go_mod + ); + assert_eq!( + std::fs::read_to_string(consumer.join("go.sum")).unwrap(), + go_sum + ); +} diff --git a/crates/socket-patch-core/src/patch/redirect/replay.rs b/crates/socket-patch-core/src/patch/redirect/replay.rs index 02a707e9..08b2f201 100644 --- a/crates/socket-patch-core/src/patch/redirect/replay.rs +++ b/crates/socket-patch-core/src/patch/redirect/replay.rs @@ -60,10 +60,13 @@ enum Inverse { /// (an absent fragment is the desired end state — no-op). RemoveAddedFragment, /// action `removed` with only `original` recorded: the redirect - /// pruned lines the pristine file needs back (go.sum entries of the - /// upstream module). Re-insert by appending — go.sum lines are - /// order-insensitive. + /// pruned go.sum lines of the upstream module that the pristine file + /// needs back. Re-inserted at go's sorted position, so the file returns + /// byte for byte. ReinsertRemoved, + /// go.sum lines the redirect added (`new`, `\n`-joined): each is removed + /// as a whole line, whatever the file's line endings. + RemoveAddedLines, /// Cleanup of PRIOR socket wiring performed during a redirect refresh /// (`redirect_golang_stale_*`). The removal already moved the file /// toward pristine; restoring it would re-create socket wiring, so @@ -142,7 +145,7 @@ fn classify(kind: &str, action: &str) -> (&'static str, Inverse) { Inverse::ReplaceFragment }, ), - "redirect_golang_gosum" => ("golang", Inverse::RemoveAddedFragment), + "redirect_golang_gosum" => ("golang", Inverse::RemoveAddedLines), "redirect_golang_gosum_prune" => ("golang", Inverse::ReinsertRemoved), "redirect_golang_stale_replace_removed" | "redirect_golang_stale_gosum_removed" => { ("golang", Inverse::NoopDrop) @@ -260,16 +263,21 @@ fn remove_fragment_once(content: &str, fragment: &str) -> String { // non-whitespace prefix (the user commented the line out) leaves the // prefix as its own line, and eating the newline would join that // prefix onto the FOLLOWING line, commenting it out too. - if start == line_start && content[end..].starts_with('\n') { - end += 1; + if start == line_start { + if content[end..].starts_with("\r\n") { + end += 2; + } else if content[end..].starts_with('\n') { + end += 1; + } } if end >= content.len() { // EOF removal: collapse the (ambiguous) trailing separator run. - let trimmed = content[..start].trim_end_matches('\n'); + let trimmed = content[..start].trim_end_matches(['\r', '\n']); if trimmed.is_empty() { return String::new(); } - return format!("{trimmed}\n"); + let eol = crate::vendor::common::detect_eol(content); + return format!("{trimmed}{eol}"); } format!("{}{}", &content[..start], &content[end..]) } @@ -600,17 +608,56 @@ pub async fn revert_remaining_redirect_edits( continue 'group; } }; - if content.contains(original) { - group_drops.insert(idx); - } else { - let mut restored = content; - if !restored.is_empty() && !restored.ends_with('\n') { - restored.push('\n'); - } - restored.push_str(original); - restored.push('\n'); + if let Some(restored) = + crate::vendor::go_sum_edit::reinsert_lines(&content, original) + { staged.insert(edit.path.clone(), Some(restored)); - group_drops.insert(idx); + } + group_drops.insert(idx); + } + Inverse::RemoveAddedLines => { + let Some(new) = str_payload(&edit.new) else { + refuse( + format!("{} edit is missing its recorded fragment", edit.kind), + &mut outcome, + ); + refused_groups.insert(group); + continue 'group; + }; + match staged_read(&staged, project_root, &edit.path).await { + Ok(Some(content)) => { + if new + .lines() + .filter(|l| !l.is_empty()) + .any(|line| content.lines().filter(|l| l == &line).count() > 1) + { + refuse( + format!( + "{}: an added line appears more than once — \ + ambiguous, refusing to guess", + edit.path + ), + &mut outcome, + ); + refused_groups.insert(group); + continue 'group; + } + if let Some(removed) = + crate::vendor::go_sum_edit::remove_lines(&content, new) + { + staged.insert(edit.path.clone(), Some(removed)); + } + group_drops.insert(idx); + } + // File gone entirely: the lines are gone with it. + Ok(None) => { + group_drops.insert(idx); + } + Err(e) => { + refuse(e, &mut outcome); + refused_groups.insert(group); + continue 'group; + } } } Inverse::PnpmTrust => { @@ -1248,6 +1295,45 @@ mod tests { assert!(state.records.is_empty()); } + /// The pruned pair goes back where `go mod tidy` writes it — module + /// path, then SEMVER version (`v1.9.0/go.mod` before `v1.10.0`, though + /// bytewise greater) — so go.sum is restored byte for byte. + #[tokio::test] + async fn reinsert_restores_the_go_sorted_position() { + let pristine = "example.com/leaf v1.0.0 h1:L=\n\ + example.com/leaf v1.0.0/go.mod h1:LM=\n\ + example.com/lib v1.9.0/go.mod h1:N9=\n\ + example.com/lib v1.10.0 h1:T=\n\ + example.com/lib v1.10.0/go.mod h1:TM=\n\ + example.com/zeta v0.1.0 h1:Z=\n"; + for eol in ["\n", "\r\n"] { + let dir = TempDir::new().unwrap(); + let pruned = pristine + .lines() + .filter(|l| !l.starts_with("example.com/lib v1.10.0")) + .map(|l| format!("{l}{eol}")) + .collect::(); + write(dir.path(), "go.sum", &pruned).await; + let mut state = state_with( + vec![edit( + "go.sum", + "redirect_golang_gosum_prune", + "removed", + Some("example.com/lib v1.10.0 h1:T=\nexample.com/lib v1.10.0/go.mod h1:TM="), + None, + )], + &[], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "{:?}", out.refusals); + assert_eq!( + read(dir.path(), "go.sum").await, + pristine.replace('\n', eol), + "eol {eol:?}" + ); + } + } + #[tokio::test] async fn reinsert_is_idempotent_when_lines_are_already_back() { let dir = TempDir::new().unwrap(); diff --git a/crates/socket-patch-core/src/vendor/go_sum_edit.rs b/crates/socket-patch-core/src/vendor/go_sum_edit.rs index 1ec30ba0..43d65e48 100644 --- a/crates/socket-patch-core/src/vendor/go_sum_edit.rs +++ b/crates/socket-patch-core/src/vendor/go_sum_edit.rs @@ -169,6 +169,88 @@ pub fn remove_module_prefix_lines(content: &str, module_prefix: &str) -> Option< Some(joined) } +/// go's go.sum line order, as `go mod tidy` writes it (`module.Sort`, then +/// the hashes sorted): module path bytewise, then the version by semver +/// (`v1.9.0` before `v1.10.0`), then the `/go.mod` suffix, then the hash. +fn go_sum_line_cmp(a: &str, b: &str) -> std::cmp::Ordering { + fn split(line: &str) -> (&str, &str, &str, &str) { + let mut fields = line.splitn(3, ' '); + let path = fields.next().unwrap_or_default(); + let version = fields.next().unwrap_or_default(); + let hash = fields.next().unwrap_or_default(); + let (version, file) = version.split_at(version.find('/').unwrap_or(version.len())); + (path, version, file, hash) + } + let (path_a, version_a, file_a, hash_a) = split(a); + let (path_b, version_b, file_b, hash_b) = split(b); + path_a + .cmp(path_b) + .then_with(|| go_semver_cmp(version_a, version_b)) + .then_with(|| file_a.cmp(file_b)) + .then_with(|| hash_a.cmp(hash_b)) +} + +/// `golang.org/x/mod/semver.Compare`: build metadata (`+incompatible`) is +/// ignored and an invalid version sorts before every valid one. +fn go_semver_cmp(a: &str, b: &str) -> std::cmp::Ordering { + let parse = |v: &str| { + v.strip_prefix('v') + .and_then(|s| semver::Version::parse(s).ok()) + }; + match (parse(a), parse(b)) { + (Some(x), Some(y)) => (x.major, x.minor, x.patch) + .cmp(&(y.major, y.minor, y.patch)) + .then_with(|| x.pre.cmp(&y.pre)), + (None, Some(_)) => std::cmp::Ordering::Less, + (Some(_), None) => std::cmp::Ordering::Greater, + (None, None) => a.cmp(b), + } +} + +/// Put previously removed `go.sum` lines (`\n`-joined) back at go's sorted +/// position, keeping the file's line endings, so a revert restores the +/// bytes go wrote. Lines already present are skipped. Returns `None` when +/// nothing was missing. +pub fn reinsert_lines(content: &str, removed: &str) -> Option { + let mut lines: Vec<&str> = content.lines().collect(); + let mut changed = false; + for line in removed.lines().filter(|l| !l.is_empty()) { + if lines.contains(&line) { + continue; + } + let at = lines + .iter() + .position(|l| go_sum_line_cmp(line, l).is_lt()) + .unwrap_or(lines.len()); + lines.insert(at, line); + changed = true; + } + if !changed { + return None; + } + let eol = super::common::detect_eol(content); + let mut joined = lines.join(eol); + joined.push_str(eol); + Some(joined) +} + +/// Remove each of `added` (`\n`-joined lines) where it appears as a whole +/// line, whatever the file's line endings. Returns `None` when none did. +pub fn remove_lines(content: &str, added: &str) -> Option { + let drop: Vec<&str> = added.lines().filter(|l| !l.is_empty()).collect(); + let kept: Vec<&str> = content.lines().filter(|l| !drop.contains(l)).collect(); + if kept.len() == content.lines().count() { + return None; + } + if kept.is_empty() { + return Some(String::new()); + } + let eol = super::common::detect_eol(content); + let mut joined = kept.join(eol); + joined.push_str(eol); + Some(joined) +} + #[cfg(test)] mod tests { use super::*; From dd64a11f59ed7e8ac1626fa8dc1ba4b5e5b4d257 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 24 Sep 2026 13:12:42 -0400 Subject: [PATCH 5/6] Unwind hosted Go redirects before vendoring Vendoring a hosted-redirected Go module rewrote its replace in place but kept the redirect-ledger record, the socket module's go.sum lines, and the pruned upstream go.sum lines. The ledger kept claiming a hosted redirect that was gone, and vendor --revert left go.sum without the upstream lines. Go now gets the same per-purl takeover revert as cargo and npm. The module's replace and socket go.sum lines are removed, the upstream lines come back in go's order, and the record is dropped before the vendored replace is written. A replace that no longer matches the recorded one refuses the takeover and leaves the files alone. Scoped rollback and remove of one hosted Go module now work as well. Assisted-by: Claude Code:claude-opus-5-5 --- CHANGELOG.md | 7 + crates/socket-patch-cli/CLI_CONTRACT.md | 4 +- .../socket-patch-cli/src/commands/vendor.rs | 3 + .../tests/e2e_golang_hosted_state.rs | 98 ++++++ .../src/patch/redirect/mod.rs | 4 +- .../src/patch/redirect/replay.rs | 7 +- .../src/patch/redirect/takeover.rs | 314 +++++++++++++++++- 7 files changed, 428 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6696c3a..37434850 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -389,6 +389,13 @@ into the new version's section — see docs/releasing.md. the file; they now return to the position `go mod tidy` sorts them to (semver order within a module). CRLF go.mod/go.sum files also unwind cleanly, with no leftover socket lines or blank lines. +- **Vendoring a hosted Go module unwinds the hosted redirect first.** + `vendor` / `get --mode vendored` over a hosted-redirected Go module left + its redirect-ledger record and the socket module's go.sum lines behind + (with the upstream lines still pruned). Go now takes the same per-purl + takeover revert as cargo and npm (`vendor_takeover_reverted_redirect`), + and scoped `rollback ` / `remove ` of one hosted Go module + works without an unscoped rollback. - **A vendoring-service outage no longer re-vendors packages.** An npm re-run (every lock flavor, `bun.lockb` included) re-acquired its tarball from whichever source answered — the service's prebuilt, or a local pack diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index f8ab04cc..553a9121 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -96,7 +96,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc `redirect_pnpm_no_lockfile` names pnpm when installer markers exist without a lock; `redirect_pnpm_entry_vendored` identifies a vendored entry instead of reporting it missing. Supported `shrinkwrap.yaml` files are writable lockfiles, not read-only markers. -**Takeover reconciliation (npm family, bun included)**: vendoring over a hosted-redirected purl (`vendor`, `scan --mode vendored`, `get --mode vendored`) first REVERTS that purl's hosted lockfile edits to their pre-redirect registry values through the per-purl redirect revert, drops the purl's record + package edits from `redirect-state.json`, and then vendors — so the vendor ledger records the PRISTINE registry fragment as its wiring `original` and `vendor --revert` lands back on registry state, never on an expiring hosted URL. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; the human path prints `Warning (vendor_takeover_reverted_redirect): …`). `--dry-run` PROBES the same revert against an in-memory ledger clone instead of promising it: a clean probe reports `vendor_would_revert_redirect`, and a drifted lock or an undecidable ledger edit surfaces in the preview with the wet run's `redirect_revert_failed` code and detail (for bun, whose hosted rewrite replaces the entry's `name@version` spec, the preview first runs the Bun vendored preflight described below and then stops at the advisory instead of reading the still-hosted lock — a lock the vendored backend would refuse is previewed as the wet run's `failed `, never as `vendor_would_revert_redirect`). A purl whose hosted edits cannot be cleanly reverted fails `redirect_revert_failed` (exit 1 / `partial_failure`, nothing vendored for it, the hosted wiring left in place, the remedy in the detail). **bun** participates like every other npm-family flavor: binary `redirect_bun_lockb_package` snapshots are claimed by their recorded package identity and restore individual binary resolutions; its text `redirect_bun_lock_package` edits are claimed by the recorded line's spec — the registry spec `@`, or a hosted URL whose tarball leaf is `-.tgz` — so a sibling version's or an aliased sibling's edit is neither claimed nor a refusal, and only an edit that mentions the package without being a bun packages-entry line refuses (remedy: an unscoped `socket-patch rollback`, whose whole-ledger replay unwinds bun.lock hosted edits; never hand-edit the ledger). The same claim rule serves scoped `rollback ` / `remove ` of one of several hosted bun records (see "Hosted unwind coverage"). Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `redirect.warnings[]`) both work in place on bun locks the target mode accepts. **Bun vendored preflight before the takeover**: `vendor` — like `scan` / `get --mode vendored`, whose pre-download preflight runs earlier — checks `bun.lock` / `bun.lockb` with the shared Bun vendored preflight BEFORE the per-purl hosted revert, so a hosted-redirected purl on a lock the vendored backend refuses (a pre-version-2 `workspace:` lock → `vendor_bun_workspace_unsupported`; a malformed or unsupported binary lock → `vendor_bun_lockb_invalid`; an unsupported text-lock version → its code) is reported `failed ` with the hosted wiring, the redirect ledger and active Bun lock byte-untouched (exit 1 / `partial_failure`): the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed` code (exit-code parity with the wet run, nothing written) instead of promising `vendor_would_revert_redirect`. Pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. The separate run-level `vendor_supersedes_redirect` warning covers the reconcile-only case — a live lock that already proves vendored won over a stale hosted ledger record (the vendor wiring then holds the hosted-spliced fragment as `original`) — and fires exactly once, on the run that drops the stale records. +**Takeover reconciliation (npm family, bun included)**: vendoring over a hosted-redirected purl (`vendor`, `scan --mode vendored`, `get --mode vendored`) first REVERTS that purl's hosted lockfile edits to their pre-redirect registry values through the per-purl redirect revert, drops the purl's record + package edits from `redirect-state.json`, and then vendors — so the vendor ledger records the PRISTINE registry fragment as its wiring `original` and `vendor --revert` lands back on registry state, never on an expiring hosted URL. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; the human path prints `Warning (vendor_takeover_reverted_redirect): …`). `--dry-run` PROBES the same revert against an in-memory ledger clone instead of promising it: a clean probe reports `vendor_would_revert_redirect`, and a drifted lock or an undecidable ledger edit surfaces in the preview with the wet run's `redirect_revert_failed` code and detail (for bun, whose hosted rewrite replaces the entry's `name@version` spec, the preview first runs the Bun vendored preflight described below and then stops at the advisory instead of reading the still-hosted lock — a lock the vendored backend would refuse is previewed as the wet run's `failed `, never as `vendor_would_revert_redirect`). A purl whose hosted edits cannot be cleanly reverted fails `redirect_revert_failed` (exit 1 / `partial_failure`, nothing vendored for it, the hosted wiring left in place, the remedy in the detail). **bun** participates like every other npm-family flavor: binary `redirect_bun_lockb_package` snapshots are claimed by their recorded package identity and restore individual binary resolutions; its text `redirect_bun_lock_package` edits are claimed by the recorded line's spec — the registry spec `@`, or a hosted URL whose tarball leaf is `-.tgz` — so a sibling version's or an aliased sibling's edit is neither claimed nor a refusal, and only an edit that mentions the package without being a bun packages-entry line refuses (remedy: an unscoped `socket-patch rollback`, whose whole-ledger replay unwinds bun.lock hosted edits; never hand-edit the ledger). The same claim rule serves scoped `rollback ` / `remove ` of one of several hosted bun records (see "Hosted unwind coverage"). Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `redirect.warnings[]`) both work in place on bun locks the target mode accepts. **Bun vendored preflight before the takeover**: `vendor` — like `scan` / `get --mode vendored`, whose pre-download preflight runs earlier — checks `bun.lock` / `bun.lockb` with the shared Bun vendored preflight BEFORE the per-purl hosted revert, so a hosted-redirected purl on a lock the vendored backend refuses (a pre-version-2 `workspace:` lock → `vendor_bun_workspace_unsupported`; a malformed or unsupported binary lock → `vendor_bun_lockb_invalid`; an unsupported text-lock version → its code) is reported `failed ` with the hosted wiring, the redirect ledger and active Bun lock byte-untouched (exit 1 / `partial_failure`): the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed` code (exit-code parity with the wet run, nothing written) instead of promising `vendor_would_revert_redirect`. Pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. **golang** takes over the same way: the per-purl revert drops the module's hosted `replace`, removes the socket module's go.sum lines, puts the pruned upstream go.sum lines back in go's sort order, and drops the ledger record, so the vendored `replace` is recorded over pristine go.mod/go.sum (a go.mod whose replace for the module is no longer the recorded one refuses `redirect_revert_failed`). The separate run-level `vendor_supersedes_redirect` warning covers the reconcile-only case — a live lock that already proves vendored won over a stale hosted ledger record (the vendor wiring then holds the hosted-spliced fragment as `original`) — and fires exactly once, on the run that drops the stale records. `scan --apply` opts JSON callers into the full discover → select → apply pipeline. Without it, `scan --json` stays read-only (discovery + the `updates` array + the `redirectState` state block below). No effect outside `--json` mode. The non-JSON path prompts the user interactively in a TTY; when stdin is NOT a TTY (CI, a pipe), `--yes` is absent, and no intent flag (`--mode`, `--apply`, `--sync`, `--vendor`, `--redirect`, `--prune`) is given, a human-mode `scan` is **report-only** (v5.0): it prints the discovery report and the existing "To apply a single patch, run: …" hint, downloads nothing, writes nothing (no `.socket/`), and exits 0. Any intent flag, `--yes`, or a TTY keeps the previous behavior (prompt in a TTY, auto-proceed otherwise). Only `scan` gained this pre-check — `rollback`/`remove`/`get`'s non-TTY auto-accept is unchanged. @@ -821,7 +821,7 @@ Restore the system but keep the local patch state for a later re-apply: manifest ### Hosted unwind coverage -* **Per-purl reverts** exist for **cargo and the npm family** (`redirect_revert_supported`): staged, fail-closed on drift, and honoring `dry_run` (every inverse and drift check resolves like a wet run; nothing flushes and the ledger is untouched). npm purls on projects with bun-lock edits DEFER to the whole-ledger replay (below) whenever it will run — the scope covers every record, and the replay stages the bun group all-or-nothing. A SCOPED unwind (`rollback `, or `remove ` while other hosted records remain) takes the per-purl revert instead: it claims that purl's `redirect_bun_lock_package` edits by the recorded line's spec (`@` registry spec, or a hosted URL whose tarball leaf is `-.tgz`) and replays them like the yarn/pnpm text kinds (whole-line fragments, CRLF-exact); a sibling version's edit is neither claimed nor a refusal; an edit that mentions the package but is not a bun packages-entry line refuses with the unscoped-`rollback` remedy. Pinned by `tests/in_process_vendor_bun_takeover.rs` (`bun_scoped_rollback_of_one_of_two_hosted_records_unwinds_only_that_purl` and the `remove` twin). Native binary `redirect_bun_lockb_package` snapshots follow the same scoped ownership rule and restore only the claimed package records; unrelated binary resolutions stay intact. +* **Per-purl reverts** exist for **cargo, golang and the npm family** (`redirect_revert_supported`): staged, fail-closed on drift, and honoring `dry_run` (every inverse and drift check resolves like a wet run; nothing flushes and the ledger is untouched). npm purls on projects with bun-lock edits DEFER to the whole-ledger replay (below) whenever it will run — the scope covers every record, and the replay stages the bun group all-or-nothing. A SCOPED unwind (`rollback `, or `remove ` while other hosted records remain) takes the per-purl revert instead: it claims that purl's `redirect_bun_lock_package` edits by the recorded line's spec (`@` registry spec, or a hosted URL whose tarball leaf is `-.tgz`) and replays them like the yarn/pnpm text kinds (whole-line fragments, CRLF-exact); a sibling version's edit is neither claimed nor a refusal; an edit that mentions the package but is not a bun packages-entry line refuses with the unscoped-`rollback` remedy. Pinned by `tests/in_process_vendor_bun_takeover.rs` (`bun_scoped_rollback_of_one_of_two_hosted_records_unwinds_only_that_purl` and the `remove` twin). Native binary `redirect_bun_lockb_package` snapshots follow the same scoped ownership rule and restore only the claimed package records; unrelated binary resolutions stay intact. * **Whole-ledger reverse replay** (`revert_remaining_redirect_edits`, core `patch/redirect/replay.rs`) runs whenever the in-scope hosted record set equals the FULL ledger record set — however the scope was spelled (bare `rollback`, `rollback '**'`, an identifier set covering every record; `remove` reuses the same eligibility rule). It walks every remaining ledger edit in reverse write order through a **per-kind inverse table**, staged and committed **per ecosystem group, all-or-nothing**: one drifted, ambiguous (a fragment appearing more than once), or unhandled edit refuses the whole group byte-untouched while other groups proceed. This covers **gem, golang, pypi, composer, bun**, the yarn/pnpm text kinds (normally claimed by the per-purl npm revert first), and the **non-package rideshare edits** — the pnpm `trustLockfile` auto-config (a pristine created scaffold is deleted; a user-modified one keeps the file and loses only the `trustLockfile: true` line, warned as `redirect_pnpm_trust_scaffold_modified`) — plus a "last one out turns off the lights" pass: when the record map empties but non-package edits remain, they are replayed in the same persist, so the trust edit never strands. * **maven and nuget fail closed**: their structured-metadata kinds (`redirect_maven_repository` / `redirect_maven_dep_management` / `redirect_maven_config` / `redirect_maven_trusted_checksums`, `redirect_nuget_source` / `redirect_nuget_lock`) have no revert implementation, so any such edit refuses its whole group (the maven `` suffix rewrite alone IS invertible, but it rides the same all-or-nothing group). The refusal keeps their records + edits in the ledger and names the remedy: re-run `scan --mode hosted` to normalize, or restore the lockfiles from version control. Unknown future kinds refuse the same way (forward-compat). * **Scoped runs** (paths / identifiers / `--ecosystems`) that do NOT cover the full record set get per-purl reverts only; in-scope hosted purls of ecosystems without one fail closed — `rollback` reports them in `hosted.unsupported` (exit 1), `remove` as the top-level `hosted_revert_unsupported` error — with the remedy "run an unscoped `socket-patch rollback` to unwind ALL hosted redirects, or re-run `scan --mode hosted`". diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 5de59bde..3538d306 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -1619,6 +1619,9 @@ pub(crate) async fn vendor_records( let reverted_what = if candidate.starts_with("pkg:cargo/") { "the hosted edits (Cargo.toml registry pin, Cargo.lock \ source/checksum, registries block)" + } else if candidate.starts_with("pkg:golang/") { + "the hosted edits (go.mod replace, the socket module's go.sum \ + lines, the pruned upstream go.sum lines)" } else { "the hosted lockfile edits back to their pre-redirect \ registry values" diff --git a/crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs b/crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs index 38b5bca9..81157c77 100644 --- a/crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs +++ b/crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs @@ -300,6 +300,17 @@ async fn hosted_takeover_of_vendored_module_removes_vendored_state() { const TEXT_SUM: &str = "golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=\n\ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=\n"; +fn pristine_module(modcache: &Path) { + let module_dir = modcache.join(format!("{UMOD}@{UVER}")); + std::fs::create_dir_all(&module_dir).unwrap(); + std::fs::write( + module_dir.join("go.mod"), + format!("module {UMOD}\n\ngo 1.21\n"), + ) + .unwrap(); + std::fs::write(module_dir.join("lib.go"), PRISTINE_LIB).unwrap(); +} + /// Hosted `rollback` puts the pruned upstream go.sum pair back where go /// sorts it, so go.mod and go.sum return byte for byte. #[tokio::test(flavor = "multi_thread")] @@ -340,3 +351,90 @@ async fn hosted_rollback_restores_go_sum_byte_for_byte() { go_sum ); } + +/// hosted → vendored takeover: vendoring must first unwind the hosted +/// redirect (replace, socket go.sum lines, pruned upstream pair, ledger +/// record), so the project is fully vendored — never a vendored go.mod +/// beside a redirect ledger that still claims the module. +#[tokio::test(flavor = "multi_thread")] +async fn vendored_takeover_of_hosted_module_unwinds_the_redirect() { + let tmp = tempfile::tempdir().unwrap(); + let consumer = tmp.path().join("consumer"); + let go_sum = format!("{UPSTREAM_SUM}{TEXT_SUM}"); + write_consumer(&consumer, &format!("require {UMOD} {UVER}\n"), &go_sum); + let modcache = tmp.path().join("modcache"); + pristine_module(&modcache); + let server = MockServer::start().await; + mount_hosted_grant(&server).await; + let env = get_hosted(&consumer, &server, &modcache); + assert_eq!(env["redirect"]["redirected"], 1, "envelope: {env}"); + let ledger_path = consumer.join(".socket/vendor/redirect-state.json"); + assert!( + std::fs::read_to_string(&ledger_path) + .unwrap() + .contains(UPURL), + "precondition: the module is hosted-redirected" + ); + + let socket = consumer.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let after = compute_git_sha256_from_bytes(PATCHED_LIB.as_bytes()); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "patches": { UPURL: { + "uuid": UUID_V, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { "lib.go": { + "beforeHash": compute_git_sha256_from_bytes(PRISTINE_LIB.as_bytes()), + "afterHash": &after, + }}, + "vulnerabilities": {}, + "description": "vendored over hosted", + "license": "MIT", + "tier": "free", + }} + })) + .unwrap(), + ) + .unwrap(); + std::fs::write(socket.join("blobs").join(&after), PATCHED_LIB).unwrap(); + + let (code, stdout, stderr) = common::run_with_env( + &consumer, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + consumer.to_str().unwrap(), + ], + &[("GOMODCACHE", modcache.to_str().unwrap())], + ); + assert_eq!( + code, 0, + "vendor failed\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert!( + stdout.contains("vendor_takeover_reverted_redirect"), + "the takeover is reported: {stdout}" + ); + let go_mod = std::fs::read_to_string(consumer.join("go.mod")).unwrap(); + assert!( + go_mod.contains(&format!( + "replace {UMOD} {UVER} => ./.socket/vendor/golang/{UUID_V}/{UMOD}@{UVER}" + )), + "{go_mod}" + ); + assert!(!go_mod.contains("gopatch"), "{go_mod}"); + assert_eq!( + std::fs::read_to_string(consumer.join("go.sum")).unwrap(), + go_sum, + "go.sum is back to its pre-redirect bytes" + ); + let ledger = std::fs::read_to_string(&ledger_path).unwrap_or_default(); + assert!( + !ledger.contains(UPURL), + "the redirect ledger no longer claims the module: {ledger}" + ); +} diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index d10e1b3e..4ae25151 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -43,8 +43,8 @@ pub use state::{ CorruptRedirectState, RedirectState, REDIRECT_STATE_REL, }; pub use takeover::{ - redirect_revert_supported, revert_cargo_redirect_purl, revert_npm_redirect_purl, - revert_redirect_purl, RedirectRevert, + redirect_revert_supported, revert_cargo_redirect_purl, revert_golang_redirect_purl, + revert_npm_redirect_purl, revert_redirect_purl, RedirectRevert, }; /// One ecosystem's integrity hashes (mirrors the TS `PatchArtifactIntegrity`). diff --git a/crates/socket-patch-core/src/patch/redirect/replay.rs b/crates/socket-patch-core/src/patch/redirect/replay.rs index 08b2f201..9e66e094 100644 --- a/crates/socket-patch-core/src/patch/redirect/replay.rs +++ b/crates/socket-patch-core/src/patch/redirect/replay.rs @@ -1,8 +1,9 @@ //! Whole-ledger reverse replay of hosted-redirect edits. //! -//! The per-purl reverts in [`super::takeover`] cover cargo and the -//! npm-family lock flavors. Everything else the hosted rewriters touch — -//! gem, golang, pypi, composer, bun, and the non-package rideshare edits +//! The per-purl reverts in [`super::takeover`] cover cargo, the +//! npm-family lock flavors, and golang (which reuses the golang inverses +//! here). Everything else the hosted rewriters touch — +//! gem, pypi, composer, bun, and the non-package rideshare edits //! (such as the pnpm `trustLockfile` auto-config) — //! has no per-purl revert: their unwind rides the ledger's designed //! whole-list contract ("edits appended in write order, a revert walks diff --git a/crates/socket-patch-core/src/patch/redirect/takeover.rs b/crates/socket-patch-core/src/patch/redirect/takeover.rs index 631897bf..162c3a46 100644 --- a/crates/socket-patch-core/src/patch/redirect/takeover.rs +++ b/crates/socket-patch-core/src/patch/redirect/takeover.rs @@ -28,6 +28,11 @@ //! the pre-revert the package cannot be vendored at all //! (`vendor_lock_entry_not_found`). //! +//! golang: the module's go.mod `replace` and the socket module's go.sum +//! lines are removed and the pruned upstream go.sum lines come back in +//! go's sort order, so the vendor backend wires its `replace` over the +//! pristine files instead of taking over the hosted directive. +//! //! FAIL CLOSED: a file that matches neither the recorded redirected fragment //! nor the recorded original has drifted — the revert refuses (`Err`) rather //! than half-applying, and the caller must then refuse to vendor that purl. @@ -46,7 +51,10 @@ use std::sync::LazyLock; use regex::Regex; use serde_json::Value; -use crate::utils::purl::{canonical_purl, parse_cargo_purl, parse_name_version}; +use crate::utils::purl::{canonical_purl, parse_cargo_purl, parse_golang_purl, parse_name_version}; +use crate::vendor::go_mod_edit::{ + is_hosted_module_path, parse_replace_entries, HOSTED_GO_MODULE_PREFIX, +}; use super::staged::{flush_staged, read_rel, staged_read, Staged, StagedBytes}; use super::state::RedirectState; @@ -63,7 +71,9 @@ pub struct RedirectRevert { /// ecosystem? Callers (the vendor dispatch loop's cross-mode takeover gate) /// must consult this instead of hardcoding `pkg:cargo/`. pub fn redirect_revert_supported(purl: &str) -> bool { - purl.starts_with("pkg:cargo/") || purl.starts_with("pkg:npm/") + purl.starts_with("pkg:cargo/") + || purl.starts_with("pkg:npm/") + || purl.starts_with("pkg:golang/") } /// Revert every hosted-redirect edit the ledger records for `purl`, then @@ -88,6 +98,8 @@ pub async fn revert_redirect_purl( revert_cargo_redirect_purl(project_root, state, purl, dry_run).await } else if purl.starts_with("pkg:npm/") { revert_npm_redirect_purl(project_root, state, purl, dry_run).await + } else if purl.starts_with("pkg:golang/") { + revert_golang_redirect_purl(project_root, state, purl, dry_run).await } else { Err(format!( "no hosted-redirect revert implementation for {purl}" @@ -305,6 +317,116 @@ pub async fn revert_cargo_redirect_purl( Ok(out) } +/// Revert one Go module's hosted redirect: its go.mod `replace`, the +/// socket module's go.sum lines, and the pruned upstream go.sum pair, then +/// drop its record and edits from `state`. The claimed edits unwind through +/// the whole-ledger replay's golang inverses, staged all-or-nothing. A +/// go.mod whose directive for the module is no longer the recorded one has +/// drifted and refuses byte-untouched. +pub async fn revert_golang_redirect_purl( + project_root: &Path, + state: &mut RedirectState, + purl: &str, + dry_run: bool, +) -> Result { + let (record_key, target) = find_record_key(state, purl)?; + let Some((module, version)) = parse_golang_purl(&target) else { + return Err(format!("not a golang purl: {purl}")); + }; + let (module, version) = (module.into_owned(), version.into_owned()); + let lhs = format!("{module} {version} =>"); + let is_replace_edit = |e: &FileEdit| { + matches!( + e.kind.as_str(), + "redirect_golang_replace" | "redirect_golang_stale_replace_removed" + ) && e.key.as_deref() == Some(module.as_str()) + && [&e.new, &e.original].iter().any(|v| { + v.as_ref() + .and_then(Value::as_str) + .is_some_and(|t| t.contains(&lhs)) + }) + }; + // The socket modules this purl's directives pointed at: go.sum edits + // key by those, never by the upstream module. + let mut socket_modules: HashSet = HashSet::new(); + socket_modules.insert(format!( + "{HOSTED_GO_MODULE_PREFIX}{}", + state.records[&record_key].uuid + )); + for e in state.edits.iter().filter(|e| is_replace_edit(e)) { + for text in [&e.new, &e.original] + .into_iter() + .flatten() + .filter_map(Value::as_str) + { + for entry in parse_replace_entries(text) { + if let Some(rhs) = entry.rhs_module.filter(|m| is_hosted_module_path(m)) { + socket_modules.insert(rhs); + } + } + } + } + let prune_key = format!("{module}@{version}"); + let mine: Vec = state + .edits + .iter() + .enumerate() + .filter(|(_, e)| match e.kind.as_str() { + "redirect_golang_replace" | "redirect_golang_stale_replace_removed" => { + is_replace_edit(e) + } + "redirect_golang_gosum_prune" => e.key.as_deref() == Some(prune_key.as_str()), + "redirect_golang_gosum" => e + .key + .as_deref() + .and_then(|k| k.rsplit_once('@')) + .is_some_and(|(m, _)| socket_modules.contains(m)), + "redirect_golang_stale_gosum_removed" => { + e.key.as_deref().is_some_and(|k| socket_modules.contains(k)) + } + _ => false, + }) + .map(|(i, _)| i) + .collect(); + + // Drift: the newest recorded directive must still be live, or the + // module must carry no replace at all (already unwound). + let newest = mine + .iter() + .rev() + .map(|&i| &state.edits[i]) + .find(|e| e.kind == "redirect_golang_replace"); + if let Some(directive) = newest.and_then(|e| e.new.as_ref()).and_then(Value::as_str) { + let go_mod = read_rel(project_root, "go.mod").await?.unwrap_or_default(); + if !go_mod.contains(directive) + && parse_replace_entries(&go_mod) + .iter() + .any(|e| e.module == module) + { + return Err(format!( + "go.mod's replace for {module} has drifted from the recorded hosted \ + redirect; refusing to touch it — re-run `scan --mode hosted` to \ + normalize the redirect, or remove the replace manually, then re-run" + )); + } + } + + let mut claimed = RedirectState::new(); + claimed.edits = mine.iter().map(|&i| state.edits[i].clone()).collect(); + claimed + .records + .insert(record_key.clone(), state.records[&record_key].clone()); + let replay = + super::replay::revert_remaining_redirect_edits(project_root, &mut claimed, dry_run).await; + if let Some(refusal) = replay.refusals.first() { + return Err(refusal.reason.clone()); + } + drop_claimed(state, mine, &record_key); + Ok(RedirectRevert { + reverted_files: replay.reverted_files.into_iter().collect(), + }) +} + /// The npm-family text-fragment edit kinds CLAIMED BY KEY: `original`/`new` /// hold the whole lock fragment as a string, the edit's `key` embeds /// `@`, and the revert is a `replacen(new, original)`. @@ -3868,4 +3990,192 @@ mod tests { state.records.keys() ); } + + const GO_PURL: &str = "pkg:golang/example.com/lib@v1.10.0"; + const GO_UUID: &str = "7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a"; + const GO_OTHER_PURL: &str = "pkg:golang/example.com/other@v0.2.0"; + const GO_OTHER_UUID: &str = "8e9f0a1b-2c3d-4e4f-9a5b-6c7d8e9f0a1b"; + const GO_ZIP_H1: &str = "h1:mU9vN/n1hbXktM62lJ6MbRKOk3aI8NDH+szCf62RXtE="; + const GO_MOD_H1: &str = "h1:XgagPTRZSCprrzR+3Ro36/XJpibdovhAbsKThYI8bxg="; + + fn go_override(module: &str, version: &str, uuid: &str) -> crate::patch::redirect::DepOverride { + let socket_module = format!("patch.socket.dev/gopatch/{uuid}"); + let socket_version = format!("{version}-socketpatch.1"); + serde_json::from_value(serde_json::json!({ + "ecosystem": "golang", + "name": module, + "version": version, + "token": "", + "patchUuid": uuid, + "artifactUrl": format!("https://patch.socket.dev/{socket_module}/@v/{socket_version}.zip"), + "registryOverride": { + "kind": "goproxy", + "indexUrl": "https://patch.socket.dev", + "identifiers": { + "name": module, "version": version, + "goModulePath": socket_module, + "goModuleVersion": socket_version, + }, + }, + "integrity": { "dirhashH1": GO_ZIP_H1, "goModH1": GO_MOD_H1 }, + })) + .unwrap() + } + + /// go's own go.sum order: `v1.9.0/go.mod` sorts BEFORE `v1.10.0` + /// (semver), although it is bytewise greater. + fn go_pristine(eol: &str) -> (String, String) { + let go_mod = "module example.com/app\n\ngo 1.21\n\nrequire (\n\texample.com/lib v1.10.0\n\texample.com/other v0.2.0\n)\n" + .replace('\n', eol); + let go_sum = "example.com/leaf v1.0.0 h1:L=\n\ + example.com/leaf v1.0.0/go.mod h1:LM=\n\ + example.com/lib v1.9.0/go.mod h1:N9=\n\ + example.com/lib v1.10.0 h1:T=\n\ + example.com/lib v1.10.0/go.mod h1:TM=\n\ + example.com/other v0.2.0 h1:O=\n\ + example.com/other v0.2.0/go.mod h1:OM=\n" + .replace('\n', eol); + (go_mod, go_sum) + } + + /// Both modules hosted-redirected by the real rewriter, written to disk. + async fn go_redirected_fixture(eol: &str) -> (tempfile::TempDir, RedirectState) { + let tmp = tempfile::tempdir().unwrap(); + let (go_mod, go_sum) = go_pristine(eol); + let mut files: BTreeMap = BTreeMap::new(); + files.insert("go.mod".into(), go_mod); + files.insert("go.sum".into(), go_sum); + let rewrite = crate::patch::redirect::rewrite_registry_redirect( + &files, + &[ + go_override("example.com/lib", "v1.10.0", GO_UUID), + go_override("example.com/other", "v0.2.0", GO_OTHER_UUID), + ], + ); + assert!(rewrite.warnings.is_empty(), "{:?}", rewrite.warnings); + for (rel, content) in &rewrite.files { + tokio::fs::write(tmp.path().join(rel), content) + .await + .unwrap(); + } + let mut state = RedirectState::new(); + state.edits = rewrite.edits; + let mut lib = record(); + lib.uuid = GO_UUID.to_string(); + let mut other = record(); + other.uuid = GO_OTHER_UUID.to_string(); + state.records.insert(GO_PURL.to_string(), lib); + state.records.insert(GO_OTHER_PURL.to_string(), other); + (tmp, state) + } + + /// hosted → vendored takeover of one Go module: its replace and gopatch + /// go.sum lines go, its pruned go.sum pair comes back where go sorts it, + /// and its ledger record is dropped. The other hosted module is intact. + #[tokio::test] + async fn golang_per_purl_revert_unwinds_only_that_module() { + for eol in ["\n", "\r\n"] { + let (tmp, mut state) = go_redirected_fixture(eol).await; + let root = tmp.path(); + assert!(redirect_revert_supported(GO_PURL)); + revert_redirect_purl(root, &mut state, GO_PURL, false) + .await + .expect("golang takeover revert succeeds"); + + let go_mod = tokio::fs::read_to_string(root.join("go.mod")) + .await + .unwrap(); + let go_sum = tokio::fs::read_to_string(root.join("go.sum")) + .await + .unwrap(); + assert!(!go_mod.contains(GO_UUID), "{go_mod:?}"); + assert!( + go_mod.contains(&format!( + "replace example.com/other v0.2.0 => patch.socket.dev/gopatch/{GO_OTHER_UUID}" + )), + "{go_mod:?}" + ); + let expected_sum = format!( + "example.com/leaf v1.0.0 h1:L={eol}\ + example.com/leaf v1.0.0/go.mod h1:LM={eol}\ + example.com/lib v1.9.0/go.mod h1:N9={eol}\ + example.com/lib v1.10.0 h1:T={eol}\ + example.com/lib v1.10.0/go.mod h1:TM={eol}\ + patch.socket.dev/gopatch/{GO_OTHER_UUID} v0.2.0-socketpatch.1 {GO_ZIP_H1}{eol}\ + patch.socket.dev/gopatch/{GO_OTHER_UUID} v0.2.0-socketpatch.1/go.mod {GO_MOD_H1}{eol}" + ); + assert_eq!(go_sum, expected_sum, "eol {eol:?}"); + assert!(!state.records.contains_key(GO_PURL)); + assert!(state.records.contains_key(GO_OTHER_PURL)); + assert!( + state.edits.iter().all(|e| { + let text = format!("{:?}{:?}{:?}", e.key, e.new, e.original); + !text.contains(GO_UUID) && !text.contains("example.com/lib") + }), + "{:?}", + state.edits + ); + + // The remaining module unwinds through the same path, back to + // the pristine bytes. + revert_redirect_purl(root, &mut state, GO_OTHER_PURL, false) + .await + .expect("second revert succeeds"); + let (pristine_mod, pristine_sum) = go_pristine(eol); + assert_eq!( + tokio::fs::read_to_string(root.join("go.mod")) + .await + .unwrap(), + pristine_mod + ); + assert_eq!( + tokio::fs::read_to_string(root.join("go.sum")) + .await + .unwrap(), + pristine_sum + ); + assert!(state.records.is_empty() && state.edits.is_empty()); + } + } + + /// A go.mod whose socket replace was hand-edited away from the recorded + /// directive has drifted: refuse, byte-untouched, ledger kept. + #[tokio::test] + async fn golang_per_purl_revert_refuses_a_drifted_replace() { + let (tmp, mut state) = go_redirected_fixture("\n").await; + let root = tmp.path(); + let go_mod = tokio::fs::read_to_string(root.join("go.mod")) + .await + .unwrap(); + let drifted = go_mod.replace( + &format!("patch.socket.dev/gopatch/{GO_UUID} v1.10.0-socketpatch.1"), + "../my-fork", + ); + tokio::fs::write(root.join("go.mod"), &drifted) + .await + .unwrap(); + let go_sum = tokio::fs::read_to_string(root.join("go.sum")) + .await + .unwrap(); + let before = state.clone(); + + let err = revert_redirect_purl(root, &mut state, GO_PURL, false) + .await + .expect_err("a drifted replace refuses"); + assert!(err.contains("go.mod"), "{err}"); + assert_eq!( + tokio::fs::read_to_string(root.join("go.mod")) + .await + .unwrap(), + drifted + ); + assert_eq!( + tokio::fs::read_to_string(root.join("go.sum")) + .await + .unwrap(), + go_sum + ); + assert_eq!(state.records.len(), before.records.len()); + assert_eq!(state.edits.len(), before.edits.len()); + } } From 47a4996946fe0c4cbe0394febc7c20a0b68b92a1 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 24 Sep 2026 13:14:08 -0400 Subject: [PATCH 6/6] Honor GOPROXY off/direct and GOPRIVATE in fetch When a vendored Go module was missing from the module cache, the pristine fetch skipped off and direct entries in GOPROXY and fell back to proxy.golang.org. That sent module paths to the public proxy even when go itself would never ask one, including for GOPRIVATE modules. The fetch now follows go: if the first GOPROXY element is off or direct, or the module matches GONOPROXY (default GOPRIVATE), it is refused with the usual unverifiable warning and not-installed skip. An explicit SOCKET_GOPROXY still wins. Assisted-by: Claude Code:claude-opus-5-5 --- CHANGELOG.md | 6 + crates/socket-patch-cli/CLI_CONTRACT.md | 4 +- .../src/vendor/registry_fetch.rs | 196 +++++++++++++++--- 3 files changed, 173 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37434850..c236394e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -396,6 +396,12 @@ into the new version's section — see docs/releasing.md. takeover revert as cargo and npm (`vendor_takeover_reverted_redirect`), and scoped `rollback ` / `remove ` of one hosted Go module works without an unscoped rollback. +- **Vendored Go fetches honor `GOPROXY=off`, `direct` and `GOPRIVATE`.** + With the module missing from the module cache, the pristine fetch fell + back to `https://proxy.golang.org` even when go itself would ask no + proxy, sending private module paths off the machine. It is now refused + (`vendor_fetch_unverifiable`, then the usual `package_not_installed` + skip) unless `SOCKET_GOPROXY` names a proxy. - **A vendoring-service outage no longer re-vendors packages.** An npm re-run (every lock flavor, `bun.lockb` included) re-acquired its tarball from whichever source answered — the service's prebuilt, or a local pack diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 553a9121..54439bee 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -110,7 +110,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Lockfile supplement (v3.4)**: `scan` discovery is no longer limited to installed trees. The project's lockfiles (`package-lock.json`/`npm-shrinkwrap.json`, `pnpm-lock.yaml` v9, `yarn.lock` classic + berry, `bun.lock`, `Cargo.lock`, `go.sum`, `composer.lock`, `Gemfile.lock`, `uv.lock`/`poetry.lock`/pinned `requirements.txt`) are inventoried and dependencies with NO installed copy join discovery — counts, the API lookup, the table (flagged ` [NOT INSTALLED]`, plus a stderr note), and the prune "scanned" set (a wiped node_modules no longer prunes lockfile-listed entries). JSON gains a top-level `lockfileOnlyPackages` count and an additive `notInstalled: true` on matching `packages[]` entries. `--apply` partitions lockfile-only patches out BEFORE download (calm `skipped`/`package_not_installed` records — never an error exit, never a manifest write); `--vendor` passes them through to the vendor engine's auto-fetch. Vendored-ledger entries likewise stay discoverable on a fresh clone (the committed artifact is the dependency). Global scans (`--global`) get no supplement. **Rush monorepos** (no root lockfile, `rush.json` present): the npm-lock inventory falls back to the Rush source-of-truth locks — `common/config/rush/pnpm-lock.yaml` plus every `common/config/subspaces/*/pnpm-lock.yaml` (`read_dir`-sorted, repo-relative paths preserved) — so a Rush repo's dependencies still join discovery. **Plug'n'Play layouts are an explicit refusal, not an empty inventory**: a `.pnp.*` loader means the npm packages are structurally unreachable in EVERY mode (under yarn PnP the installed-tree crawl is empty too — no `node_modules/`), so `scan` surfaces an additive top-level `warnings[]` array (`{code, detail}` objects, omitted when empty) carrying `yarn_pnp_unsupported` (same code as apply's refusal; remedy `yarn patch `) or `pnpm_pnp_unsupported` (pnpm's `node-linker=pnp` twin; pnpm remedies), plus a stderr `Warning (): …` line on the human path. Exit code and `status` are deliberately unchanged (exit 0 / `success` — the same posture as hosted refusals, which exit 0 with `redirected: 0`); the warning is the machine-readable signal that nothing was checked. Pinned by `tests/e2e_safety_yarn_pnp.rs`. -**Vendor auto-fetch (v3.4)**: `vendor`/`scan --vendor` no longer fail on lockfile-resolved packages with no installed copy. Already-vendored purls stage from their committed artifact (sha256-verified against the vendor ledger; offline-safe). Otherwise the pristine artifact is fetched per the lockfile resolution and verified against the lock's recorded integrity FAIL-CLOSED before any write: npm SRI (or yarn classic's sha1 fragment), yarn berry's cache-zip checksum (rebuilt from the fetched tarball; cacheKey 10c0 only), Cargo.lock sha256 over the .crate, go.sum `h1:` dirhash over the module zip, composer `dist.shasum` (sha1), Gemfile.lock `CHECKSUMS` sha256, uv.lock wheel sha256 (pure `py3-none-any` wheels only). Entries the lock cannot verify are NEVER fetched (`vendor_fetch_unverifiable` warning + the calm `package_not_installed` skip). Registry bases honor `SOCKET_NPM_REGISTRY`, `SOCKET_CRATES_REGISTRY`, `SOCKET_GOPROXY` (else `GOPROXY`); npm/yarn/composer/gem/uv lock-recorded URLs are used verbatim. `--offline` refuses the fetch with the calm skip (the detail names the lockfile resolution). The fetch stages into a private tempdir — the project tree is never touched. +**Vendor auto-fetch (v3.4)**: `vendor`/`scan --vendor` no longer fail on lockfile-resolved packages with no installed copy. Already-vendored purls stage from their committed artifact (sha256-verified against the vendor ledger; offline-safe). Otherwise the pristine artifact is fetched per the lockfile resolution and verified against the lock's recorded integrity FAIL-CLOSED before any write: npm SRI (or yarn classic's sha1 fragment), yarn berry's cache-zip checksum (rebuilt from the fetched tarball; cacheKey 10c0 only), Cargo.lock sha256 over the .crate, go.sum `h1:` dirhash over the module zip, composer `dist.shasum` (sha1), Gemfile.lock `CHECKSUMS` sha256, uv.lock wheel sha256 (pure `py3-none-any` wheels only). Entries the lock cannot verify are NEVER fetched (`vendor_fetch_unverifiable` warning + the calm `package_not_installed` skip). Registry bases honor `SOCKET_NPM_REGISTRY`, `SOCKET_CRATES_REGISTRY`, `SOCKET_GOPROXY` (else `GOPROXY`, `GONOPROXY` and `GOPRIVATE` the way go reads them — see the env table); npm/yarn/composer/gem/uv lock-recorded URLs are used verbatim. `--offline` refuses the fetch with the calm skip (the detail names the lockfile resolution). The fetch stages into a private tempdir — the project tree is never touched. `scan --sync` is sugar for `--apply --prune` — the canonical single-flag bot invocation. `scan --json --sync --yes` discovers, applies, and reconciles state in one pass. @@ -988,7 +988,7 @@ Env-only knobs (no CLI flag) read by the vendor auto-fetch / artifact-rebuild pa |---|---|---| | `SOCKET_NPM_REGISTRY` | `https://registry.npmjs.org` | Base for conventional npm tarball URLs (vendor auto-fetch + the npm-family lockfile-integrity reconstruction rung in `repair`). | | `SOCKET_CRATES_REGISTRY` | `https://static.crates.io/crates` | crates.io static `.crate` download host. | -| `SOCKET_GOPROXY` | `https://proxy.golang.org` | Go module proxy. Wins over the standard `GOPROXY` env var, whose first non-`direct`/`off` element is used otherwise. | +| `SOCKET_GOPROXY` | `https://proxy.golang.org` | Go module proxy. Wins over the standard `GOPROXY` env var, whose first element is used otherwise. When that element is `off` or `direct`, or the module matches `GONOPROXY` (default `GOPRIVATE`), go would not ask a proxy, so the pristine fetch is refused (`vendor_fetch_unverifiable` + the calm `package_not_installed` skip) instead of falling back to `proxy.golang.org`. | | `SOCKET_MAVEN_REGISTRY` | `https://repo1.maven.org/maven2` | maven2 base for the fallback upstream-pom download. | ### Internal env vars (no stability guarantee) diff --git a/crates/socket-patch-core/src/vendor/registry_fetch.rs b/crates/socket-patch-core/src/vendor/registry_fetch.rs index e5d4f488..20744068 100644 --- a/crates/socket-patch-core/src/vendor/registry_fetch.rs +++ b/crates/socket-patch-core/src/vendor/registry_fetch.rs @@ -471,27 +471,84 @@ async fn fetch_cargo( }) } -/// Default Go module proxy; `SOCKET_GOPROXY` wins, else the standard -/// `GOPROXY` env (first element that isn't `direct`/`off`). +/// go's default module proxy (the first element of go's default +/// `GOPROXY=https://proxy.golang.org,direct`). pub const DEFAULT_GOPROXY: &str = "https://proxy.golang.org"; -fn goproxy_base() -> String { +/// The module proxy go itself would ask for `module`, or `Err` when go would +/// not use a proxy for it: GOPROXY's first element is `off` or `direct`, or +/// the module matches GONOPROXY (defaulting to GOPRIVATE). Falling back to a +/// public proxy there would send a private module path off the machine. +/// A non-empty `SOCKET_GOPROXY` is an explicit choice and always wins. +fn goproxy_base(module: &str) -> Result { if let Ok(v) = std::env::var("SOCKET_GOPROXY") { let v = v.trim_end_matches('/').to_string(); if !v.is_empty() { - return v; + return Ok(v); } } - if let Ok(v) = std::env::var("GOPROXY") { - // GOPROXY is a comma- OR pipe-separated list (go help goproxy). - for part in v.split([',', '|']) { - let part = part.trim().trim_end_matches('/'); - if !part.is_empty() && part != "direct" && part != "off" { - return part.to_string(); - } + let nonempty = |key: &str| std::env::var(key).ok().filter(|v| !v.trim().is_empty()); + if let Some((key, patterns)) = nonempty("GONOPROXY") + .map(|v| ("GONOPROXY", v)) + .or_else(|| nonempty("GOPRIVATE").map(|v| ("GOPRIVATE", v))) + { + if go_match_prefix_patterns(&patterns, module) { + return Err(format!( + "{module} matches {key}, so go fetches it directly, never through a \ + module proxy; not fetching it (set SOCKET_GOPROXY to name a proxy \ + that serves it)" + )); } } - DEFAULT_GOPROXY.to_string() + let goproxy = nonempty("GOPROXY").unwrap_or_else(|| format!("{DEFAULT_GOPROXY},direct")); + // A comma- OR pipe-separated list (go help goproxy); go tries the first + // element first, and `off` / `direct` there mean no proxy is consulted. + let first = goproxy + .split([',', '|']) + .map(|part| part.trim().trim_end_matches('/')) + .find(|part| !part.is_empty()) + .unwrap_or(DEFAULT_GOPROXY); + match first { + "off" => Err("GOPROXY=off disables module downloads; not fetching".to_string()), + "direct" => Err( + "GOPROXY=direct fetches modules from their version control origin, which \ + socket-patch does not do; not fetching (set SOCKET_GOPROXY to name a proxy)" + .to_string(), + ), + proxy => Ok(proxy.to_string()), + } +} + +/// `golang.org/x/mod/module.MatchPrefixPatterns`: does any comma-separated +/// glob match a leading path-element prefix of `target`? A glob with +/// syntax this matcher does not implement (`[...]`, `\`) counts as a +/// match, so an unrecognized private pattern never leaks a module path. +fn go_match_prefix_patterns(globs: &str, target: &str) -> bool { + globs + .split(',') + .map(str::trim) + .filter(|g| !g.is_empty()) + .any(|glob| { + let elements = glob.matches('/').count() + 1; + let prefix: Vec<&str> = target.splitn(elements + 1, '/').take(elements).collect(); + prefix.len() == elements + && (glob.contains(['[', '\\']) + || go_glob_match(glob.as_bytes(), prefix.join("/").as_bytes())) + }) +} + +/// `path.Match` for `*` and `?` (neither crosses a `/`) and literals. +fn go_glob_match(pattern: &[u8], name: &[u8]) -> bool { + match pattern.split_first() { + None => name.is_empty(), + Some((b'*', rest)) => (0..=name.len()) + .take_while(|&i| i == 0 || name[i - 1] != b'/') + .any(|i| go_glob_match(rest, &name[i..])), + Some((b'?', rest)) => { + name.first().is_some_and(|&c| c != b'/') && go_glob_match(rest, &name[1..]) + } + Some((&c, rest)) => name.first() == Some(&c) && go_glob_match(rest, &name[1..]), + } } /// go.sum's `h1:` dirhash over a module zip: sha256 of the sorted @@ -674,14 +731,15 @@ async fn fetch_golang( "go module entries verify via the go.sum h1 dirhash only".to_string(), )); }; - let url = entry.resolved.clone().unwrap_or_else(|| { - format!( + let url = match &entry.resolved { + Some(url) => url.clone(), + None => format!( "{}/{}/@v/{}.zip", - goproxy_base(), + goproxy_base(&entry.name).map_err(FetchError::Unverifiable)?, encode_module_path(&entry.name), encode_module_path(&entry.version) - ) - }); + ), + }; let bytes = download(client, &url).await.map_err(FetchError::Failed)?; let actual = go_h1_of_zip(&bytes).map_err(FetchError::Failed)?; if &actual != expected { @@ -2159,6 +2217,7 @@ mod tests { #[test] #[serial_test::serial] fn goproxy_base_splits_on_pipe_separator() { + const MODULE: &str = "example.com/m"; // GOPROXY is a comma- OR pipe-separated list (go help goproxy); a // pipe-separated value must yield the first usable proxy, not a // `https://a|b`-shaped base that builds an unparseable URL. @@ -2169,9 +2228,9 @@ mod tests { "GOPROXY", "https://athens.example|https://proxy.golang.org|direct", ); - let piped = goproxy_base(); - std::env::set_var("GOPROXY", "off|https://mirror.example/,direct"); - let mixed = goproxy_base(); + let piped = goproxy_base(MODULE); + std::env::set_var("GOPROXY", "https://mirror.example/,direct"); + let mixed = goproxy_base(MODULE); match saved { Some(v) => std::env::set_var("GOPROXY", v), None => std::env::remove_var("GOPROXY"), @@ -2180,8 +2239,8 @@ mod tests { Some(v) => std::env::set_var("SOCKET_GOPROXY", v), None => std::env::remove_var("SOCKET_GOPROXY"), } - assert_eq!(piped, "https://athens.example"); - assert_eq!(mixed, "https://mirror.example"); + assert_eq!(piped.as_deref(), Ok("https://athens.example")); + assert_eq!(mixed.as_deref(), Ok("https://mirror.example")); } #[tokio::test] @@ -2557,27 +2616,99 @@ mod tests { assert!(fetched.dir().join("go.mod").is_file()); } + /// go never sends a module path to a proxy when GOPROXY starts with + /// `off` / `direct`, or when the module matches GONOPROXY (defaulting to + /// GOPRIVATE). The pristine fetch must not either: it refuses before any + /// network I/O instead of falling back to proxy.golang.org. + #[tokio::test] + #[serial_test::serial] + async fn golang_fetch_never_uses_a_proxy_go_would_not() { + let mock = MockServer::start().await; + let entry = LockfileEntry { + ecosystem: "golang", + name: "example.com/private/mod".into(), + version: "v1.0.0".into(), + purl: "pkg:golang/example.com/private/mod@v1.0.0".into(), + resolved: None, + integrity: LockIntegrity::GoH1("h1:AAAA".into()), + }; + let keys = ["SOCKET_GOPROXY", "GOPROXY", "GOPRIVATE", "GONOPROXY"]; + let saved: Vec> = keys.iter().map(|k| std::env::var(k).ok()).collect(); + for k in keys { + std::env::remove_var(k); + } + let proxy = mock.uri(); + let cases: Vec<(String, &str, &str, bool)> = vec![ + ("off".into(), "", "", false), + ("direct".into(), "", "", false), + (format!("off,{proxy}"), "", "", false), + (format!("direct|{proxy}"), "", "", false), + (proxy.clone(), "example.com/private", "", false), + (proxy.clone(), "example.com/*", "", false), + (proxy.clone(), "*.example", "", true), + (proxy.clone(), "example.com/private", "other.example", true), + ]; + let mut outcomes = Vec::new(); + for (goproxy, goprivate, gonoproxy, uses_proxy) in &cases { + std::env::set_var("GOPROXY", goproxy); + std::env::set_var("GOPRIVATE", goprivate); + std::env::set_var("GONOPROXY", gonoproxy); + let result = fetch_and_stage(&entry, &build_registry_client()).await; + outcomes.push(( + goproxy.clone(), + *goprivate, + *gonoproxy, + *uses_proxy, + result.err(), + )); + } + for (k, v) in keys.iter().zip(saved) { + match v { + Some(v) => std::env::set_var(k, v), + None => std::env::remove_var(k), + } + } + for (goproxy, goprivate, gonoproxy, uses_proxy, err) in &outcomes { + let case = format!("GOPROXY={goproxy} GOPRIVATE={goprivate} GONOPROXY={gonoproxy}"); + if *uses_proxy { + assert!( + matches!(err, Some(FetchError::Failed(_))), + "{case}: {err:?}" + ); + } else { + assert!( + matches!(err, Some(FetchError::Unverifiable(d)) if d.contains("GO")), + "{case}: {err:?}" + ); + } + } + let hits = mock.received_requests().await.unwrap_or_default().len(); + assert_eq!(hits, 2, "only the two proxy-eligible cases reach the proxy"); + } + #[test] #[serial_test::serial] fn goproxy_base_env_precedence() { + const MODULE: &str = "example.com/m"; let saved_socket = std::env::var("SOCKET_GOPROXY").ok(); let saved = std::env::var("GOPROXY").ok(); // SOCKET_GOPROXY wins over GOPROXY (trailing slash trimmed). std::env::set_var("SOCKET_GOPROXY", "https://socket.example/"); std::env::set_var("GOPROXY", "https://ignored.example"); - let socket_wins = goproxy_base(); + let socket_wins = goproxy_base(MODULE); // An EMPTY SOCKET_GOPROXY falls through to GOPROXY. std::env::set_var("SOCKET_GOPROXY", ""); std::env::set_var("GOPROXY", "https://fallback.example"); - let empty_falls_through = goproxy_base(); + let empty_falls_through = goproxy_base(MODULE); // Neither set → the default proxy. std::env::remove_var("SOCKET_GOPROXY"); std::env::remove_var("GOPROXY"); - let neither = goproxy_base(); - // A GOPROXY of only direct/off parts is unusable → the default. + let neither = goproxy_base(MODULE); + // A GOPROXY led by direct/off consults no proxy: refused, never the + // default proxy. std::env::set_var("GOPROXY", "direct,off"); - let all_unusable = goproxy_base(); + let no_proxy = goproxy_base(MODULE); match saved_socket { Some(v) => std::env::set_var("SOCKET_GOPROXY", v), @@ -2587,10 +2718,13 @@ mod tests { Some(v) => std::env::set_var("GOPROXY", v), None => std::env::remove_var("GOPROXY"), } - assert_eq!(socket_wins, "https://socket.example"); - assert_eq!(empty_falls_through, "https://fallback.example"); - assert_eq!(neither, DEFAULT_GOPROXY); - assert_eq!(all_unusable, DEFAULT_GOPROXY); + assert_eq!(socket_wins.as_deref(), Ok("https://socket.example")); + assert_eq!( + empty_falls_through.as_deref(), + Ok("https://fallback.example") + ); + assert_eq!(neither.as_deref(), Ok(DEFAULT_GOPROXY)); + assert!(no_proxy.is_err(), "{no_proxy:?}"); } #[test]