Skip to content

Commit 872b591

Browse files
Stop hosted Go redirects claiming unpatched deps (#252)
* 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/<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 * 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. * 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 * 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 * 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 * 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 --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent cf8150b commit 872b591

17 files changed

Lines changed: 1690 additions & 130 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,63 @@ into the new version's section — see docs/releasing.md.
502502

503503
### Fixed
504504

505+
- **Hosted Go redirects no longer claim patches that did not land.**
506+
`scan`/`get --mode hosted` counted a Go module as redirected (recorded
507+
it in the redirect ledger, so `vex` attested it) whenever any project
508+
file contained the patch-server origin — e.g. an already hosted
509+
`package-lock.json` — or leftover `patch.socket.dev/gopatch/…` go.sum
510+
lines, even when the go.mod rewrite was refused. A Go module now counts
511+
only when its go.mod `replace` and both go.sum lines are in place. A
512+
module that go.mod does not require and go.sum does not list at the
513+
patched version (another project's module found in the shared module
514+
cache) is refused with `redirect_golang_not_in_module_graph` instead of
515+
getting an inert `replace`.
516+
- **Switching a vendored Go module to hosted mode cleans up the vendored
517+
copy.** `scan`/`get --mode hosted` rewrote the vendored `replace` but
518+
left `.socket/vendor/golang/<uuid>/` and its vendor-ledger entry
519+
behind, so the next `vendor` run switched the module back. The hosted
520+
run now reverts the vendored state first, as it already did for cargo
521+
and npm.
522+
- **Go `replace` directives the CLI did not write are left alone.** A
523+
replace onto another checkout's vendored copy
524+
(`../other/.socket/vendor/golang/…`) or onto a module under
525+
`patch.socket.dev/gopatch/` other than `<patch uuid>` was treated as
526+
socket-owned and could be rewritten or removed. Only
527+
`./.socket/vendor/golang/…`, `./.socket/go-patches/…` and exactly
528+
`patch.socket.dev/gopatch/<uuid>` are now owned.
529+
- **Go replace edits keep go.mod valid and readable.** A go.mod carrying
530+
two socket-owned `replace` lines for one module (for example after a
531+
merge) is collapsed to one instead of leaving a duplicate go rejects;
532+
refreshing a directive keeps its trailing `// comment`; and quoted
533+
module paths (`"github.com/x/y"`) are recognized, so a quoted user
534+
replace is no longer duplicated and a quoted `require` still gets the
535+
version check.
536+
- **`+incompatible` Go modules resolve in vendor and apply.** Purls that
537+
spell the version `v2.0.0%2Bincompatible` are now percent-decoded, so
538+
the module is found in the module cache and the `replace` and copy
539+
directory carry the real `+incompatible` version.
540+
- **`+incompatible` Go copies survive `apply` reconcile.** A manifest key
541+
spelled `%2Bincompatible` no longer makes the freshly applied
542+
`.socket/go-patches/…@v2.0.0+incompatible` copy look orphaned, so it and
543+
its `replace` are kept.
544+
- **Hosted Go rollback restores go.sum byte for byte.** The upstream
545+
module's go.sum lines that the redirect pruned went back at the end of
546+
the file; they now return to the position `go mod tidy` sorts them to
547+
(semver order within a module). CRLF go.mod/go.sum files also unwind
548+
cleanly, with no leftover socket lines or blank lines.
549+
- **Vendoring a hosted Go module unwinds the hosted redirect first.**
550+
`vendor` / `get --mode vendored` over a hosted-redirected Go module left
551+
its redirect-ledger record and the socket module's go.sum lines behind
552+
(with the upstream lines still pruned). Go now takes the same per-purl
553+
takeover revert as cargo and npm (`vendor_takeover_reverted_redirect`),
554+
and scoped `rollback <purl>` / `remove <purl>` of one hosted Go module
555+
works without an unscoped rollback.
556+
- **Vendored Go fetches honor `GOPROXY=off`, `direct` and `GOPRIVATE`.**
557+
With the module missing from the module cache, the pristine fetch fell
558+
back to `https://proxy.golang.org` even when go itself would ask no
559+
proxy, sending private module paths off the machine. It is now refused
560+
(`vendor_fetch_unverifiable`, then the usual `package_not_installed`
561+
skip) unless `SOCKET_GOPROXY` names a proxy.
505562
- **A vendoring-service outage no longer re-vendors packages.** An npm
506563
re-run (every lock flavor, `bun.lockb` included) re-acquired its tarball
507564
from whichever source answered — the service's prebuilt, or a local pack

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 6 additions & 6 deletions
Large diffs are not rendered by default.

‎crates/socket-patch-cli/src/commands/apply.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,7 @@ async fn try_local_go_apply(
416416
// `pkg_path` is the pristine, case-encoded module-cache dir; `module`/
417417
// `version` are the decoded PURL components keying the copy + `replace`.
418418
let (module, version) = parse_golang_purl(purl)?;
419+
let (module, version) = (&*module, &*version);
419420
Some(
420421
apply_go_redirect(
421422
purl,

‎crates/socket-patch-cli/src/commands/scan/hosted.rs‎

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,10 +1078,9 @@ pub(crate) async fn run_redirect_selected(
10781078
/// records and the confirmation probe key on; everything the probe
10791079
/// needs AFTER the rewrite to decide whether the dep was actually
10801080
/// redirected (artifact URL, registry index URL, fail-closed maven's
1081-
/// suffixed version, golang's content-addressed module path) already
1082-
/// rides the override. The single vector is filtered in place by every
1083-
/// withhold/refusal step, and the rewriters' `overrides` slice is
1084-
/// materialized from it once, after the last filter.
1081+
/// suffixed version) already rides the override. The single vector is
1082+
/// filtered in place by every withhold/refusal step, and the rewriters'
1083+
/// `overrides` slice is materialized from it once, after the last filter.
10851084
struct Candidate {
10861085
purl: String,
10871086
dep: DepOverride,
@@ -1307,7 +1306,10 @@ pub(crate) async fn run_redirect_selected(
13071306
// VENDORED — for cargo a committed `[patch.crates-io]` path entry, a
13081307
// detached Cargo.lock entry, a committed copy, and a vendored ledger
13091308
// entry; for the npm family a `file:./.socket/vendor/…` lock resolution
1310-
// (plus a berry `resolutions` pin) and its committed tarball. The hosted
1309+
// (plus a berry `resolutions` pin) and its committed tarball; for golang
1310+
// the vendor-owned go.mod `replace`, its committed module copy, and its
1311+
// ledger entry (left behind, a later `vendor` run takes the module back
1312+
// and the modes flip-flop). The hosted
13111313
// rewriters know nothing about that wiring: cargo then refuses every
13121314
// `--locked` build over the now-unused `[patch]` entry while this run
13131315
// reports success, and the npm rewriters either hijack the vendored
@@ -1323,7 +1325,9 @@ pub(crate) async fn run_redirect_selected(
13231325
// whose vendored state cannot be cleanly reverted (revert failure, or
13241326
// vendored wiring with a missing/corrupt ledger) is REFUSED — skipped
13251327
// with an actionable error — never half-migrated.
1326-
let takeover_capable = |p: &str| p.starts_with("pkg:cargo/") || p.starts_with("pkg:npm/");
1328+
let takeover_capable = |p: &str| {
1329+
p.starts_with("pkg:cargo/") || p.starts_with("pkg:npm/") || p.starts_with("pkg:golang/")
1330+
};
13271331
let mut takeover_pre_warnings: Vec<serde_json::Value> = Vec::new();
13281332
// Dry-run takeover previews: `(purl, uuid)` pairs whose vendored state
13291333
// the wet run would revert and then redirect. Withheld from the
@@ -2326,17 +2330,20 @@ pub(crate) async fn run_redirect_selected(
23262330
if purl.starts_with("pkg:cargo/") {
23272331
return rewrite.confirmed_cargo_uuids.contains(uuid);
23282332
}
2333+
// Golang likewise: the goproxy `indexUrl` is the bare
2334+
// patch-server origin (present in any other hosted lock), and
2335+
// the socket module's go.sum lines outlive a removed replace.
2336+
if purl.starts_with("pkg:golang/") {
2337+
return rewrite.confirmed_golang_uuids.contains(uuid);
2338+
}
23292339
// The override's own targets: artifact URL; per-dependency
23302340
// registry index URL; fail-closed maven's globally-unique
2331-
// `-socket.<hex8>` suffixed version (never the `.pom` URL);
2332-
// golang's content-addressed `patch.socket.dev/gopatch/<uuid>`
2333-
// module path (go.mod + go.sum never carry a URL).
2341+
// `-socket.<hex8>` suffixed version (never the `.pom` URL).
23342342
let artifact_url = c.dep.artifact_url.as_str();
23352343
let registry = c.dep.registry_override.as_ref();
23362344
let index_url = registry.map(|o| o.index_url.as_str());
23372345
let suffixed_version =
23382346
registry.and_then(|o| o.identifiers.maven_suffixed_version.as_deref());
2339-
let go_module_path = registry.and_then(|o| o.identifiers.go_module_path.as_deref());
23402347
let encoded = socket_patch_core::utils::uri::encode_uri_component(artifact_url);
23412348
final_texts.iter().any(|text| {
23422349
// The rewriters' own predicate — raw, or the `\/`-escaped
@@ -2352,7 +2359,6 @@ pub(crate) async fn run_redirect_selected(
23522359
|| text.contains(encoded.as_str())
23532360
|| index_url.is_some_and(|iu| text.contains(iu))
23542361
|| suffixed_version.is_some_and(|sv| text.contains(sv))
2355-
|| go_module_path.is_some_and(|gm| text.contains(gm))
23562362
})
23572363
})
23582364
.map(|c| (c.purl.clone(), c.dep.patch_uuid.clone()))

‎crates/socket-patch-cli/src/commands/vendor.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1704,6 +1704,9 @@ pub(crate) async fn vendor_records(
17041704
let reverted_what = if candidate.starts_with("pkg:cargo/") {
17051705
"the hosted edits (Cargo.toml registry pin, Cargo.lock \
17061706
source/checksum, registries block)"
1707+
} else if candidate.starts_with("pkg:golang/") {
1708+
"the hosted edits (go.mod replace, the socket module's go.sum \
1709+
lines, the pruned upstream go.sum lines)"
17071710
} else {
17081711
"the hosted lockfile edits back to their pre-redirect \
17091712
registry values"

0 commit comments

Comments
 (0)