Skip to content

Stop hosted Go redirects claiming unpatched deps - #252

Merged
Mikola Lysenko (mikolalysenko) merged 7 commits into
mainfrom
fix/golang-replace-ownership
Sep 24, 2026
Merged

Mikola Lysenko (mikolalysenko) merged 7 commits into
mainfrom
fix/golang-replace-ownership

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

LLM Description written by Claude Code:claude-opus-5-5

Summary

An audit of the Go paths turned up seven suspected bugs. Each one below was first reproduced by a new test that failed on main; all seven reproduced and are fixed here. Follow-up commits fix four more Go-path bugs that a compatibility capture run found (B9–B12 below), plus Bugbot's reconcile regression from the B4 fix. The worst one: hosted mode could record a Go module in the redirect ledger, so vex attested the patch, even though the go.mod rewrite had been refused and the build still linked the unpatched module.

B1: hosted confirmation matched the goproxy indexUrl

  • Input: a polyglot repo whose package-lock.json is already hosted-redirected to https://patch.socket.dev/..., plus a Go dep whose rewrite is refused (for example, a user replace M V => ../my-fork). A related input: stale patch.socket.dev/gopatch/<uuid> go.sum lines left behind after the user removed the replace, with the graph now at another version.
  • Wrong result: redirected: 1, a ledger record, and a VEX attestation for an unpatched module. The confirmation probe used text.contains(indexUrl), and for goproxy that is the bare origin https://patch.socket.dev. It also matched on the module path in any file, go.sum included.
  • Fix: the golang rewriter now reports confirmed_golang_uuids, which requires the go.mod replace plus both go.sum lines, written by this run or already in place. Hosted confirmation for pkg:golang/ uses that set alone, like cargo. The substring probe no longer looks at Go module paths.

B2: hosted takeover of a vendored Go module left the vendored state behind

  • Input: vendor M, then get/scan --mode hosted for M.
  • Wrong result: go.mod ended up hosted, while .socket/vendor/state.json still claimed M and .socket/vendor/golang/<uuid>/ stayed on disk. The next vendor run switched the module back to vendored.
  • Fix: pkg:golang/ joins cargo and npm as takeover-capable. The existing per-purl revert_go_vendor runs first (wiring, copy and ledger entry), then the redirect.

B3: hosted wrote a replace for a module outside the build graph

  • Input: local-mode discovery crawls the whole GOMODCACHE, so a module that another project downloaded gets granted here. It is not in require, and go.sum has no line for it at that version.
  • Wrong result: an inert replace plus go.sum lines, and the patch confirmed.
  • Fix: such a module is refused with the new additive warning redirect_golang_not_in_module_graph, and nothing is written. Three things still count as membership: a require entry, go.sum lines at that version (a go 1.16 transitive dep), or our own replace already pinned at that version (a re-run after the prune).

B4: parse_golang_purl did not percent-decode

  • Input: pkg:golang/github.com/foo/bar@v2.0.0%2Bincompatible.
  • Wrong result: the vendor and apply paths used the version v2.0.0%2Bincompatible. The crawler looked for bar@v2.0.0%2!bincompatible and never found the module. If it had, the replace and the copy directory would have carried %2B.
  • Fix: each component is decoded after the split and before the path-safety guards, the same way hosted mode already did. The function now returns Cow, and call sites shadow to &str.

B6: ownership detection used substring and prefix matching

  • Input: a user's replace M V => ../sibling/.socket/vendor/golang/<uuid>/M@V, or a replace onto patch.socket.dev/gopatch/<uuid>/extra.
  • Wrong result: the CLI treated the directive as socket-owned, so vendor, apply or rollback could rewrite or drop it.
  • Fix: a path is socket-owned only when it sits under ./.socket/vendor/golang/ or ./.socket/go-patches/ at the project root. A hosted module is socket-owned only if it is exactly patch.socket.dev/gopatch/<canonical uuid> (the new is_hosted_module_path). The hosted rewriter now refuses any other module path with redirect_golang_untrusted_module_path.

B7: only the first socket-owned directive was refreshed

  • Input: a merge that leaves both a vendored and a hosted replace M V line.
  • Wrong result: one line was refreshed and the duplicate stayed, so go rejected the go.mod.
  • Fix: the upsert rewrites the first socket-owned line and drops the rest, along with the blank separator line the upsert had added.

B8: an in-place rewrite dropped trailing comments, and quoted paths were not unquoted

  • Input: replace M V => ./.socket/go-patches/M@V // CVE-…. A second input: require "github.com/x/y" v1.4.2 and replace "github.com/x/y" v1.4.2 => ../fork.
  • Wrong result: the comment was lost on refresh. The quoted user replace was invisible to the conflict check, so a duplicate directive was appended, and the quoted require skipped the stale-pin check.
  • Fix: the trailing // … is kept on refresh. The replace and require parsers now unquote "…" and `…` tokens.

B9: hosted rollback put the pruned go.sum pair back in the wrong place

  • Input: get --mode hosted for M, then rollback, on a go.sum where M's lines are not last.
  • Wrong result: M's pruned pair was appended at the end of go.sum, so go.sum did not come back byte for byte. This showed up in 226 of 292 hosted capture runs. CRLF go.mod/go.sum also unwound with the socket go.sum lines left in place (they were matched as an LF-joined fragment) and blank lines left in go.mod.
  • Fix: removed go.sum lines go back at go's sorted position (module path, then semver version, then /go.mod, then hash). The socket module's go.sum lines are removed line by line, the duplicate-line ambiguity guard still applies, and removing a fragment takes its \r\n with it.

B10: vendoring over a hosted Go module left the hosted state behind (B2 in reverse)

  • Input: get --mode hosted for M, then vendor or get --mode vendored.
  • Wrong result: the vendored replace overwrote the hosted one in place. redirect-state.json kept M's record, go.sum kept the patch.socket.dev/gopatch/<uuid> lines, and the upstream pair stayed pruned, so vendor --revert left a go.sum missing M.
  • Fix: pkg:golang/ joins cargo and npm in redirect_revert_supported. The new revert_golang_redirect_purl claims the purl's replace, go.sum and prune edits, unwinds them through the golang replay inverses (staged all-or-nothing), and drops the record. A go.mod replace for M that no longer matches the recorded one refuses as redirect_revert_failed, leaving the files untouched. This also makes scoped rollback <purl> / remove <purl> work for a single hosted Go module.

B11: vendored fetch ignored GOPROXY=off/direct and GOPRIVATE

  • Input: a vendored run with the module missing from GOMODCACHE, and GOPROXY=off or direct, or a module that matches GOPRIVATE/GONOPROXY.
  • Wrong result: goproxy_base() skipped off/direct and fell back to https://proxy.golang.org, which sent the module path to the public proxy.
  • Fix: the fetch follows go's rules: the first GOPROXY element decides, and a match in GONOPROXY (default GOPRIVATE, using MatchPrefixPatterns semantics) means no proxy. Those cases are refused before any network I/O (vendor_fetch_unverifiable, then package_not_installed). An explicit SOCKET_GOPROXY still wins.

B12: a failed vendored get kept the patch in .socket/manifest.json

Bugbot: reconcile pruned decoded +incompatible copies

  • Bugbot Autofix pushed the fix, 943bbfd: reconcile_go_redirects now compares by canonical_purl. The follow-up commit only adds its CHANGELOG entry.

Tests

  • New crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs (real binary + wiremock, no go toolchain needed): refused_go_rewrite_is_not_confirmed_by_another_lockfile (B1), leftover_go_sum_lines_do_not_confirm_a_refused_rewrite (B1/B3), hosted_takeover_of_vendored_module_removes_vendored_state (B2).
  • Core unit tests:
    • golang_module_outside_the_graph_is_refused (B3)
    • golang_confirms_only_landed_redirects (B1)
    • golang_module_path_with_extra_segments_refused (B6)
    • test_parse_golang_purl_percent_decodes and test_find_by_purls_percent_encoded_version (B4)
    • test_detect_owner_ignores_foreign_socket_dirs and test_hosted_owner_requires_exact_uuid_module (B6)
    • test_upsert_collapses_duplicate_socket_directives (B7)
    • test_upsert_refresh_keeps_trailing_comment and test_quoted_module_paths_are_unquoted (B8)
  • Follow-up tests, each failing before its fix:
    • e2e_golang_hosted_state.rs: hosted_rollback_restores_go_sum_byte_for_byte (B9) and vendored_takeover_of_hosted_module_unwinds_the_redirect (B10)
    • replay::reinsert_restores_the_go_sorted_position (B9, LF and CRLF)
    • takeover::golang_per_purl_revert_unwinds_only_that_module (B10, LF and CRLF, two modules) and golang_per_purl_revert_refuses_a_drifted_replace
    • registry_fetch::golang_fetch_never_uses_a_proxy_go_would_not (B11). The two older goproxy_base_* tests are re-pinned: an off/direct first element now refuses instead of falling back.
  • All 11 original new tests (8 core, 3 CLI) failed on main and pass here. Existing fixtures that used gopatch/some-uuid now use a canonical uuid, and detect_owner("sub/.socket/go-patches/…") is now None.
  • cargo test --workspace --all-features --no-fail-fast (with the follow-ups): 8029 passed. The only failures are the 12 tests in setup_matrix_gem.rs, and they are environmental: this host's bundler is 1.17.2, which setup correctly refuses. The real-go capstones (e2e_golang_hosted_build, e2e_vendor_golang_build, e2e_golang_build, e2e_golang_redirect) ran against go 1.26.3 and pass.
  • cargo clippy --workspace --all-features -- -D warnings (with and without --tests): clean.
  • Compatibility capture with this branch's release binary, +incompatible shape, 12 native go toolchains (1.16–1.27) × 4 vendored modes: every build, replace, go list, run, tidy, repeat and rollback check passes. The only failing check is the depscan harness's manifestPresence expectation for vendored-get / vendored-service (24 of 48). That expectation predates Cleanup: no .socket residue, locks that never outlive a command, manifest-free vendored mode #247's manifest-free vendored mode and is a harness update, not a CLI bug.
  • cargo fmt --check is not clean on main itself (about 60 pre-existing files). The hunks in this PR are rustfmt-clean.

CHANGELOG [Unreleased] › Fixed and the CLI_CONTRACT hosted confirmation rules are updated. The follow-ups also update CLI_CONTRACT's takeover reconciliation, per-purl revert list and SOCKET_GOPROXY rows. redirect_golang_not_in_module_graph is a new additive code.

🤖 Generated with Claude Code


Note

Medium Risk
Changes hosted-mode ledger/VEX semantics and go.mod rewrite rules for Go; incorrect confirmation was a security/attestation bug, but behavior shifts may surprise repos that relied on false positives or loose ownership matching.

Overview
Hosted Go redirects now only count a module as redirected when the rewriter reports it in confirmed_golang_uuids (go.mod replace plus both socket-module go.sum lines)—not when unrelated files mention the patch-server origin or stale go.sum lines. Refused rewrites no longer land in the redirect ledger or get VEX attestation.

Vendored → hosted for Go matches cargo/npm: the hosted run reverts vendored wiring, the committed copy, and the vendor ledger entry before applying the hosted redirect.

Graph and ownership guards: modules absent from require and go.sum at the patched version are refused with redirect_golang_not_in_module_graph. Socket-owned replace targets are limited to ./.socket/go-patches/…, ./.socket/vendor/golang/…, and exactly patch.socket.dev/gopatch/<canonical uuid>; foreign or deeper paths are left alone or refused.

PURL and go.mod editing: golang PURLs percent-decode versions (e.g. %2Bincompatible). The go.mod editor collapses duplicate socket-owned replaces, preserves trailing // comments, and unquotes quoted module paths in require/replace.

CHANGELOG, CLI contract, core unit tests, and new CLI e2e tests (e2e_golang_hosted_state.rs) document and lock in the behavior.

Reviewed by Cursor Bugbot for commit ad8d067. Configure here.

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/<uuid> 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

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Reconcile drops decoded Go copies
    • Changed reconcile_go_redirects to use canonical_purl instead of strip_purl_qualifiers, which now properly decodes percent-encoded characters like %2B to + for matching manifest keys against disk-based PURLs.

Create PR

You can send follow-ups to the cloud agent here.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit ad8d067. Configure here.

Comment thread crates/socket-patch-core/src/patch/redirect/golang_local.rs
…mparison

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.
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
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
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
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
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

[agent] I added four signed commits on top of Bugbot's autofix 943bbfd, which fixed the +incompatible reconcile regression the Bugbot review flagged:

  • 0f19e6d Log the +incompatible reconcile fix: adds a CHANGELOG entry for 943bbfd.
  • 5db4858 Restore go.sum exactly on hosted Go rollback (B9): the pruned upstream pair now goes back in go's sort order, and CRLF go.mod/go.sum unwind cleanly.
  • dd64a11 Unwind hosted Go redirects before vendoring (B10, B2 in reverse): adds a per-purl golang hosted revert, so vendor takeover leaves no stale redirect record and no gopatch go.sum lines, and restores the upstream go.sum lines. Scoped Go rollback/remove works too.
  • 47a4996 Honor GOPROXY off/direct and GOPRIVATE in fetch (B11): the vendored pristine fetch no longer falls back to proxy.golang.org when go itself would not use a proxy.

Each fix started with a failing test. Full workspace tests: 8029 passed, and the only failures are the known environmental gem/bundler ones. clippy -D warnings is clean. The description now covers B9–B12. B12, the failed vendored get that kept its manifest entry, is already fixed on main by #247.

Resolves the CHANGELOG/CLI_CONTRACT and go_sum_edit.rs conflicts by
keeping both sides. The hosted ownership check now accepts the same
patch.socket.dev/gopatch/<uuid>[/vN] shapes VEX discovery does, and
VEX discovery still flags Socket-looking replace targets the CLI
does not own (another checkout's vendored copy, an absolute path, a
malformed hosted module) instead of skipping them silently.

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

[agent] Merged main (#251, v5 manifest-less VEX) into this branch as signed merge 4be201a (no force-push). Conflicts in CHANGELOG/CLI_CONTRACT and go_sum_edit.rs were resolved by keeping both sides, plus two compile fixes after the merge (RedirectRevert.warnings now forwards replay warnings; a test LockfileEntry gains source_kind). One behavioral interaction: #251 VEX discovery relied on the looser ownership check, so is_hosted_module_path now delegates to hosted_module_uuid (the /vN major suffix is accepted), and discovery gains is_foreign_socket_target so Socket-looking targets we do not own are still diagnosed patched_ref_invalid. Local results: clippy -D warnings clean; socket-patch-core all tests pass; Go CLI e2e tests (hosted_state, golang, redirect, hosted_build, vendor_golang_build) pass. The only local failures are the 13 gem setup_matrix_gem tests, which need a newer bundler than this machine has (1.17).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants