From baf1d7d3a707e93bbabf11840f30b5dff2e48127 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 13 Aug 2026 10:51:27 -0700 Subject: [PATCH 1/5] feat(hosted): golang hosted redirect via fork-replace + committed go.sum pin (free tier) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overturns docs/design/golang-hosted-no-go.md for the free tier. The committable shape: a fork-style `replace => patch.socket.dev/gopatch/ -socketpatch.` in go.mod plus the socket module's two h1: lines in go.sum (and the replaced original's lines pruned — the tidy-stable state). Day-2 machines need zero configuration: go consults the checksum database only for modules absent from go.sum, so the committed pair is the entire redirect — validated empirically per docs/design/golang-hosted.md and pinned by e2e_golang_hosted_build.rs (fresh caches, bogus-GOSUMDB tripwire, tidy byte-level no-op, tampered-hash SECURITY ERROR). - redirect/mod.rs: real rewrite_golang, gated per-dep on registry_override.kind == "goproxy"; fails closed (redirect_golang_* warnings, no partial writes) on missing hashes, out-of-namespace module path, require-version mismatch, or user replace conflict; references without the override keep redirect_golang_unsupported (paid tier). Schema: Integrity.goModH1 + identifiers.goModuleVersion (additive). - go_mod_edit.rs: ReplaceOwner::Hosted (RHS-prefix ownership under patch.socket.dev/gopatch/), module-target parse + hosted upsert with cross-owner in-place takeover; appended directives now gofmt-shaped (blank line before the stanza) so tidy stays a no-op. - go_sum_edit.rs (new): pure sorted upsert / exact-version prune / prefix removal; pruned lines ride the ledger `original` for revert. - scan/hosted.rs: go.mod + go.sum join REDIRECT_CANDIDATE_FILES; the redirected-confirmation matcher accepts the socket module path (a Go rewrite contains no artifact/index URL). - Golden fixture golang/gomod/basic pins the byte contract the depscan TS twin must match; prod e2e golang leg becomes a both-worlds shape guard. - docs: design doc (incl. depscan server requirements: gopatch artifact flavor + token-free GOPROXY routes + go-get meta + write-once invariant), ecosystems matrix, README, CLI contract, changelog. Server-side publication is the remaining half: production publishes no golang hosted modules yet, so CLI behavior is unchanged until depscan ships the goproxy override. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 20 + README.md | 3 +- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../src/commands/scan/hosted.rs | 105 ++-- .../tests/e2e_golang_hosted_build.rs | 351 ++++++++++++ .../tests/e2e_hosted_production.rs | 63 ++- .../src/patch/redirect/mod.rs | 519 +++++++++++++++++- .../src/vendor/go_mod_edit.rs | 271 ++++++++- .../src/vendor/go_sum_edit.rs | 255 +++++++++ crates/socket-patch-core/src/vendor/mod.rs | 1 + .../golang/gomod/basic/expected-edits.json | 23 + .../golang/gomod/basic/expected/go.mod | 7 + .../golang/gomod/basic/expected/go.sum | 2 + .../redirect/golang/gomod/basic/input/go.mod | 5 + .../redirect/golang/gomod/basic/input/go.sum | 2 + .../golang/gomod/basic/overrides.json | 24 + .../tests/redirect_golden.rs | 1 + docs/design/golang-hosted-no-go.md | 20 +- docs/design/golang-hosted.md | 195 +++++++ docs/ecosystems.md | 16 +- docs/testing/hosted-production-e2e.md | 2 +- 21 files changed, 1772 insertions(+), 115 deletions(-) create mode 100644 crates/socket-patch-cli/tests/e2e_golang_hosted_build.rs create mode 100644 crates/socket-patch-core/src/vendor/go_sum_edit.rs create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/golang/gomod/basic/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/golang/gomod/basic/expected/go.mod create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/golang/gomod/basic/expected/go.sum create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/golang/gomod/basic/input/go.mod create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/golang/gomod/basic/input/go.sum create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/golang/gomod/basic/overrides.json create mode 100644 docs/design/golang-hosted.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eb548b5..867be454 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,26 @@ into the new version's section — see docs/releasing.md. ### Added +- **Hosted mode for Go (free tier).** `scan --mode hosted` now redirects + golang dependencies when the reference carries a `goproxy` registry + override: a fork-style + `replace => patch.socket.dev/gopatch/ -socketpatch.` + in `go.mod` plus the socket module's two `h1:` lines in `go.sum` (and the + replaced original's lines pruned — the tidy-stable state). Day-2 machines + need no configuration: go consults the checksum database only for modules + absent from `go.sum`, so the committed pair is the whole redirect — + validated end-to-end in `e2e_golang_hosted_build.rs` (fresh caches, bogus + `GOSUMDB` tripwire, `go mod tidy` byte-level no-op, tampered-hash + `SECURITY ERROR`). Fails closed (per-dep `redirect_golang_*` warnings, no + partial writes) on missing hashes, an out-of-namespace module path, a + require-version mismatch, or a user-authored replace conflict; references + without the override keep the historical `redirect_golang_unsupported` + warning (paid tier stays vendored — see `docs/design/golang-hosted.md`). + Wire schema gains `integrity.goModH1` and + `registryOverride.identifiers.goModuleVersion` (additive). Requires + server-side publication of the grant-free `gopatch` artifact flavor — + production publishes no golang hosted modules yet, so behavior is unchanged + until it does. - **Version-bump automation + release-readiness gate.** `scripts/bump-version.sh --pr` performs the whole bump chore — stamps every packaging site via `version-sync.sh`, rolls `[Unreleased]` diff --git a/README.md b/README.md index 531b2695..8ddaeddc 100644 --- a/README.md +++ b/README.md @@ -1228,5 +1228,6 @@ by `setup --exclude`). surface: exact JSON shapes, exit codes, flag/env bindings, and the semver policy that governs them. - **[Design notes](docs/design/)** — e.g. [the configuration model](docs/design/configuration.md) - and [why hosted mode is impossible for Go](docs/design/golang-hosted-no-go.md). + and [hosted mode for Go](docs/design/golang-hosted.md) (free tier; the + [paid-tier no-go analysis](docs/design/golang-hosted-no-go.md) it supersedes). - **[Changelog](CHANGELOG.md)** diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 3a0f4f44..a8db21e4 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -100,7 +100,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments `scan --vendor --detached` performs the same vendoring **without ever writing `.socket/manifest.json`**: records are fetched into memory (`download.detached: true`), the artifacts are built + wired, and the ledger entry carries `detached: true` plus an embedded copy of the patch record (`record`) as the verification source. Detached patches are invisible to apply/rollback/repair (nothing is in the manifest), exempt from `vendor`'s manifest reconcile, and exit via `remove ` (which reverts them) or `vendor --revert`. Idempotent re-runs reuse the embedded record and skip the patch-view fetch entirely. -`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. Re-runs over already-rewritten output record zero new edits. 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). +`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. 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). 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`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate and `bun.lock`), `requirements.txt` / `uv.lock`, `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` v1 — a binary `bun.lockb` with no text lock is auto-migrated to text via `bun install --save-text-lockfile --frozen-lockfile --lockfile-only` before the read, recorded as a `removed` FileEdit; `redirect_bun_lockb_would_migrate` on `--dry-run`, `redirect_bun_lockb_unsupported` when the migration is unavailable). **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). diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index a7a29236..37df1fa2 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -40,6 +40,11 @@ const REDIRECT_CANDIDATE_FILES: &[&str] = &[ // on diverging spellings). "gems.rb", "gems.locked", + // The golang rewriter edits the main module's go.mod (fork-style + // `replace`) and go.sum (the socket module's two h1: lines). go.sum may + // legitimately be absent — the rewriter creates it in that case. + "go.mod", + "go.sum", "pom.xml", // Maven Trusted Checksums files the fail-closed maven rewriter merges into // (read so an existing user config / checksum set is preserved, not @@ -152,12 +157,22 @@ pub(super) async fn run_redirect( let mut skipped: Vec = Vec::new(); let mut overrides: Vec = Vec::new(); - // (purl, uuid, artifact_url, registry index_url, maven suffixed version) - // per granted reference — used AFTER the rewrite to decide which deps were - // actually redirected (their target URL / index / suffixed version landed - // in a file) before persisting records or attesting anything. The last - // element is Some only for fail-closed maven overrides. - type RedirectCandidate = (String, String, String, Option, Option); + // (purl, uuid, artifact_url, registry index_url, maven suffixed version, + // go module path) per granted reference — used AFTER the rewrite to decide + // which deps were actually redirected (their target URL / index / suffixed + // version / socket module path landed in a file) before persisting records + // or attesting anything. The fifth element is Some only for fail-closed + // maven overrides; the sixth only for golang (whose go.mod/go.sum edits + // carry the content-addressed `patch.socket.dev/gopatch/` module + // path, never the artifact or index URL). + type RedirectCandidate = ( + String, + String, + String, + Option, + Option, + Option, + ); let mut candidates: Vec = Vec::new(); if !selected.is_empty() { @@ -225,6 +240,10 @@ pub(super) async fn run_redirect( .registry_override .as_ref() .and_then(|o| o.identifiers.maven_suffixed_version.clone()), + reference + .registry_override + .as_ref() + .and_then(|o| o.identifiers.go_module_path.clone()), )); overrides.push(DepOverride { ecosystem, @@ -484,39 +503,47 @@ pub(super) async fn run_redirect( .collect(); let confirmed: Vec<(String, String)> = candidates .iter() - .filter(|(purl, uuid, artifact_url, index_url, suffixed_version)| { - // Cargo is transactional: the rewriter reports exactly which - // patch uuids FULLY landed (manifest pin + lock + registry - // block). Substring presence must never confirm a cargo dep — - // the `[registries.…]` config block contains the index URL while - // pinning nothing, so a config-block-only rewrite would be - // attested with zero enforcement in any build. - if purl.starts_with("pkg:cargo/") { - return rewrite.confirmed_cargo_uuids.contains(uuid); - } - 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 - // slashes an old composer.lock spells them with — so a - // writer's spelling can never be one this probe misses. It - // was: the composer rewriter emitted `\/`-escaped urls this - // probe never looked for, so a fully successful composer - // redirect reported `redirected: 0`, fetched no patch record - // into the ledger, and left the patch unattestable by `vex`. - socket_patch_core::patch::redirect::artifact_url_present(text, artifact_url) - // The berry rewriter writes the URL percent-encoded into the - // lock's `::__archiveUrl=` binding, so the raw form is absent. - || text.contains(encoded.as_str()) - || index_url.as_deref().is_some_and(|iu| text.contains(iu)) - // Fail-closed maven pins the globally-unique - // `-socket.` suffixed version (never the `.pom` URL), - // so match on that string. - || suffixed_version - .as_deref() - .is_some_and(|sv| text.contains(sv)) - }) - }) - .map(|(purl, uuid, _, _, _)| (purl.clone(), uuid.clone())) + .filter( + |(purl, uuid, artifact_url, index_url, suffixed_version, go_module_path)| { + // Cargo is transactional: the rewriter reports exactly which + // patch uuids FULLY landed (manifest pin + lock + registry + // block). Substring presence must never confirm a cargo dep — + // the `[registries.…]` config block contains the index URL while + // pinning nothing, so a config-block-only rewrite would be + // attested with zero enforcement in any build. + if purl.starts_with("pkg:cargo/") { + return rewrite.confirmed_cargo_uuids.contains(uuid); + } + 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 + // slashes an old composer.lock spells them with — so a + // writer's spelling can never be one this probe misses. It + // was: the composer rewriter emitted `\/`-escaped urls this + // probe never looked for, so a fully successful composer + // redirect reported `redirected: 0`, fetched no patch record + // into the ledger, and left the patch unattestable by `vex`. + socket_patch_core::patch::redirect::artifact_url_present(text, artifact_url) + // The berry rewriter writes the URL percent-encoded into the + // lock's `::__archiveUrl=` binding, so the raw form is absent. + || text.contains(encoded.as_str()) + || index_url.as_deref().is_some_and(|iu| text.contains(iu)) + // Fail-closed maven pins the globally-unique + // `-socket.` suffixed version (never the `.pom` URL), + // so match on that string. + || suffixed_version + .as_deref() + .is_some_and(|sv| text.contains(sv)) + // golang pins the content-addressed + // `patch.socket.dev/gopatch/` module path into + // go.mod + go.sum (no URL ever lands in either file). + || go_module_path + .as_deref() + .is_some_and(|gm| text.contains(gm)) + }) + }, + ) + .map(|(purl, uuid, _, _, _, _)| (purl.clone(), uuid.clone())) .collect(); // Fetch the full patch view (file hashes + vulnerabilities) for each diff --git a/crates/socket-patch-cli/tests/e2e_golang_hosted_build.rs b/crates/socket-patch-cli/tests/e2e_golang_hosted_build.rs new file mode 100644 index 00000000..7658a32c --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_golang_hosted_build.rs @@ -0,0 +1,351 @@ +#![cfg(unix)] +//! Full go-toolchain capstone for the HOSTED golang redirect: proves that the +//! rewriter's committed `go.mod` + `go.sum` alone make a **fresh day-2 +//! machine** (empty caches, default `-mod=readonly`, NO machine-local +//! configuration) build the PATCHED module — and that go's own integrity +//! machinery still has teeth against a tampered pin. +//! +//! The three properties this pins (each validated empirically before the +//! feature was built — see `docs/design/golang-hosted.md`): +//! +//! 1. **No sumdb consultation**: `GOSUMDB` is set to a bogus database name +//! for every day-2 command. go parses `GOSUMDB` lazily and consults it only +//! for modules ABSENT from `go.sum` — if any command here asked the +//! checksum database, it would fail loudly (`malformed verifier id`), so +//! green tests prove committed go.sum lines are sufficient day-2 state. +//! 2. **Fork-style replace with a zero-rewrite artifact**: the served module +//! zip keeps the ORIGINAL module path in its internal `go.mod` and the +//! original import-path spellings in its sources; only the zip's entry +//! prefix and the proxy directory use the socket module path. +//! 3. **go.sum verification stays load-bearing**: flipping one character of +//! the committed zip `h1:` fails the build with a checksum SECURITY ERROR +//! on a fresh cache — a wrong CLI-written hash can never be silently built. +//! +//! Hermetic + offline: both the upstream and the socket-patched module are +//! served from a `file://` GOPROXY into per-"machine" temp caches. Skips when +//! `go`/`zip` aren't installed. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[path = "common/mod.rs"] +mod common; + +use common::{cache_env, has_command}; + +use socket_patch_core::patch::redirect::{ + rewrite_registry_redirect, DepOverride, Integrity, RegistryOverride, + RegistryOverrideIdentifiers, +}; + +const UMOD: &str = "example.com/upstream"; +const UVER: &str = "v1.0.0"; +const UUID: &str = "55555555-5555-5555-5555-555555555555"; +const SVER: &str = "v1.0.0-socketpatch.1"; +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"; + +fn socket_module() -> String { + format!("patch.socket.dev/gopatch/{UUID}") +} + +/// One "machine": its own GOMODCACHE + GOCACHE, nothing shared. +struct Machine { + modcache: PathBuf, + gocache: PathBuf, +} + +impl Machine { + fn new(tmp: &Path, name: &str) -> Self { + let m = Machine { + modcache: tmp.join(name).join("modcache"), + gocache: tmp.join(name).join("gocache"), + }; + std::fs::create_dir_all(&m.modcache).unwrap(); + std::fs::create_dir_all(&m.gocache).unwrap(); + m + } +} + +/// Run `go` sandboxed (cache_env), then this machine's caches + the given env +/// on top (explicit values win over both the sandbox and hostile ambient). +fn go(dir: &Path, machine: &Machine, args: &[&str], env: &[(&str, &str)]) -> std::process::Output { + let mut cmd = Command::new("go"); + cmd.args(args).current_dir(dir); + cache_env::isolate(&mut cmd); + cmd.env("GOMODCACHE", &machine.modcache); + cmd.env("GOCACHE", &machine.gocache); + cmd.env("GOTOOLCHAIN", "local"); + for (k, v) in env { + cmd.env(k, v); + } + cmd.output().expect("run go") +} + +/// The day-2 environment: default mod mode (`-mod=readonly`), sumdb pointed at +/// a BOGUS database that fails loudly if ever consulted, and every sanctioned +/// escape hatch (`GOPRIVATE`/`GONOSUMDB`/`GONOPROXY`) explicitly empty — the +/// committed go.mod+go.sum must carry the redirect entirely on their own. +fn day2_env(proxy_url: &str) -> Vec<(&'static str, String)> { + vec![ + ("GOPROXY", proxy_url.to_string()), + ("GOSUMDB", "sum.invalid.example".to_string()), + ("GOFLAGS", String::new()), + ("GOPRIVATE", String::new()), + ("GONOSUMDB", String::new()), + ("GONOPROXY", String::new()), + ] +} + +fn as_pairs<'a>(env: &'a [(&'static str, String)]) -> Vec<(&'a str, &'a str)> { + env.iter().map(|(k, v)| (*k, v.as_str())).collect() +} + +/// Stage a module dir and zip it into the file-proxy under `mod_path@ver/`. +fn publish(tmp: &Path, mod_path: &str, ver: &str, gomod: &str, lib: &str) { + let stage_root = tmp.join("stage").join(mod_path.replace('/', "_")); + let stage = stage_root.join(format!("{mod_path}@{ver}")); + std::fs::create_dir_all(&stage).unwrap(); + std::fs::write(stage.join("go.mod"), gomod).unwrap(); + std::fs::write(stage.join("lib.go"), lib).unwrap(); + + let pxv = tmp.join("proxy").join(mod_path).join("@v"); + std::fs::create_dir_all(&pxv).unwrap(); + std::fs::write( + pxv.join(format!("{ver}.info")), + format!("{{\"Version\":\"{ver}\"}}"), + ) + .unwrap(); + // The served `.mod` must byte-match the zip's internal go.mod — go hashes + // the SERVED bytes into the `/go.mod h1:` line without cross-checking the + // zip, so the server contract freezes them together. + std::fs::write(pxv.join(format!("{ver}.mod")), gomod).unwrap(); + let zip_out = pxv.join(format!("{ver}.zip")); + let status = Command::new("zip") + .args([ + "-q", + "-r", + zip_out.to_str().unwrap(), + &format!("{mod_path}@{ver}"), + ]) + .current_dir(&stage_root) + .status() + .expect("run zip"); + assert!(status.success(), "zip failed for {mod_path}@{ver}"); +} + +/// Harvest the two go.sum hashes for `mod_path@ver` the way the patch server +/// would publish them: `go mod download -json` in a throwaway module (sums +/// off — this is the trusted build side, not the consumer side). +fn harvest_sums(tmp: &Path, proxy_url: &str, mod_path: &str, ver: &str) -> (String, String) { + let dir = tmp.join("harvest").join(mod_path.replace('/', "_")); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("go.mod"), + "module example.com/harvest\n\ngo 1.21\n", + ) + .unwrap(); + let machine = Machine::new(tmp, &format!("harvest-{}", mod_path.replace('/', "_"))); + let out = go( + &dir, + &machine, + &["mod", "download", "-json", &format!("{mod_path}@{ver}")], + &[ + ("GOPROXY", proxy_url), + ("GOSUMDB", "off"), + ("GOFLAGS", "-mod=mod"), + ], + ); + assert!( + out.status.success(), + "hash harvest failed for {mod_path}@{ver}: {}", + String::from_utf8_lossy(&out.stderr) + ); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("download -json"); + ( + v["Sum"].as_str().expect("Sum").to_string(), + v["GoModSum"].as_str().expect("GoModSum").to_string(), + ) +} + +#[test] +fn day2_machine_builds_patched_module_from_committed_files_alone() { + if !has_command("go") || !has_command("zip") { + eprintln!("skipping e2e_golang_hosted_build: `go`/`zip` not installed"); + return; + } + // RED guards: hostile ambient values every pinned env below must defeat. + // `GOTOOLCHAIN` must lose to the `local` pin; `GOFLAGS=-mod=mod` must lose + // to the explicit empty (day-2 must run readonly); `GONOSUMDB=*` must lose + // to the explicit empty (it would mask the bogus-GOSUMDB tripwire). + std::env::set_var("GOTOOLCHAIN", "go1.99.99"); + std::env::set_var("GOFLAGS", "-mod=mod"); + std::env::set_var("GONOSUMDB", "*"); + let tmp = tempfile::tempdir().unwrap(); + let proxy_url = format!("file://{}", tmp.path().join("proxy").display()); + let smod = socket_module(); + + // ── the patch server's side ───────────────────────────────────────────── + // Upstream module (vulnerable), and the patched artifact published under + // the socket module path. ZERO-REWRITE converter shape: the internal + // go.mod still declares the ORIGINAL module path and sources keep original + // import spellings; only the zip prefix + proxy dir carry the socket path. + let upstream_gomod = format!("module {UMOD}\n\ngo 1.21\n"); + publish(tmp.path(), UMOD, UVER, &upstream_gomod, PRISTINE_LIB); + publish(tmp.path(), &smod, SVER, &upstream_gomod, PATCHED_LIB); + let (zip_h1, gomod_h1) = harvest_sums(tmp.path(), &proxy_url, &smod, SVER); + let (u_zip_h1, u_gomod_h1) = harvest_sums(tmp.path(), &proxy_url, UMOD, UVER); + + // ── the user's project, pre-redirect ──────────────────────────────────── + let consumer = tmp.path().join("consumer"); + std::fs::create_dir_all(&consumer).unwrap(); + std::fs::write( + consumer.join("go.mod"), + format!("module example.com/consumer\n\ngo 1.21\n\nrequire {UMOD} {UVER}\n"), + ) + .unwrap(); + std::fs::write( + consumer.join("go.sum"), + format!("{UMOD} {UVER} {u_zip_h1}\n{UMOD} {UVER}/go.mod {u_gomod_h1}\n"), + ) + .unwrap(); + std::fs::write( + consumer.join("main.go"), + format!( + "package main\n\nimport (\n\t\"fmt\"\n\t\"{UMOD}\"\n)\n\nfunc main() {{ fmt.Println(\"OUT:\", upstream.Greeting()) }}\n" + ), + ) + .unwrap(); + + // Sanity: an untouched project on a fresh machine links PRISTINE. + let m0 = Machine::new(tmp.path(), "machine0"); + let env = day2_env(&proxy_url); + let base = go(&consumer, &m0, &["run", "."], &as_pairs(&env)); + assert!( + base.status.success(), + "baseline run failed: {}", + String::from_utf8_lossy(&base.stderr) + ); + assert!(String::from_utf8_lossy(&base.stdout).contains("OUT: PRISTINE")); + + // ── `scan --mode hosted`'s rewrite (in-process, pure) ─────────────────── + let ovr = DepOverride { + ecosystem: "golang".into(), + name: UMOD.into(), + namespace: None, + version: UVER.into(), + token: String::new(), + patch_uuid: UUID.into(), + artifact_url: format!("{proxy_url}/{smod}/@v/{SVER}.zip"), + berry_zip_url: None, + registry_override: Some(RegistryOverride { + kind: "goproxy".into(), + index_url: proxy_url.clone(), + identifiers: RegistryOverrideIdentifiers { + name: UMOD.into(), + version: UVER.into(), + go_module_path: Some(smod.clone()), + go_module_version: Some(SVER.into()), + ..Default::default() + }, + }), + integrity: Integrity { + dirhash_h1: Some(zip_h1.clone()), + go_mod_h1: Some(gomod_h1), + ..Default::default() + }, + }; + let mut files = std::collections::BTreeMap::new(); + for name in ["go.mod", "go.sum"] { + files.insert( + name.to_string(), + std::fs::read_to_string(consumer.join(name)).unwrap(), + ); + } + let rewrite = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert!( + rewrite.warnings.is_empty(), + "rewrite warnings: {:?}", + rewrite.warnings + ); + assert_eq!( + rewrite.files.keys().collect::>(), + ["go.mod", "go.sum"], + "exactly the two committed files change" + ); + for (name, content) in &rewrite.files { + std::fs::write(consumer.join(name), content).unwrap(); + } + + // ── day 2: a fresh machine, committed files only, zero local config ───── + let m2 = Machine::new(tmp.path(), "machine2"); + let patched = go(&consumer, &m2, &["run", "."], &as_pairs(&env)); + assert!( + patched.status.success(), + "day-2 run failed: {}", + String::from_utf8_lossy(&patched.stderr) + ); + assert!( + String::from_utf8_lossy(&patched.stdout).contains("OUT: PATCHED"), + "day-2 build must link the PATCHED module: {}", + String::from_utf8_lossy(&patched.stdout) + ); + + // `go mod tidy` on the same machine is a byte-level no-op: the redirect + // survives the day-2 command most likely to churn go.mod/go.sum. + let before_mod = std::fs::read_to_string(consumer.join("go.mod")).unwrap(); + let before_sum = std::fs::read_to_string(consumer.join("go.sum")).unwrap(); + let tidy = go(&consumer, &m2, &["mod", "tidy"], &as_pairs(&env)); + assert!( + tidy.status.success(), + "go mod tidy failed: {}", + String::from_utf8_lossy(&tidy.stderr) + ); + assert_eq!( + std::fs::read_to_string(consumer.join("go.mod")).unwrap(), + before_mod, + "tidy must not churn go.mod" + ); + assert_eq!( + std::fs::read_to_string(consumer.join("go.sum")).unwrap(), + before_sum, + "tidy must not churn go.sum" + ); + + // ── tamper: go.sum verification must stay load-bearing ────────────────── + // Flip one character of the committed zip h1 — a fresh machine must + // refuse the download with a checksum SECURITY ERROR, never build it. + let tampered = { + let flip = |c: char| if c == 'A' { 'B' } else { 'A' }; + let mut lines: Vec = before_sum.lines().map(str::to_string).collect(); + let idx = lines + .iter() + .position(|l| l.starts_with(&format!("{smod} {SVER} h1:"))) + .expect("socket zip h1 line present"); + let mut chars: Vec = lines[idx].chars().collect(); + let at = lines[idx].find("h1:").unwrap() + 3; + chars[at] = flip(chars[at]); + lines[idx] = chars.into_iter().collect(); + lines.join("\n") + "\n" + }; + std::fs::write(consumer.join("go.sum"), &tampered).unwrap(); + let m3 = Machine::new(tmp.path(), "machine3"); + let bad = go(&consumer, &m3, &["build", "./..."], &as_pairs(&env)); + let bad_err = String::from_utf8_lossy(&bad.stderr); + assert!( + !bad.status.success(), + "tampered go.sum must fail the build, got: {}", + String::from_utf8_lossy(&bad.stdout) + ); + assert!( + bad_err.contains("checksum mismatch") || bad_err.contains("SECURITY ERROR"), + "failure must be go's checksum verification, got: {bad_err}" + ); + std::fs::write(consumer.join("go.sum"), &before_sum).unwrap(); + + // Best-effort: the go module cache is written read-only; relax perms so + // the tempdir cleans up. + let _ = Command::new("chmod") + .args(["-R", "u+w", tmp.path().to_str().unwrap()]) + .status(); +} diff --git a/crates/socket-patch-cli/tests/e2e_hosted_production.rs b/crates/socket-patch-cli/tests/e2e_hosted_production.rs index 437b3092..db6ff851 100644 --- a/crates/socket-patch-cli/tests/e2e_hosted_production.rs +++ b/crates/socket-patch-cli/tests/e2e_hosted_production.rs @@ -48,8 +48,10 @@ //! patches for them, so there is nothing real to redirect to. Rather than //! silently skipping, [`canary_unpublished_ecosystems`] probes production //! every run and tells us the moment that changes. -//! * **golang** — hosted mode is refused **by design** -//! (`docs/design/golang-hosted-no-go.md`). Covered as a negative assertion. +//! * **golang** — hosted mode is supported for free-tier references carrying +//! a `goproxy` override (`docs/design/golang-hosted.md`), but production +//! publishes no golang hosted modules yet. Covered as a shape guard that +//! holds in both worlds. //! * **deno** — hosted mode is not supported. Covered as a negative assertion. //! //! # Prerequisites @@ -1899,17 +1901,20 @@ async fn gem_bundler_hosted_redirect_and_known_install_defect() { // Documented negative cases // =========================================================================== -/// Go hosted mode is refused by design (`docs/design/golang-hosted-no-go.md`). +/// Go hosted mode: supported for free-tier references that carry a `goproxy` +/// override (`docs/design/golang-hosted.md`); refused with +/// `redirect_golang_unsupported` otherwise (`golang-hosted-no-go.md`, the +/// paid-tier analysis). /// -/// This asserts the *documented* shape of the refusal rather than a specific -/// warning payload, because production publishes no free golang patches today, -/// so there is nothing for the rewriter to refuse. If that ever changes, the -/// `redirect_golang_unsupported` branch below starts exercising and this test -/// becomes a real guard with no edit needed. +/// Production publishes no golang hosted modules today, so this guards BOTH +/// worlds without edits: while prod ships no `goproxy` overrides, nothing may +/// be redirected (an empty-project scan must not invent a redirect); the day +/// prod starts shipping them, any redirect this scan performs must be the +/// documented shape — a socket-namespace replace in go.mod plus go.sum lines. #[test] #[ignore = "live production API. Run with --ignored."] -fn golang_hosted_is_refused_by_design() { - const LEG: &str = "golang_hosted_is_refused_by_design"; +fn golang_hosted_redirects_only_via_goproxy_override() { + const LEG: &str = "golang_hosted_redirects_only_via_goproxy_override"; if !has_command("go") { soft_skip!(LEG, "`go` not on PATH"); } @@ -1923,27 +1928,27 @@ fn golang_hosted_is_refused_by_design() { .expect("write go.mod"); let env_json = scan_hosted(&proj, &["--ecosystems", "golang"]); - assert_eq!( - redirected_count(&env_json), - 0, - "{LEG}: golang hosted mode redirected something — it is documented as \ - impossible (sumdb + module-path identity + GOPROXY leakage). Either \ - the design changed or this is a real bug:\n{env_json:#}" - ); - let warnings = env_json["redirect"]["warnings"] - .as_array() - .cloned() - .unwrap_or_default(); - if warnings - .iter() - .any(|w| w["code"].as_str() == Some("redirect_golang_unsupported")) - { - println!("{LEG}: production now publishes golang patches; the documented refusal fired."); - } else { + let redirected = redirected_count(&env_json); + if redirected == 0 { println!( - "{LEG}: no golang patches published, so the refusal path is inert. \ - Asserted only that hosted mode redirected nothing." + "{LEG}: production publishes no golang hosted modules (or none \ + matched an empty project); nothing redirected, as documented." + ); + } else { + // The moment prod ships goproxy overrides, every golang redirect must + // be the committable fork-replace shape. + let go_mod = std::fs::read_to_string(proj.join("go.mod")).expect("read go.mod"); + assert!( + go_mod.contains("patch.socket.dev/gopatch/"), + "{LEG}: {redirected} golang redirect(s) reported but go.mod has no \ + socket-namespace replace:\n{go_mod}\n{env_json:#}" + ); + assert!( + proj.join("go.sum").is_file(), + "{LEG}: golang redirect without a go.sum pin bricks -mod=readonly \ + builds:\n{env_json:#}" ); + println!("{LEG}: production now serves golang hosted modules; shape verified."); } } diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index ddf3f9ec..cf4ce17e 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -40,6 +40,12 @@ pub struct Integrity { pub sha1: Option, pub md5: Option, pub dirhash_h1: Option, + /// go.sum's second line for a Go module: `h1:` dirhash of the SERVED + /// `/@v/.mod` bytes (x/mod `HashGoMod`, i.e. `Hash1` over the + /// single entry `go.mod`). Both this and `dirhash_h1` are required before + /// the golang rewriter will touch anything — under `-mod=readonly` a + /// missing go.sum line is a hard build error on every other machine. + pub go_mod_h1: Option, pub yarn_berry10c0: Option, } @@ -49,7 +55,18 @@ pub struct RegistryOverrideIdentifiers { pub name: String, pub version: String, pub cargo_cksum_sha256: Option, + /// Module path of the Socket-published patched Go module — grant-free and + /// content-addressed under `go_mod_edit::HOSTED_GO_MODULE_PREFIX` + /// (`patch.socket.dev/gopatch/`), served over the standard + /// GOPROXY protocol. The rewriter fails closed on any path outside that + /// namespace: the prefix is the only ownership signal a module-to-module + /// `replace` (and its go.sum lines) carries. pub go_module_path: Option, + /// Version the Socket Go module is published under (the replace RHS, + /// `-socketpatch.`). Always in the v0/v1 range regardless of the + /// original's major version — a v2+ RHS would force a `/v2` module-path + /// suffix, and the RHS version need not relate to the original's. + pub go_module_version: Option, pub nuget_id_lower: Option, pub nuget_version_norm: Option, pub maven_group_id: Option, @@ -170,7 +187,7 @@ pub fn rewrite_registry_redirect( rewrite_nuget(files, overrides, &mut result); rewrite_gem(files, overrides, &mut result); rewrite_maven_pom(files, overrides, &mut result); - rewrite_golang(overrides, &mut result); + rewrite_golang(files, overrides, &mut result); result } @@ -3849,22 +3866,207 @@ fn gradle_snippet( ) } -// ── golang (documented limitation) ────────────────────────────────────────── -// Hosted redirect for Go is a deliberate no-go: every workable shape needs -// machine-local GOPROXY/GOPRIVATE configuration that can't be committed to the -// repo as a per-dependency edit. The full analysis (sumdb hard-fail, module- -// path identity vs the build-once converter, GOPROXY leaking licensed bytes to -// the public mirror) lives in `docs/design/golang-hosted-no-go.md`. -fn rewrite_golang(overrides: &[DepOverride], result: &mut RewriteResult) { - for dep in overrides.iter().filter(|o| o.ecosystem == "golang") { +// ── golang (go.mod fork-replace + go.sum pin) ──────────────────────────────── +// The committable shape (validated empirically — `docs/design/golang-hosted.md`): +// +// go.mod: replace => patch.socket.dev/gopatch/ +// go.sum: patch.socket.dev/gopatch/ h1:… (zip dirhash) +// patch.socket.dev/gopatch/ /go.mod h1:… (served .mod) +// +// Day-2 machines need NO machine-local configuration: go consults the checksum +// database only for modules ABSENT from go.sum, the Socket module path is +// grant-free/content-addressed (one build-once artifact per patch, public on +// the free tier), and with the pinned replace in force go never fetches or +// verifies the original module at all. A dep whose reference carries no +// `goproxy` override falls back to the historical `redirect_golang_unsupported` +// warning (the paid tier's tokened URLs remain a genuine no-go — see the +// design doc's paid-tier analysis). +fn rewrite_golang( + files: &BTreeMap, + overrides: &[DepOverride], + result: &mut RewriteResult, +) { + use crate::vendor::go_mod_edit::{self, HOSTED_GO_MODULE_PREFIX}; + use crate::vendor::go_sum_edit; + + let golang: Vec<&DepOverride> = overrides + .iter() + .filter(|o| o.ecosystem == "golang") + .collect(); + if golang.is_empty() { + return; + } + // The replace directive can only live in the MAIN module's go.mod. + let Some(orig_go_mod) = files.get("go.mod") else { result.warnings.push(RewriteWarning { - code: "redirect_golang_unsupported".into(), - detail: format!( - "{}@{}: hosted redirect for Go is not possible without machine-local GOPROXY/GOPRIVATE configuration; run `socket-patch vendor` (committable, offline-verified) instead", - full_name(dep), - dep.version - ), + code: "redirect_golang_no_go_mod".into(), + detail: "no go.mod present; golang redirect skipped".into(), }); + return; + }; + let mut go_mod = orig_go_mod.clone(); + // An absent go.sum starts empty: the fully-replaced original needs no + // lines of its own, so the two socket lines alone are a complete pin. + let mut go_sum = files.get("go.sum").cloned().unwrap_or_default(); + let (mut mod_changed, mut sum_changed) = (false, false); + + for dep in &golang { + let fname = full_name(dep); + let Some(ov) = &dep.registry_override else { + result.warnings.push(RewriteWarning { + code: "redirect_golang_unsupported".into(), + detail: format!( + "{fname}@{}: no hosted Go module is published for this patch; run \ + `socket-patch vendor` (committable, offline-verified) instead", + dep.version + ), + }); + continue; + }; + if ov.kind != "goproxy" { + continue; + } + let (Some(rhs_module), Some(rhs_version)) = ( + &ov.identifiers.go_module_path, + &ov.identifiers.go_module_version, + ) else { + result.warnings.push(RewriteWarning { + code: "redirect_golang_missing_module".into(), + detail: format!( + "{fname}@{} goproxy override lacks goModulePath/goModuleVersion", + dep.version + ), + }); + continue; + }; + // 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) { + 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}`", + dep.version + ), + }); + continue; + } + // BOTH go.sum hashes must be pinnable up front — a replace without + // them (or with a malformed hash) bricks every `-mod=readonly` build. + let (Some(zip_h1), Some(gomod_h1)) = (&dep.integrity.dirhash_h1, &dep.integrity.go_mod_h1) + else { + result.warnings.push(RewriteWarning { + code: "redirect_golang_missing_integrity".into(), + detail: format!( + "{fname}@{} has no dirhashH1/goModH1 integrity pair", + dep.version + ), + }); + continue; + }; + if !zip_h1.starts_with("h1:") || !gomod_h1.starts_with("h1:") { + result.warnings.push(RewriteWarning { + code: "redirect_golang_missing_integrity".into(), + detail: format!( + "{fname}@{}: integrity hashes must be `h1:`-prefixed dirhashes", + dep.version + ), + }); + continue; + } + // Stale-pin cross-check: `replace` is keyed on module+version, and a + // pin the graph no longer selects is SILENTLY inert (the build links + // the unpatched module with zero warning) — refuse to write one. + if let Some(required) = go_mod_edit::parse_required_versions(&go_mod).get(&fname) { + if required != &dep.version { + result.warnings.push(RewriteWarning { + code: "redirect_golang_version_mismatch".into(), + detail: format!( + "{fname}: go.mod requires {required} but the patch targets {} — \ + a version-pinned replace would be silently ignored", + dep.version + ), + }); + continue; + } + } + + match go_mod_edit::upsert_hosted_replace_entry( + &go_mod, + &fname, + &dep.version, + rhs_module, + rhs_version, + ) { + Err(e) => { + result.warnings.push(RewriteWarning { + code: "redirect_golang_replace_conflict".into(), + detail: format!("{fname}@{}: {e}", dep.version), + }); + continue; + } + // Re-run over an already-redirected go.mod: nothing to record. + Ok(None) => {} + Ok(Some(new)) => { + go_mod = new; + mod_changed = true; + result.edits.push(FileEdit { + path: "go.mod".into(), + kind: "redirect_golang_replace".into(), + action: "added".into(), + key: Some(fname.clone()), + original: None, + new: Some(Value::String(format!( + "replace {fname} {} => {rhs_module} {rhs_version}", + dep.version + ))), + }); + } + } + if let Some(new) = + go_sum_edit::upsert_module_lines(&go_sum, rhs_module, rhs_version, zip_h1, gomod_h1) + { + go_sum = new; + sum_changed = true; + result.edits.push(FileEdit { + path: "go.sum".into(), + kind: "redirect_golang_gosum".into(), + action: "added".into(), + key: Some(format!("{rhs_module}@{rhs_version}")), + original: None, + new: Some(Value::String(format!( + "{rhs_module} {rhs_version} {zip_h1}\n{rhs_module} {rhs_version}/go.mod {gomod_h1}" + ))), + }); + } + // Prune the replaced original's lines: with the pinned replace in + // force go never fetches or verifies the original, and `go mod tidy` + // prunes exactly these — writing the tidy-stable state up front keeps + // the first day-2 tidy a byte-level no-op. The removed lines ride in + // `original` so the ledger can restore them on revert. + if let Some((new, removed)) = + go_sum_edit::remove_exact_module_version_lines(&go_sum, &fname, &dep.version) + { + go_sum = new; + sum_changed = true; + result.edits.push(FileEdit { + path: "go.sum".into(), + kind: "redirect_golang_gosum_prune".into(), + action: "removed".into(), + key: Some(format!("{fname}@{}", dep.version)), + original: Some(Value::String(removed.join("\n"))), + new: None, + }); + } + } + + if mod_changed { + result.files.insert("go.mod".into(), go_mod); + } + if sum_changed { + result.files.insert("go.sum".into(), go_sum); } } @@ -7590,4 +7792,291 @@ snapshots: r.warnings[0].detail ); } + + // ── golang ─────────────────────────────────────────────────────────────── + + const GO_UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + const GO_ZIP_H1: &str = "h1:mU9vN/n1hbXktM62lJ6MbRKOk3aI8NDH+szCf62RXtE="; + const GO_GOMOD_H1: &str = "h1:XgagPTRZSCprrzR+3Ro36/XJpibdovhAbsKThYI8bxg="; + + fn golang_socket_module() -> String { + format!("patch.socket.dev/gopatch/{GO_UUID}") + } + + /// A hosted golang reference for `github.com/foo/bar@v1.4.2`, patched + /// module published at `patch.socket.dev/gopatch/ v1.4.2-socketpatch.1`. + fn golang_override() -> DepOverride { + DepOverride { + ecosystem: "golang".into(), + name: "github.com/foo/bar".into(), + namespace: None, + version: "v1.4.2".into(), + token: String::new(), + patch_uuid: GO_UUID.into(), + artifact_url: format!( + "https://patch.socket.dev/patch-registry/golang/{}/@v/v1.4.2-socketpatch.1.zip", + golang_socket_module() + ), + berry_zip_url: None, + registry_override: Some(RegistryOverride { + kind: "goproxy".into(), + index_url: "https://patch.socket.dev/patch-registry/golang".into(), + identifiers: RegistryOverrideIdentifiers { + name: "github.com/foo/bar".into(), + version: "v1.4.2".into(), + go_module_path: Some(golang_socket_module()), + go_module_version: Some("v1.4.2-socketpatch.1".into()), + ..Default::default() + }, + }), + integrity: Integrity { + dirhash_h1: Some(GO_ZIP_H1.into()), + go_mod_h1: Some(GO_GOMOD_H1.into()), + ..Default::default() + }, + } + } + + fn golang_files() -> BTreeMap { + let mut files = BTreeMap::new(); + files.insert( + "go.mod".to_string(), + "module example.com/app\n\ngo 1.21\n\nrequire github.com/foo/bar v1.4.2\n".to_string(), + ); + files.insert( + "go.sum".to_string(), + "github.com/foo/bar v1.4.2 h1:UPSTREAM=\ngithub.com/foo/bar v1.4.2/go.mod h1:UPSTREAMM=\n".to_string(), + ); + files + } + + #[test] + fn golang_writes_replace_and_gosum_pair() { + let files = golang_files(); + let ovr = golang_override(); + let out = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + + assert!(out.warnings.is_empty(), "warnings: {:?}", out.warnings); + let go_mod = &out.files["go.mod"]; + assert!(go_mod.contains(&format!( + "replace github.com/foo/bar v1.4.2 => {} v1.4.2-socketpatch.1", + golang_socket_module() + ))); + assert!( + go_mod.contains("require github.com/foo/bar v1.4.2"), + "user content preserved" + ); + let go_sum = &out.files["go.sum"]; + assert!(go_sum.contains(&format!( + "{} v1.4.2-socketpatch.1 {GO_ZIP_H1}", + golang_socket_module() + ))); + assert!(go_sum.contains(&format!( + "{} v1.4.2-socketpatch.1/go.mod {GO_GOMOD_H1}", + golang_socket_module() + ))); + // The replaced original's lines are PRUNED (the tidy-stable state: go + // never fetches the fully-replaced version, and the first `go mod + // tidy` would remove exactly these lines otherwise). + assert!(!go_sum.contains("h1:UPSTREAM=")); + let prune = out + .edits + .iter() + .find(|e| e.kind == "redirect_golang_gosum_prune") + .expect("prune edit recorded"); + assert_eq!(prune.action, "removed"); + assert!( + prune + .original + .as_ref() + .is_some_and(|o| o.as_str().unwrap_or_default().contains("h1:UPSTREAM=")), + "removed lines ride in `original` for revert" + ); + // Replace + go.sum add + prune, informatively keyed. + assert_eq!(out.edits.len(), 3); + assert!(out.edits.iter().any(|e| e.path == "go.mod" + && e.kind == "redirect_golang_replace" + && e.key.as_deref() == Some("github.com/foo/bar"))); + assert!(out + .edits + .iter() + .any(|e| e.path == "go.sum" && e.kind == "redirect_golang_gosum")); + } + + #[test] + fn golang_second_pass_is_noop() { + let files = golang_files(); + let ovr = golang_override(); + let first = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + 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() && second.edits.is_empty() && second.warnings.is_empty(), + "re-run must be a no-op: files={:?} edits={:?} warnings={:?}", + second.files.keys(), + second.edits, + second.warnings + ); + } + + #[test] + fn golang_creates_go_sum_when_absent() { + let mut files = golang_files(); + files.remove("go.sum"); + let ovr = golang_override(); + let out = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert!(out.warnings.is_empty(), "warnings: {:?}", out.warnings); + let go_sum = &out.files["go.sum"]; + assert_eq!( + go_sum.lines().count(), + 2, + "fresh go.sum carries exactly the socket module's two lines" + ); + } + + #[test] + fn golang_without_override_falls_back_to_unsupported_warning() { + let files = golang_files(); + let mut ovr = golang_override(); + ovr.registry_override = None; + let out = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert!(out.files.is_empty() && out.edits.is_empty()); + assert_eq!(out.warnings.len(), 1); + assert_eq!(out.warnings[0].code, "redirect_golang_unsupported"); + assert!(out.warnings[0].detail.contains("socket-patch vendor")); + } + + #[test] + fn golang_no_go_mod_warns_and_skips() { + let mut files = golang_files(); + files.remove("go.mod"); + let ovr = golang_override(); + let out = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert!(out.files.is_empty()); + assert_eq!(out.warnings[0].code, "redirect_golang_no_go_mod"); + } + + /// Both go.sum hashes are load-bearing: a replace committed without them + /// bricks every `-mod=readonly` build downstream. Missing either one must + /// fail closed — warning, zero file changes. + #[test] + fn golang_missing_either_hash_fails_closed() { + for strip in ["zip", "gomod"] { + let files = golang_files(); + let mut ovr = golang_override(); + match strip { + "zip" => ovr.integrity.dirhash_h1 = None, + _ => ovr.integrity.go_mod_h1 = None, + } + let out = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert!( + out.files.is_empty() && out.edits.is_empty(), + "{strip}: must not write a partial redirect" + ); + assert_eq!(out.warnings[0].code, "redirect_golang_missing_integrity"); + } + } + + #[test] + fn golang_malformed_hash_fails_closed() { + let files = golang_files(); + let mut ovr = golang_override(); + ovr.integrity.dirhash_h1 = Some("sha256:nope".into()); + let out = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert!(out.files.is_empty()); + assert_eq!(out.warnings[0].code, "redirect_golang_missing_integrity"); + } + + /// The hosted namespace prefix is the ONLY ownership signal a + /// module-to-module replace carries — a server handing us any other path + /// must be refused (we could never recognize or remove the directive). + #[test] + fn golang_module_path_outside_namespace_refused() { + let files = golang_files(); + let mut ovr = golang_override(); + ovr.registry_override + .as_mut() + .unwrap() + .identifiers + .go_module_path = Some("evil.example/gopatch/x".into()); + 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" + ); + } + + /// `replace` is keyed on module+version and goes SILENTLY inert when the + /// graph resolves a different version (validated empirically) — writing + /// one against a mismatched require would claim protection it doesn't + /// deliver. + #[test] + fn golang_require_version_mismatch_skips() { + let mut files = golang_files(); + files.insert( + "go.mod".to_string(), + "module example.com/app\n\ngo 1.21\n\nrequire github.com/foo/bar v1.5.0\n".to_string(), + ); + let ovr = golang_override(); + let out = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert!(out.files.is_empty()); + assert_eq!(out.warnings[0].code, "redirect_golang_version_mismatch"); + assert!(out.warnings[0].detail.contains("v1.5.0")); + } + + /// A module NOT in the main go.mod's require block may still be resolved + /// (transitively) at the patched version — the cross-check only fires on a + /// positive mismatch, never on absence. + #[test] + fn golang_transitive_dep_absent_from_require_still_redirects() { + 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(), + ); + let ovr = golang_override(); + let out = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert!(out.warnings.is_empty(), "warnings: {:?}", out.warnings); + assert!(out.files["go.mod"].contains("replace github.com/foo/bar v1.4.2 =>")); + } + + #[test] + fn golang_user_authored_replace_conflict_warns() { + let mut files = golang_files(); + files.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 ovr = golang_override(); + let out = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert!(out.files.is_empty(), "must not override the user's fork"); + assert_eq!(out.warnings[0].code, "redirect_golang_replace_conflict"); + // go.sum must not gain socket lines for a redirect that wasn't written. + assert!(out.edits.is_empty()); + } + + /// Mode takeover: a local `.socket/go-patches/` replace from `apply` is + /// rewritten in place to the hosted module — one directive, no duplicate. + #[test] + fn golang_takes_over_local_redirect_in_place() { + let mut files = golang_files(); + files.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 => ./.socket/go-patches/github.com/foo/bar@v1.4.2\n" + .to_string(), + ); + let ovr = golang_override(); + let out = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert!(out.warnings.is_empty(), "warnings: {:?}", out.warnings); + let go_mod = &out.files["go.mod"]; + assert!(!go_mod.contains("go-patches"), "local target gone"); + assert_eq!( + go_mod.matches("replace github.com/foo/bar").count(), + 1, + "exactly one directive for the module: {go_mod}" + ); + } } 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 4facc9a6..65580c5f 100644 --- a/crates/socket-patch-core/src/vendor/go_mod_edit.rs +++ b/crates/socket-patch-core/src/vendor/go_mod_edit.rs @@ -11,10 +11,13 @@ //! 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`]). -//! A module-to-module replacement (`=> example.com/fork v1.2.3`) or a path -//! pointing anywhere else is user-authored and is never modified or removed. -//! The path prefix is the entire ownership signal; there is no `managed.json`. +//! 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, +//! [`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 +//! `managed.json`. //! //! At most one socket-owned `replace` exists per module: `ensure_replace_entry` //! rewrites an existing socket-owned line of EITHER owner in place (this @@ -45,14 +48,28 @@ pub const GO_PATCHES_DIR: &str = ".socket/go-patches"; /// target path is under this prefix is owned by [`ReplaceOwner::Vendor`]. 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 +/// 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 +/// prefix is the ONLY ownership signal for hosted directives (go.sum lines +/// carry no markers either), so the hosted rewriter refuses module paths the +/// server hands it outside this namespace. +pub const HOSTED_GO_MODULE_PREFIX: &str = "patch.socket.dev/gopatch/"; + /// Which socket-managed backend owns a `replace` directive, classified by the -/// directive's target-path prefix. +/// directive's target prefix (filesystem path or hosted module namespace). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ReplaceOwner { /// `apply`'s machine-local redirect copies under `.socket/go-patches/`. GoPatches, /// `vendor`'s committed copies under `.socket/vendor/golang//`. Vendor, + /// `scan --mode hosted`'s module-to-module replaces onto + /// [`HOSTED_GO_MODULE_PREFIX`] (no local bytes; go.sum pins integrity). + Hosted, } /// Classify a `replace` target path: which socket backend owns it, or `None` @@ -93,6 +110,11 @@ pub struct ReplaceEntry { /// Right-hand-side path, iff the replacement is a filesystem path /// (`None` for a module-to-module `=> mod ver` replacement). pub path: Option, + /// Right-hand-side module path, iff the replacement is module-to-module + /// (`None` for a filesystem-path replacement). + pub rhs_module: Option, + /// Right-hand-side version of a module-to-module replacement. + pub rhs_version: Option, /// Which socket backend owns this directive (`None` = user-authored). pub owner: Option, } @@ -268,23 +290,33 @@ fn parse_replace_body(body: &str) -> Option { let module = (*lhs.first()?).to_string(); let version = lhs.get(1).map(|s| s.to_string()); let first_rhs = rhs.first()?; - let (path, owner) = if rhs_is_path(first_rhs) { + let (path, rhs_module, rhs_version, owner) = if rhs_is_path(first_rhs) { let p = (*first_rhs).to_string(); let owner = detect_owner(&p); - (Some(p), owner) + (Some(p), None, None, owner) } else { - (None, None) // module-to-module replacement + // 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); + (None, Some(m), rhs.get(1).map(|s| s.to_string()), owner) }; Some(ReplaceEntry { module, version, path, + rhs_module, + rhs_version, owner, }) } -/// Parse every `replace` directive (single-line and block forms). -fn parse_replace_entries(content: &str) -> Vec { +/// Parse every `replace` directive (single-line and block forms). Pure — +/// exposed so the hosted rewriter (which works on in-memory content, not the +/// disk) can inspect the state it produced. +pub fn parse_replace_entries(content: &str) -> Vec { let mut out = Vec::new(); let _ = for_each_directive_body(content, "replace", |_, body| { out.extend(parse_replace_body(body)); @@ -294,8 +326,10 @@ fn parse_replace_entries(content: &str) -> Vec { } /// Parse `require` directives into `module -> version` (last wins; the module -/// graph selects one version per module path). -fn parse_required_versions(content: &str) -> HashMap { +/// graph selects one version per module path). Pure — exposed for the hosted +/// rewriter's stale-pin cross-check (a version-pinned `replace` is silently +/// inert when the graph resolves a different version). +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(); @@ -316,14 +350,50 @@ fn upsert_replace_entry( version: &str, base_rel: &str, ) -> Result, String> { - let want_path = replace_target_path(base_rel, module, version); - let want_line = format!("replace {module} {version} => {want_path}"); + upsert_socket_replace( + content, + module, + version, + &replace_target_path(base_rel, module, version), + ) +} + +/// Upsert a socket-owned module-to-module +/// `replace module version => rhs_module rhs_version` (the hosted redirect +/// shape — `rhs_module` must live under [`HOSTED_GO_MODULE_PREFIX`] or the +/// written directive would not parse back as socket-owned). Pure: callers +/// (the hosted rewriter) operate on in-memory file content, not the disk. +pub fn upsert_hosted_replace_entry( + content: &str, + module: &str, + version: &str, + rhs_module: &str, + rhs_version: &str, +) -> Result, String> { + debug_assert!(rhs_module.starts_with(HOSTED_GO_MODULE_PREFIX)); + upsert_socket_replace( + content, + module, + version, + &format!("{rhs_module} {rhs_version}"), + ) +} + +/// Shared upsert core: `target` is the full right-hand side — either a +/// `./`-prefixed filesystem path or `" "`. +fn upsert_socket_replace( + content: &str, + module: &str, + version: &str, + target: &str, +) -> 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; for_each_directive_body(content, "replace", |i, body| { - inspect_existing(body, module, version, &want_path, i, &mut socket_line) + inspect_existing(body, module, version, target, i, &mut socket_line) })?; if let Some(idx) = socket_line { @@ -337,7 +407,7 @@ fn upsert_replace_entry( .strip_prefix("replace") .is_some_and(|rest| rest.starts_with(char::is_whitespace)); let new = if is_block_member { - format!("{indent}{module} {version} => {want_path}") + format!("{indent}{module} {version} => {target}") } else { format!("{indent}{want_line}") }; @@ -348,11 +418,17 @@ fn upsert_replace_entry( return Ok(Some(join_preserving_trailing_newline(&lines, content))); } - // No socket-owned entry yet → append a single-line directive. + // No socket-owned entry yet → append a single-line directive, separated + // from the previous stanza by one blank line (the shape `go mod tidy` + // itself produces — anything else makes the first day-2 tidy churn the + // committed go.mod). let mut body = content.to_string(); if !body.is_empty() && !body.ends_with('\n') { body.push('\n'); } + if !body.is_empty() && !body.ends_with("\n\n") { + body.push('\n'); + } body.push_str(&want_line); body.push('\n'); Ok(Some(body)) @@ -365,7 +441,7 @@ fn inspect_existing( body: &str, module: &str, version: &str, - want_path: &str, + want_target: &str, line_idx: usize, socket_line: &mut Option, ) -> Result<(), String> { @@ -376,10 +452,10 @@ fn inspect_existing( return Ok(()); } if e.socket_owned() { - // A socket-owned entry (any version, EITHER owner): refresh it in + // A socket-owned entry (any version, ANY owner): refresh it in // 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 with no remove+add window. + // vendor copy (or a hosted module) with no remove+add window. if socket_line.is_none() { *socket_line = Some(line_idx); } @@ -388,7 +464,13 @@ fn inspect_existing( // A user-authored replace for the same module. Only the *same version* // (or a version-less catch-all) collides with the directive we want to add. let same_version = e.version.as_deref() == Some(version) || e.version.is_none(); - if same_version && e.path.as_deref() != Some(want_path) { + let existing_target = e.path.clone().or_else(|| { + e.rhs_module.as_ref().map(|m| match &e.rhs_version { + Some(v) => format!("{m} {v}"), + None => m.clone(), + }) + }); + if same_version && existing_target.as_deref() != Some(want_target) { return Err(format!( "go.mod already has a user-authored `replace {module}{}` => {}; \ refusing to overwrite", @@ -396,7 +478,7 @@ fn inspect_existing( .as_deref() .map(|v| format!(" {v}")) .unwrap_or_default(), - e.path.as_deref().unwrap_or("") + existing_target.as_deref().unwrap_or("") )); } Ok(()) @@ -706,6 +788,151 @@ replace ( .any(|e| e.module == "github.com/foo/bar" && e.socket_owned())); } + // ── hosted (module-to-module) replaces ─────────────────────────── + const HOSTED_MOD: &str = "patch.socket.dev/gopatch/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + + #[test] + fn test_parse_hosted_and_user_module_replace() { + let gomod = format!( + "module m\n\n\ + replace github.com/foo/bar v1.4.2 => {HOSTED_MOD} v1.4.2-socketpatch.1\n\ + replace example.com/qux => example.com/qux-fork v1.1.0\n" + ); + let entries = parse_replace_entries(&gomod); + let bar = entries + .iter() + .find(|e| e.module == "github.com/foo/bar") + .unwrap(); + assert_eq!(bar.owner, Some(ReplaceOwner::Hosted)); + assert_eq!(bar.path, None); + assert_eq!(bar.rhs_module.as_deref(), Some(HOSTED_MOD)); + assert_eq!(bar.rhs_version.as_deref(), Some("v1.4.2-socketpatch.1")); + // A user's fork replace stays user-authored (rhs captured, no owner). + let qux = entries + .iter() + .find(|e| e.module == "example.com/qux") + .unwrap(); + assert_eq!(qux.owner, None); + assert_eq!(qux.rhs_module.as_deref(), Some("example.com/qux-fork")); + assert_eq!(qux.rhs_version.as_deref(), Some("v1.1.0")); + } + + #[test] + fn test_upsert_hosted_appends_and_is_idempotent() { + let gomod = "module m\n\ngo 1.21\n\nrequire github.com/foo/bar v1.4.2\n"; + let out = upsert_hosted_replace_entry( + gomod, + "github.com/foo/bar", + "v1.4.2", + HOSTED_MOD, + "v1.4.2-socketpatch.1", + ) + .unwrap() + .unwrap(); + assert!(out.contains(&format!( + "replace github.com/foo/bar v1.4.2 => {HOSTED_MOD} v1.4.2-socketpatch.1" + ))); + assert!(out.ends_with('\n')); + assert!(upsert_hosted_replace_entry( + &out, + "github.com/foo/bar", + "v1.4.2", + HOSTED_MOD, + "v1.4.2-socketpatch.1", + ) + .unwrap() + .is_none()); + // Round-trips as socket-owned. + let entries = parse_replace_entries(&out); + assert_eq!( + entries + .iter() + .find(|e| e.module == "github.com/foo/bar") + .unwrap() + .owner, + Some(ReplaceOwner::Hosted) + ); + } + + /// Hosted takes over a go-patches (local apply) redirect in place — the + /// same single-write mechanism as the vendor takeover. + #[test] + fn test_upsert_hosted_takes_over_local_redirect() { + let gomod = "module m\n\nreplace github.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2\n"; + let out = upsert_hosted_replace_entry( + gomod, + "github.com/foo/bar", + "v1.4.2", + HOSTED_MOD, + "v1.4.2-socketpatch.1", + ) + .unwrap() + .unwrap(); + assert!(!out.contains("go-patches"), "old owner's path gone"); + let entries = parse_replace_entries(&out); + assert_eq!( + entries + .iter() + .filter(|e| e.module == "github.com/foo/bar") + .count(), + 1 + ); + assert_eq!(entries[0].owner, Some(ReplaceOwner::Hosted)); + } + + /// And the reverse: a local apply/vendor upsert refreshes a hosted line in + /// place (mode takeover), never appending a duplicate directive. + #[test] + fn test_upsert_path_takes_over_hosted() { + let gomod = format!( + "module m\n\nreplace github.com/foo/bar v1.4.2 => {HOSTED_MOD} v1.4.2-socketpatch.1\n" + ); + let out = upsert_replace_entry(&gomod, "github.com/foo/bar", "v1.4.2", GO_PATCHES_DIR) + .unwrap() + .unwrap(); + assert!(!out.contains("gopatch/"), "hosted target gone"); + let entries = parse_replace_entries(&out); + assert_eq!( + entries + .iter() + .filter(|e| e.module == "github.com/foo/bar") + .count(), + 1 + ); + assert_eq!(entries[0].owner, Some(ReplaceOwner::GoPatches)); + } + + #[test] + fn test_upsert_hosted_refuses_user_module_replace_same_version() { + let gomod = "module m\n\nreplace github.com/foo/bar v1.4.2 => example.com/fork v9.9.9\n"; + assert!(upsert_hosted_replace_entry( + gomod, + "github.com/foo/bar", + "v1.4.2", + HOSTED_MOD, + "v1.4.2-socketpatch.1", + ) + .is_err()); + } + + #[test] + fn test_remove_hosted_entry() { + let gomod = format!( + "module m\n\nreplace github.com/foo/bar v1.4.2 => {HOSTED_MOD} v1.4.2-socketpatch.1\n" + ); + // Wrong owner: no-op. + assert!( + remove_replace_entry(&gomod, "github.com/foo/bar", ReplaceOwner::GoPatches) + .unwrap() + .is_none() + ); + let out = remove_replace_entry(&gomod, "github.com/foo/bar", ReplaceOwner::Hosted) + .unwrap() + .unwrap(); + assert!(!out.contains("gopatch")); + assert!(out.contains("module m")); + } + // ── cross-owner takeover + owner filtering ─────────────────────── const VENDOR_BASE: &str = ".socket/vendor/golang/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; diff --git a/crates/socket-patch-core/src/vendor/go_sum_edit.rs b/crates/socket-patch-core/src/vendor/go_sum_edit.rs new file mode 100644 index 00000000..f0a6638f --- /dev/null +++ b/crates/socket-patch-core/src/vendor/go_sum_edit.rs @@ -0,0 +1,255 @@ +//! Pure `go.sum` line edits for the hosted Go redirect. +//! +//! The hosted redirect (`scan --mode hosted`) points a `replace` directive at +//! a Socket-published module (see `go_mod_edit::HOSTED_GO_MODULE_PREFIX`) and +//! must commit that module's two `go.sum` lines alongside it: +//! +//! ```text +//! patch.socket.dev/gopatch/ h1: +//! patch.socket.dev/gopatch/ /go.mod h1: +//! ``` +//! +//! Both lines are load-bearing on day-2 machines (validated empirically — +//! see `docs/design/golang-hosted.md`): under the default `-mod=readonly` a +//! missing zip line fails resolution up front, a missing `/go.mod` line fails +//! after download, and a *present* line is verified against the fetched bytes +//! (a wrong hash is a hard `SECURITY ERROR`). Crucially, go consults the +//! checksum database (`GOSUMDB`) only for modules **absent** from `go.sum` — +//! committed lines mean fresh clones and CI never ask `sum.golang.org` about +//! the Socket module, which is what makes the hosted redirect committable. +//! +//! Everything here is a pure `&str` transform (the hosted rewriters operate on +//! in-memory file content, mirrored byte-identically by depscan's TS twins). +//! Unrelated lines are preserved verbatim; insertions keep go's lexicographic +//! line order so a later `go mod tidy` is a no-op, not a reshuffle. (Whole-line +//! byte order equals go's `(module, version)` sort because `' '` compares +//! below every module-path/version character, and the zip line sorts before +//! its `/go.mod` sibling because `' '` < `'/'`.) + +/// The two `go.sum` lines for one module version. +fn module_lines(module: &str, version: &str, zip_h1: &str, gomod_h1: &str) -> [String; 2] { + [ + format!("{module} {version} {zip_h1}"), + format!("{module} {version}/go.mod {gomod_h1}"), + ] +} + +/// Upsert the two `go.sum` lines for `module@version`. Any existing lines for +/// exactly that module+version (either suffix form) are replaced; everything +/// else — including stale lines for the *replaced* original module, which go +/// tolerates and `go mod tidy` prunes — is preserved verbatim. `content` may +/// be empty (a project whose `go.sum` does not exist yet). Returns the new +/// content, or `None` when the file already carries exactly these lines. +pub fn upsert_module_lines( + content: &str, + module: &str, + version: &str, + zip_h1: &str, + gomod_h1: &str, +) -> Option { + let want = module_lines(module, version, zip_h1, gomod_h1); + let zip_key = format!("{module} {version} "); + let gomod_key = format!("{module} {version}/go.mod "); + + let mut lines: Vec<&str> = content.lines().collect(); + let already = lines + .iter() + .filter(|l| **l == want[0] || **l == want[1]) + .count() + == 2; + if already { + return None; + } + lines.retain(|l| !l.starts_with(&zip_key) && !l.starts_with(&gomod_key)); + + // Insert both lines at their sorted position (stable against an unsorted + // user file: first line strictly greater wins; ties cannot occur — the + // exact-key duplicates were just removed). + let mut out: Vec<&str> = Vec::with_capacity(lines.len() + 2); + let mut pending = want.iter().map(String::as_str).peekable(); + for line in lines { + while pending.peek().is_some_and(|w| *w < line) { + out.push(pending.next().unwrap()); + } + out.push(line); + } + out.extend(pending); + + let mut joined = out.join("\n"); + joined.push('\n'); + Some(joined) +} + +/// 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 +/// original at all, and `go mod tidy` prunes exactly these lines — writing +/// that state up front keeps the first day-2 tidy a byte-level no-op. Returns +/// `(new_content, removed_lines)`, or `None` when nothing matched. +pub fn remove_exact_module_version_lines( + content: &str, + module: &str, + version: &str, +) -> Option<(String, Vec)> { + let zip_key = format!("{module} {version} "); + let gomod_key = format!("{module} {version}/go.mod "); + let mut removed: Vec = Vec::new(); + let kept: Vec<&str> = content + .lines() + .filter(|l| { + if l.starts_with(&zip_key) || l.starts_with(&gomod_key) { + removed.push((*l).to_string()); + false + } else { + true + } + }) + .collect(); + if removed.is_empty() { + return None; + } + if kept.is_empty() { + return Some((String::new(), removed)); + } + let mut joined = kept.join("\n"); + joined.push('\n'); + Some((joined, removed)) +} + +/// Remove every `go.sum` line whose module path starts with `module_prefix` +/// (both the zip and `/go.mod` forms, any version). `go.sum` lines carry no +/// ownership markers, so removal — like ownership — keys on the socket-hosted +/// module namespace. Returns the new content, or `None` when nothing matched. +pub fn remove_module_prefix_lines(content: &str, module_prefix: &str) -> Option { + let kept: Vec<&str> = content + .lines() + .filter(|l| { + l.split_whitespace() + .next() + .is_none_or(|m| !m.starts_with(module_prefix)) + }) + .collect(); + if kept.len() == content.lines().count() { + return None; + } + if kept.is_empty() { + return Some(String::new()); + } + let mut joined = kept.join("\n"); + joined.push('\n'); + Some(joined) +} + +#[cfg(test)] +mod tests { + use super::*; + + const MOD: &str = "patch.socket.dev/gopatch/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + const VER: &str = "v1.4.2-socketpatch.1"; + const ZIP_H1: &str = "h1:mU9vN/n1hbXktM62lJ6MbRKOk3aI8NDH+szCf62RXtE="; + const GOMOD_H1: &str = "h1:XgagPTRZSCprrzR+3Ro36/XJpibdovhAbsKThYI8bxg="; + + #[test] + fn creates_from_empty_and_is_idempotent() { + let out = upsert_module_lines("", MOD, VER, ZIP_H1, GOMOD_H1).unwrap(); + assert_eq!( + out, + format!("{MOD} {VER} {ZIP_H1}\n{MOD} {VER}/go.mod {GOMOD_H1}\n") + ); + assert!(upsert_module_lines(&out, MOD, VER, ZIP_H1, GOMOD_H1).is_none()); + } + + #[test] + fn inserts_in_sorted_position_preserving_neighbors() { + // `github.com/... < patch.socket.dev/... < sigs.k8s.io/...` + let existing = "github.com/foo/bar v1.4.2 h1:AAA=\n\ + github.com/foo/bar v1.4.2/go.mod h1:BBB=\n\ + sigs.k8s.io/yaml v1.3.0 h1:CCC=\n"; + let out = upsert_module_lines(existing, MOD, VER, ZIP_H1, GOMOD_H1).unwrap(); + let lines: Vec<&str> = out.lines().collect(); + assert_eq!(lines.len(), 5); + assert!(lines[0].starts_with("github.com/foo/bar v1.4.2 h1:")); + assert!(lines[1].starts_with("github.com/foo/bar v1.4.2/go.mod")); + assert_eq!(lines[2], format!("{MOD} {VER} {ZIP_H1}")); + assert_eq!(lines[3], format!("{MOD} {VER}/go.mod {GOMOD_H1}")); + assert!(lines[4].starts_with("sigs.k8s.io/yaml")); + assert!(out.ends_with('\n')); + } + + #[test] + fn zip_line_sorts_before_gomod_line_between_versions() { + // Same module, an OLDER socket version already recorded: both new + // lines land after both old ones (version string sort), interleaved + // correctly. + let old = format!( + "{MOD} v1.0.0-socketpatch.1 h1:OLD=\n{MOD} v1.0.0-socketpatch.1/go.mod h1:OLDM=\n" + ); + let out = upsert_module_lines(&old, MOD, VER, ZIP_H1, GOMOD_H1).unwrap(); + let lines: Vec<&str> = out.lines().collect(); + assert_eq!(lines.len(), 4); + assert!(lines[0].contains("v1.0.0-socketpatch.1 h1:")); + assert!(lines[1].contains("v1.0.0-socketpatch.1/go.mod")); + assert!(lines[2].contains(&format!("{VER} {ZIP_H1}"))); + assert!(lines[3].contains(&format!("{VER}/go.mod"))); + } + + #[test] + fn replaces_stale_hashes_for_same_version() { + let stale = format!("{MOD} {VER} h1:STALE=\n{MOD} {VER}/go.mod h1:STALEM=\n"); + let out = upsert_module_lines(&stale, MOD, VER, ZIP_H1, GOMOD_H1).unwrap(); + assert!(!out.contains("STALE")); + assert_eq!(out.lines().count(), 2); + assert!(out.contains(ZIP_H1) && out.contains(GOMOD_H1)); + } + + /// `v1.0.0 ` vs `v1.0.0/go.mod `: the version key must not prefix-match a + /// longer version (`v1.0.0-socketpatch.1`) — the trailing space/`/go.mod` + /// in the removal keys guards that. + #[test] + fn does_not_clobber_longer_version_of_same_module() { + let other = format!("{MOD} {VER}.2 h1:KEEP=\n{MOD} {VER}.2/go.mod h1:KEEPM=\n"); + let out = upsert_module_lines(&other, MOD, VER, ZIP_H1, GOMOD_H1).unwrap(); + assert_eq!(out.lines().count(), 4); + assert!(out.contains("KEEP=")); + assert!(out.contains("KEEPM=")); + } + + #[test] + fn remove_exact_version_lines_only() { + let content = format!( + "example.com/lib v1.0.0 h1:OLD=\n\ + example.com/lib v1.0.0/go.mod h1:OLDM=\n\ + example.com/lib v1.0.1 h1:KEEP=\n\ + {MOD} {VER} {ZIP_H1}\n" + ); + let (out, removed) = + remove_exact_module_version_lines(&content, "example.com/lib", "v1.0.0").unwrap(); + assert_eq!(removed.len(), 2); + assert!(removed[0].contains("OLD=") && removed[1].contains("OLDM=")); + assert!(out.contains("v1.0.1 h1:KEEP="), "other versions kept"); + assert!(out.contains(ZIP_H1), "unrelated modules kept"); + assert!( + remove_exact_module_version_lines(&out, "example.com/lib", "v1.0.0").is_none(), + "idempotent" + ); + } + + #[test] + fn remove_by_prefix() { + let content = format!( + "github.com/foo/bar v1.4.2 h1:AAA=\n{MOD} {VER} {ZIP_H1}\n{MOD} {VER}/go.mod {GOMOD_H1}\n" + ); + let out = remove_module_prefix_lines(&content, "patch.socket.dev/gopatch/").unwrap(); + assert_eq!(out, "github.com/foo/bar v1.4.2 h1:AAA=\n"); + assert!(remove_module_prefix_lines(&out, "patch.socket.dev/gopatch/").is_none()); + } + + #[test] + fn remove_everything_yields_empty() { + let content = format!("{MOD} {VER} {ZIP_H1}\n"); + assert_eq!( + remove_module_prefix_lines(&content, "patch.socket.dev/gopatch/").unwrap(), + "" + ); + } +} diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index f6076c1e..1ef2eb5d 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -55,6 +55,7 @@ pub(crate) mod common; pub mod composer_lock; pub mod gem; pub mod go_mod_edit; +pub mod go_sum_edit; pub mod golang; pub mod lock_inventory; pub mod maven_repo; diff --git a/crates/socket-patch-core/tests/fixtures/redirect/golang/gomod/basic/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/golang/gomod/basic/expected-edits.json new file mode 100644 index 00000000..01f7e245 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/golang/gomod/basic/expected-edits.json @@ -0,0 +1,23 @@ +[ + { + "path": "go.mod", + "kind": "redirect_golang_replace", + "action": "added", + "key": "github.com/foo/bar", + "new": "replace github.com/foo/bar v1.4.2 => patch.socket.dev/gopatch/55555555-5555-5555-5555-555555555555 v1.4.2-socketpatch.1" + }, + { + "path": "go.sum", + "kind": "redirect_golang_gosum", + "action": "added", + "key": "patch.socket.dev/gopatch/55555555-5555-5555-5555-555555555555@v1.4.2-socketpatch.1", + "new": "patch.socket.dev/gopatch/55555555-5555-5555-5555-555555555555 v1.4.2-socketpatch.1 h1:mU9vN/n1hbXktM62lJ6MbRKOk3aI8NDH+szCf62RXtE=\npatch.socket.dev/gopatch/55555555-5555-5555-5555-555555555555 v1.4.2-socketpatch.1/go.mod h1:XgagPTRZSCprrzR+3Ro36/XJpibdovhAbsKThYI8bxg=" + }, + { + "path": "go.sum", + "kind": "redirect_golang_gosum_prune", + "action": "removed", + "key": "github.com/foo/bar@v1.4.2", + "original": "github.com/foo/bar v1.4.2 h1:2l9cOKZSVXK6d5nzJoRDWXvUqAqLz0RmJdY6spCZ6q4=\ngithub.com/foo/bar v1.4.2/go.mod h1:o4QpFlOhKfazXNvOG5FLDBCyo/oOCcSwSPGMnLXG9e0=" + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/golang/gomod/basic/expected/go.mod b/crates/socket-patch-core/tests/fixtures/redirect/golang/gomod/basic/expected/go.mod new file mode 100644 index 00000000..8bca9e15 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/golang/gomod/basic/expected/go.mod @@ -0,0 +1,7 @@ +module example.com/app + +go 1.21 + +require github.com/foo/bar v1.4.2 + +replace github.com/foo/bar v1.4.2 => patch.socket.dev/gopatch/55555555-5555-5555-5555-555555555555 v1.4.2-socketpatch.1 diff --git a/crates/socket-patch-core/tests/fixtures/redirect/golang/gomod/basic/expected/go.sum b/crates/socket-patch-core/tests/fixtures/redirect/golang/gomod/basic/expected/go.sum new file mode 100644 index 00000000..eb3e1515 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/golang/gomod/basic/expected/go.sum @@ -0,0 +1,2 @@ +patch.socket.dev/gopatch/55555555-5555-5555-5555-555555555555 v1.4.2-socketpatch.1 h1:mU9vN/n1hbXktM62lJ6MbRKOk3aI8NDH+szCf62RXtE= +patch.socket.dev/gopatch/55555555-5555-5555-5555-555555555555 v1.4.2-socketpatch.1/go.mod h1:XgagPTRZSCprrzR+3Ro36/XJpibdovhAbsKThYI8bxg= diff --git a/crates/socket-patch-core/tests/fixtures/redirect/golang/gomod/basic/input/go.mod b/crates/socket-patch-core/tests/fixtures/redirect/golang/gomod/basic/input/go.mod new file mode 100644 index 00000000..7cae01f2 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/golang/gomod/basic/input/go.mod @@ -0,0 +1,5 @@ +module example.com/app + +go 1.21 + +require github.com/foo/bar v1.4.2 diff --git a/crates/socket-patch-core/tests/fixtures/redirect/golang/gomod/basic/input/go.sum b/crates/socket-patch-core/tests/fixtures/redirect/golang/gomod/basic/input/go.sum new file mode 100644 index 00000000..c312873f --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/golang/gomod/basic/input/go.sum @@ -0,0 +1,2 @@ +github.com/foo/bar v1.4.2 h1:2l9cOKZSVXK6d5nzJoRDWXvUqAqLz0RmJdY6spCZ6q4= +github.com/foo/bar v1.4.2/go.mod h1:o4QpFlOhKfazXNvOG5FLDBCyo/oOCcSwSPGMnLXG9e0= diff --git a/crates/socket-patch-core/tests/fixtures/redirect/golang/gomod/basic/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/golang/gomod/basic/overrides.json new file mode 100644 index 00000000..bc39fae2 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/golang/gomod/basic/overrides.json @@ -0,0 +1,24 @@ +[ + { + "ecosystem": "golang", + "name": "github.com/foo/bar", + "version": "v1.4.2", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "55555555-5555-5555-5555-555555555555", + "artifactUrl": "https://patch.socket.dev/patch-registry/golang/patch.socket.dev/gopatch/55555555-5555-5555-5555-555555555555/@v/v1.4.2-socketpatch.1.zip", + "registryOverride": { + "kind": "goproxy", + "indexUrl": "https://patch.socket.dev/patch-registry/golang", + "identifiers": { + "name": "github.com/foo/bar", + "version": "v1.4.2", + "goModulePath": "patch.socket.dev/gopatch/55555555-5555-5555-5555-555555555555", + "goModuleVersion": "v1.4.2-socketpatch.1" + } + }, + "integrity": { + "dirhashH1": "h1:mU9vN/n1hbXktM62lJ6MbRKOk3aI8NDH+szCf62RXtE=", + "goModH1": "h1:XgagPTRZSCprrzR+3Ro36/XJpibdovhAbsKThYI8bxg=" + } + } +] diff --git a/crates/socket-patch-core/tests/redirect_golden.rs b/crates/socket-patch-core/tests/redirect_golden.rs index 4fdb6a27..ad4c4311 100644 --- a/crates/socket-patch-core/tests/redirect_golden.rs +++ b/crates/socket-patch-core/tests/redirect_golden.rs @@ -32,6 +32,7 @@ const RUST_IMPLEMENTED: &[&str] = &[ "nuget/packages-lock", "gem/bundler", "maven/pom", + "golang/gomod", ]; fn fixtures_root() -> PathBuf { diff --git a/docs/design/golang-hosted-no-go.md b/docs/design/golang-hosted-no-go.md index f6f4e6d1..b8f61912 100644 --- a/docs/design/golang-hosted-no-go.md +++ b/docs/design/golang-hosted-no-go.md @@ -1,10 +1,24 @@ # Hosted redirect for Go: a deliberate no-go -**Status:** decided — golang is excluded from HOSTED (registry-redirect) mode. +> **SUPERSEDED for the FREE tier (2026-08-13)** by +> [golang-hosted.md](golang-hosted.md): a fork-style `replace` onto a +> grant-free, content-addressed `patch.socket.dev/gopatch/` module plus +> committed `go.sum` lines dissolves all three blockers below — empirically, +> go consults the checksum database only for modules *absent* from `go.sum`, +> so committed hashes ARE the committable per-module exemption Blocker 1 said +> Go lacked, and a token-free module path defuses Blockers 2 and 3. **The +> analysis below still governs the PAID tier** (tokened URLs re-trigger +> Blockers 2 and 3), whose remedy remains vendored mode or the +> [ephemeral-CI recipe](#sanctioned-exception-ephemeral-ci-goproxy). + +**Status:** paid tier only — free-tier golang HOSTED mode is designed in +[golang-hosted.md](golang-hosted.md). **Remedy:** `socket-patch vendor` (VENDORED mode: bytes committed to `.socket/vendor/`, offline-verified, `replace => ./path` in `go.mod`). -**Warning code:** `redirect_golang_unsupported` (emitted by both the Rust -CLI rewriter and the depscan backend's TS twin, +**Warning code:** `redirect_golang_unsupported` (emitted for golang references +that carry no `goproxy` override — paid grants, or free patches the server has +not yet published a hosted Go module for — by both the Rust CLI rewriter and +the depscan backend's TS twin, `workspaces/app/src/patches/registry-rewrite/golang.ts`). HOSTED mode's contract is a *committable, per-dependency* lockfile/registry diff --git a/docs/design/golang-hosted.md b/docs/design/golang-hosted.md new file mode 100644 index 00000000..88b5d701 --- /dev/null +++ b/docs/design/golang-hosted.md @@ -0,0 +1,195 @@ +# Hosted redirect for Go: the fork-replace design (free tier) + +**Status:** implemented CLI-side (rewriter, golden fixture, day-2 e2e); waiting +on server-side publication (see [Server requirements](#server-requirements-depscan)). +**Supersedes:** [golang-hosted-no-go.md](golang-hosted-no-go.md) for the FREE +tier. The paid-tier analysis there (blockers 2 and 3 against tokened URLs) +still stands; paid golang references must not carry a `goproxy` override. + +## The shape + +`scan --mode hosted` commits exactly two files' worth of edits — no artifact +bytes, no machine-local configuration: + +```text +# go.mod +replace github.com/foo/bar v1.4.2 => patch.socket.dev/gopatch/ v1.4.2-socketpatch.1 + +# go.sum +patch.socket.dev/gopatch/ v1.4.2-socketpatch.1 h1:… (module-zip dirhash) +patch.socket.dev/gopatch/ v1.4.2-socketpatch.1/go.mod h1:… (served .mod bytes) +``` + +The patched module is published — grant-free and content-addressed, one +build-once artifact per patch — at a Socket-owned module path under +`patch.socket.dev/gopatch/`, served over the standard GOPROXY protocol. The +`replace` is Go's native fork mechanism; the committed `go.sum` lines are the +integrity pin. The rewriter also prunes the replaced original's two `go.sum` +lines: with the pinned replace in force, go removes the original from the +module graph entirely, and writing the tidy-stable state up front keeps the +first day-2 `go mod tidy` a byte-level no-op. + +## Why this dissolves the no-go doc's blockers (free tier) + +Every claim below was validated empirically against go 1.26 before the feature +was built (file-`GOPROXY` fixtures, fresh per-"machine" caches; the capstone +lives in `crates/socket-patch-cli/tests/e2e_golang_hosted_build.rs`). + +**Blocker 1 — day-2 sumdb hard-fail.** The no-go analysis assumed every fresh +machine re-consults `sum.golang.org` for the patched version. In fact go +consults the checksum database **only for modules absent from `go.sum`**. +Proven with a tripwire: every day-2 command run with `GOSUMDB` set to a bogus +database name (which fails loudly the moment it is consulted) — `go build`, +`go run`, `go test`, `go vet`, `go mod download`, `go mod verify`, +`go mod tidy` all succeed on an empty cache with only the committed +`go.mod` + `go.sum`, and zero `/sumdb/` requests appear in proxy logs. The +control (deleting one go.sum line) fails with `invalid GOSUMDB`, proving the +tripwire works. Committed `go.sum` lines ARE the committable per-module sumdb +exemption the old doc said Go didn't have. + +**Blocker 2 — module-path identity forces per-grant artifacts.** Only true if +grant material rides in the module path. The socket module path is +content-addressed by patch uuid and carries no token: one frozen artifact per +patch, shared by every consumer — build-once compatible. The zip's entry +prefix must be the socket path (`gopatch/@/`, go rejects any +other prefix), but its **internal `go.mod` may keep declaring the ORIGINAL +module path** — go accepts a replacement declaring either side of the arrow — +and patched sources MUST keep their original import spellings (rewriting them +to the socket path fails with `used for two different module paths`). So the +artifact is the upstream module with patched files, rezipped under a new +prefix: a zero-source-rewrite converter. + +**Blocker 3 — default GOPROXY publishes licensed bytes / leaks tokens.** For +the free tier, both horns are blunt: free patches are already anonymously +fetchable (production mints free-tier grants with no auth today), so +`proxy.golang.org` caching the module is republication of something already +public — and there is no token to leak because the path is grant-free. Google's +mirror caching the bytes is a robustness *win*: after first fetch, day-2 +builds succeed even if `patch.socket.dev` is down. (Also measured: with the +pinned replace in force, go never fetches or verifies the ORIGINAL module at +all — the patched graph builds even if the upstream registry is down.) + +## Day-2 contract (all validated) + +- Fresh clone/CI with committed `go.mod` + `go.sum`, empty caches, default + `-mod=readonly`, no `GOPRIVATE`/`GONOSUMDB`/`GOFLAGS`: builds and links the + patched code. +- `go.sum` verification keeps its teeth: one flipped character in the + committed `h1:` fails the build with go's checksum `SECURITY ERROR` — a + wrong CLI-written hash can never be silently built. (This is also why the + rewriter fails closed unless BOTH hashes are present: a replace committed + without its go.sum lines bricks every downstream `-mod=readonly` build.) +- `go mod tidy` is a byte-level no-op on the rewriter's output. +- `go mod vendor` vendors the PATCHED bytes under the upstream import path + (`vendor/modules.txt` records the replace) and `-mod=vendor` builds stay + patched — the vendored escape hatch composes. +- Proxy fetch sequence for the pinned module is exactly `.zip`, `.mod`, + `.info` (`.info` is required even for a fully pinned build; `/@v/list` and + `/@latest` are never requested on the pinned path). +- Module zips' `h1:` dirhash is content-derived (entry names + contents; + compression, mtimes, ordering, modes are ignored) — but the `/go.mod h1:` + hashes the SERVED `.mod` bytes, and go does NOT cross-check them against the + zip's internal `go.mod`. Server contract: freeze the two together. + +## Known limitations (documented, not blockers) + +- **Upgrade drift** (shared with local/vendored modes): `replace` is keyed on + module+version. `go get -u` / a require bump silently strands the pin — the + build reverts to the vulnerable version with zero warning while the inert + replace line stays in `go.mod`, and the next tidy strips the patch's go.sum + lines. Reliable text-only drift signals for a future `--check`: replace LHS + version ≠ require version; socket replace present with no matching go.sum + line. The rewriter refuses up front when `require` already disagrees with + the patch version (`redirect_golang_version_mismatch`). +- **Corporate proxies**: a `GOPROXY` pinned to an internal mirror (Artifactory + etc.) that cannot reach `patch.socket.dev` will 404 the socket module unless + the mirror passes through. Comma-fallback semantics also mean a proxy that + answers 403/500 (rather than 404/410) blocks the fetch. +- **Pre-modules packages**: modules without a `go.mod` are rejected by the + build pipeline (`UNSUPPORTED_ARCHIVE_FORMAT`) — same limit as vendored mode. +- **v2+ originals**: the socket module is always published in the v0/v1 + version range (a v2+ RHS version would force a `/v2` path suffix); the RHS + version need not relate to the original's. Not yet exercised against a real + `/v2` module — verify when the first such patch ships. +- **Paid tier**: unchanged no-go. Tokened URLs re-trigger blockers 2 and 3; + the ephemeral-CI `GOPROXY` recipe in the old doc remains the documented + paid workaround. + +## CLI implementation + +- `patch/redirect/mod.rs rewrite_golang` — pure rewriter, activates per-dep on + `registry_override.kind == "goproxy"`; absent override falls back to the + historical `redirect_golang_unsupported` warning. Fails closed (warning, no + partial writes) on: missing `go.mod`, missing/malformed `goModulePath` / + `goModuleVersion`, module path outside `patch.socket.dev/gopatch/`, missing + either integrity hash, require-version mismatch, user-authored replace + conflict. Warning codes: `redirect_golang_no_go_mod`, + `redirect_golang_missing_module`, `redirect_golang_untrusted_module_path`, + `redirect_golang_missing_integrity`, `redirect_golang_version_mismatch`, + `redirect_golang_replace_conflict`. +- `vendor/go_mod_edit.rs` — `ReplaceOwner::Hosted` (ownership = RHS module + path under `HOSTED_GO_MODULE_PREFIX`; go.mod and go.sum carry no other + marker), module-target parse (`rhs_module`/`rhs_version`) and + `upsert_hosted_replace_entry`, with in-place cross-owner takeover between + local `.socket/go-patches/`, vendored, and hosted directives. +- `vendor/go_sum_edit.rs` — pure go.sum editing: sorted upsert of the socket + module's two lines, prune of the replaced original's lines (removed lines + ride in the ledger `original` for revert), prefix-keyed removal. +- `scan/hosted.rs` — `go.mod`/`go.sum` added to `REDIRECT_CANDIDATE_FILES`; + the redirected-confirmation matcher additionally accepts the socket module + path (a Go rewrite contains no artifact/index URL). +- Wire schema — `Integrity.goModH1` (new) alongside `dirhashH1`; + `RegistryOverrideIdentifiers.goModuleVersion` (new) alongside the + previously-reserved `goModulePath`. +- Tests — unit suite in `redirect/mod.rs`; golden fixture + `tests/fixtures/redirect/golang/gomod/basic/` (the cross-language contract + the depscan TS twin must match byte-identically); capstone + `e2e_golang_hosted_build.rs` (real go, file proxy, bogus-GOSUMDB tripwire, + tidy no-op, tamper SECURITY ERROR). + +## Server requirements (depscan) + +What already exists (verified in code + live prod probes, 2026-08-13): + +- The GOPROXY protocol implementation (`patch-serving/registry-decision.ts` + `golangDecision`: `/@v/list`, `.info`, `.mod`, `.zip`, `/@latest`) — deployed + and executing in prod, but token-gated + (`/patch-registry/golang/{token}/{uuid}/…`) and serving the ORIGINAL module + path + version. +- A byte-deterministic golang repack (STORE-only zips, epoch mtimes, sorted + names; e2e-pinned to reproduce `sum.golang.org`'s h1 for unpatched input). +- `hashGoZip` (persisted as `package_dirhash_h1`) and `hashGoMod` (exists in + `lib/src/go/sum.ts` but never persisted or exposed). +- Anonymous free-tier grant minting via `POST /patch/package`. + +What the free-tier Go redirect needs: + +1. **A second artifact flavor per golang patch**: same patched contents, + zip entries prefixed `patch.socket.dev/gopatch/@/` + (internal `go.mod` unchanged — keep declaring the original path). Because + h1 covers entry names, this flavor has its OWN dirhash: persist both its + zip h1 and its `/go.mod` h1 (`hashGoMod` of the served `.mod` bytes). +2. **A token-free route family** serving that flavor over the GOPROXY + protocol at a stable public path for module `patch.socket.dev/gopatch/`, + free-tier patches only. Strictly 404/410 anything else (including bare + parent-prefix `/@v/list` probes go issues during tidy) so comma-fallback + proxies fall through. `.info` needs no `Time` field. +3. **`?go-get=1` discovery**: serve + `` + at the module path on `patch.socket.dev`, so `GOPROXY=direct` users and + `proxy.golang.org` itself can resolve it (validated end-to-end with the + `mod` VCS type; `sum.golang.org`'s own lookup does the same discovery). +4. **Reference API**: populate `registryOverride.kind = "goproxy"`, + `indexUrl` = the proxy base, `identifiers.goModulePath` / + `goModuleVersion` (`-socketpatch.`, always v0/v1-range), and + both integrity hashes (`dirhashH1` = the gopatch-flavor zip h1, `goModH1`). + FREE patches only — never emit a `goproxy` override for a paid-tier grant. +5. **Write-once invariant (the kill-shot risk)**: once a + `gopatch/@` is fetchable, `proxy.golang.org` caches its + bytes immutably and consumers commit its hashes. Any rebuild that changes + bytes MUST bump `-socketpatch.`; the existing admin + "regenerate to populate" rebuild practice must be forbidden for published + gopatch versions. +6. **TS twin**: replace `registry-rewrite/golang.ts`'s unconditional warning + with the byte-identical twin of the Rust rewriter, pinned by the shared + `golang/gomod` golden fixture. diff --git a/docs/ecosystems.md b/docs/ecosystems.md index e30cbb43..7733e1bd 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -18,7 +18,7 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. | PyPI (`pypi`) — uv / poetry / pdm / pipenv / pip | ✅ `.pth` startup hook via `setup` | ✅ five lockfile flavors: uv, poetry, pdm, pipenv (lock rewired, but pipenv doesn't hash-check file entries — `vendor_integrity_unverified` warning; the committed wheel bytes are the protection), and requirements.txt (consumed by pip or `uv pip`) | ✅ requirements.txt + uv.lock. **poetry / pdm / pipenv locks are not rewritten** — use vendored | | Cargo (`cargo`) | ✅ in-place + `.cargo-checksum.json` rewrite (shared registry-cache caveat — see [Cargo: shared registry cache](#cargo-shared-registry-cache)) | ✅ `[patch.crates-io]` path entry | ✅ per-patch sparse registry (`[registries.socket-patch-]` + Cargo.lock source/checksum) | | RubyGems (`gem`) | ✅ Bundler plugin via `setup` | ✅ Gemfile + Gemfile.lock path pair (`Gemfile` spelling only — a `gems.rb` project cannot vendor yet) | ✅ per-dep `source` block — edits `gems.rb` + `gems.locked` when present (bundler prefers them over `Gemfile`; spellings that diverge beyond Socket's own edits fail closed with `redirect_gem_gemfile_spellings_diverge`); the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning) | -| Go (`golang`) | ✅ `go.mod` `replace` → `.socket/go-patches/` — see [Go: directory replaces and go.sum](#go-directory-replaces-and-gosum) | ✅ `replace` → the committed vendor tree | ❌ **not possible** — sumdb, module-path identity, and default-GOPROXY leakage each rule it out; see [golang-hosted-no-go.md](design/golang-hosted-no-go.md). **Use vendored** (`redirect_golang_unsupported` names the remedy) | +| Go (`golang`) | ✅ `go.mod` `replace` → `.socket/go-patches/` — see [Go: directory replaces and go.sum](#go-directory-replaces-and-gosum) | ✅ `replace` → the committed vendor tree | ✅ (free tier) fork-style `replace` → `patch.socket.dev/gopatch/` + committed `go.sum` pin; see [golang-hosted.md](design/golang-hosted.md). Paid tier stays ❌ ([golang-hosted-no-go.md](design/golang-hosted-no-go.md)); `redirect_golang_unsupported` names the vendored remedy | | Maven (`maven`) | ✅ apply-only (no `setup` hook — reports `no_files`); in-place jar patching leaves the `~/.m2` checksum sidecars stale — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) | ✅ committed maven2 `file://` repository. A root pom declaring `` (multi-module aggregator) is refused (`vendor_maven_multimodule_unsupported`), and a gradle-only project is refused (`vendor_gradle_unsupported`) | ✅ **pom projects only, fail-closed** — the patched jar is pinned at a Socket-only `-socket.` suffix; `${property}` versions are refused; Gradle gets a manual `exclusiveContent` snippet — see [Maven & NuGet caveats](#maven--nuget-caveats) | | NuGet (`nuget`) | ✅ apply-only (no `setup` hook — reports `no_files`); in-place patching deletes `.nupkg.metadata` and advises on the `.nupkg.sha512` tamper-evidence sidecar — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) | ✅ committed folder feed + `packageSourceMapping` + `packages.lock.json` contentHash pin | ✅ `nuget.config` source + source-mapping, `packages.lock.json` contentHash rewrite. See the locked-mode note in [Maven & NuGet caveats](#maven--nuget-caveats) | | Composer (`composer`) | ✅ post-install script events | ✅ `composer.lock` `dist: path` rewrite | ✅ `composer.lock` dist url + shasum rewrite | @@ -161,9 +161,17 @@ commit it, and review it like any other vendored code. The wiring survives `go mod tidy`, and `apply --check` gives CI a read-only audit that the committed redirects still match the manifest. -Hosted mode is a hard ❌ for Go — sumdb verification, module-path identity, and -default-GOPROXY leakage each independently rule it out; the full analysis is in -[golang-hosted-no-go.md](design/golang-hosted-no-go.md). +Hosted mode uses Go's other native `replace` form — a fork-style +module-to-module directive onto a Socket-published, content-addressed module +(`replace => patch.socket.dev/gopatch/ -socketpatch.`) +plus the module's two committed `go.sum` lines. Because go consults the +checksum database only for modules *absent* from `go.sum`, the committed pair +is the complete day-2 state: fresh clones and CI build the patched module with +no machine-local configuration, and a tampered hash still fails closed with +go's checksum `SECURITY ERROR`. Free tier only; the paid-tier analysis (and +the ephemeral-CI workaround) is in +[golang-hosted-no-go.md](design/golang-hosted-no-go.md), the full free-tier +design in [golang-hosted.md](design/golang-hosted.md). ## Supported platforms diff --git a/docs/testing/hosted-production-e2e.md b/docs/testing/hosted-production-e2e.md index 5886e80e..6382ea47 100644 --- a/docs/testing/hosted-production-e2e.md +++ b/docs/testing/hosted-production-e2e.md @@ -78,7 +78,7 @@ failure instead of N confusing ones that look like CLI regressions. | Maven | ✅ | ❌ **none** | canary only | | NuGet | ✅ | ❌ **none** | canary only | | Composer | ✅ | ❌ **none** | canary only | -| Go | ❌ [by design](../design/golang-hosted-no-go.md) | ❌ none | negative assertion | +| Go | ✅ free tier [by design](../design/golang-hosted.md) (paid: ❌ [analysis](../design/golang-hosted-no-go.md)) | ❌ none published yet | shape guard (redirects only via `goproxy` override) | | Deno | ❌ not supported | — | negative assertion | Maven, NuGet and Composer all *implement* hosted mode, but production publishes From 78d949ff856bfd9aa86ccb8308e5666699b38117 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 13 Aug 2026 11:22:06 -0700 Subject: [PATCH 2/5] fix(hosted-golang): harden cross-mode interactions and server-input trust per adversarial review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine confirmed findings from the post-implementation review, all fixed: - Local apply now FAILS CLOSED on a Hosted-owned replace instead of silently taking it over (which would strand pruned go.sum lines and break every -mod=readonly build once the directive is later dropped); the error names the way out. Hosted->local stays one-way by design — hosted-takes-over-local reconciles go.sum in the same pass. - apply --check no longer reports MissingReplace drift for a module a hosted redirect legitimately took over (exemption now matches Vendor AND Hosted owners). - vendor takeover of a hosted replace is visible: the wiring record captures the hosted directive text as `original` (action Rewritten), a vendor_takeover warning explains the go.sum implications up front, and vendor --revert detects the hosted prior from the namespace prefix in `original` and names the recovery (`scan --mode hosted` re-run or `go mod tidy`). - Injection guard: every server-controlled token written into the line-oriented go.mod/go.sum (module paths, versions) is refused on whitespace/control characters (redirect_golang_unsafe_coords), and h1 hashes must be exactly `h1:` + 44-char base64. - A committed-but-inert socket pin (require bumped past the patch version) is now reconciled away — directive and its go.sum lines removed with `removed` edits — so the stale module path can no longer confirm the dep as redirected (ledger + VEX) while go links the unpatched version. - Hosted takeover of an existing socket directive records the replaced text in the ledger `original` with action `updated` (was: added, original None — the pre-redirect state was lost to revert). - e2e hardening: GOENV=off (empty env-var pins don't defeat `go env -w` config), a chmod-on-drop guard so read-only GOMODCACHE trees don't leak from the tempdir on assertion failure, and a positive tripwire control proving the bogus GOSUMDB actually fires when consulted (a missing go.sum line must fail with the invalid verifier error). Co-Authored-By: Claude Fable 5 --- .../tests/e2e_golang_hosted_build.rs | 54 ++++- .../src/patch/redirect/golang_local.rs | 48 +++- .../src/patch/redirect/mod.rs | 225 +++++++++++++++++- .../src/vendor/go_mod_edit.rs | 6 +- crates/socket-patch-core/src/vendor/golang.rs | 69 +++++- 5 files changed, 373 insertions(+), 29 deletions(-) diff --git a/crates/socket-patch-cli/tests/e2e_golang_hosted_build.rs b/crates/socket-patch-cli/tests/e2e_golang_hosted_build.rs index 7658a32c..184e4687 100644 --- a/crates/socket-patch-cli/tests/e2e_golang_hosted_build.rs +++ b/crates/socket-patch-cli/tests/e2e_golang_hosted_build.rs @@ -76,6 +76,10 @@ fn go(dir: &Path, machine: &Machine, args: &[&str], env: &[(&str, &str)]) -> std cmd.env("GOMODCACHE", &machine.modcache); cmd.env("GOCACHE", &machine.gocache); cmd.env("GOTOOLCHAIN", "local"); + // Empty env-var pins do NOT defeat `go env -w` config — go treats an + // empty variable as unset and falls back to the env FILE. GOENV=off is + // the only switch that ignores it entirely. + cmd.env("GOENV", "off"); for (k, v) in env { cmd.env(k, v); } @@ -101,6 +105,19 @@ fn as_pairs<'a>(env: &'a [(&'static str, String)]) -> Vec<(&'a str, &'a str)> { env.iter().map(|(k, v)| (*k, v.as_str())).collect() } +/// Go writes extracted modules into GOMODCACHE with read-only dirs, which +/// makes `TempDir::drop`'s remove fail silently — and a panicking assertion +/// would skip any trailing chmod. Restore write bits on EVERY exit path. +struct ChmodGuard(PathBuf); +impl Drop for ChmodGuard { + fn drop(&mut self) { + let _ = Command::new("chmod") + .args(["-R", "u+w"]) + .arg(&self.0) + .status(); + } +} + /// Stage a module dir and zip it into the file-proxy under `mod_path@ver/`. fn publish(tmp: &Path, mod_path: &str, ver: &str, gomod: &str, lib: &str) { let stage_root = tmp.join("stage").join(mod_path.replace('/', "_")); @@ -182,6 +199,7 @@ fn day2_machine_builds_patched_module_from_committed_files_alone() { std::env::set_var("GOFLAGS", "-mod=mod"); std::env::set_var("GONOSUMDB", "*"); let tmp = tempfile::tempdir().unwrap(); + let _cleanup = ChmodGuard(tmp.path().to_path_buf()); let proxy_url = format!("file://{}", tmp.path().join("proxy").display()); let smod = socket_module(); @@ -343,9 +361,35 @@ fn day2_machine_builds_patched_module_from_committed_files_alone() { ); std::fs::write(consumer.join("go.sum"), &before_sum).unwrap(); - // Best-effort: the go module cache is written read-only; relax perms so - // the tempdir cleans up. - let _ = Command::new("chmod") - .args(["-R", "u+w", tmp.path().to_str().unwrap()]) - .status(); + // ── positive tripwire control ──────────────────────────────────────── + // Prove the bogus GOSUMDB actually fires when consulted: with the socket + // zip h1 line REMOVED, resolving the module needs the checksum database, + // and the bogus name must fail loudly — this is what makes every green + // assertion above meaningful (the tripwire is demonstrably armed). + let without_h1: String = before_sum + .lines() + .filter(|l| !l.starts_with(&format!("{smod} {SVER} h1:"))) + .map(|l| format!("{l}\n")) + .collect(); + std::fs::write(consumer.join("go.sum"), &without_h1).unwrap(); + let m4 = Machine::new(tmp.path(), "machine4"); + let dl = go( + &consumer, + &m4, + &["mod", "download", &format!("{smod}@{SVER}")], + &as_pairs(&env), + ); + let dl_err = String::from_utf8_lossy(&dl.stderr); + assert!( + !dl.status.success(), + "a missing go.sum line must force a checksum-DB lookup that fails, got: {}", + String::from_utf8_lossy(&dl.stdout) + ); + assert!( + dl_err.contains("GOSUMDB") + || dl_err.contains("verifier") + || dl_err.contains("sum.invalid.example"), + "failure must come from the bogus checksum DB (tripwire armed), got: {dl_err}" + ); + std::fs::write(consumer.join("go.sum"), &before_sum).unwrap(); } 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 6642db70..29aeea60 100644 --- a/crates/socket-patch-core/src/patch/redirect/golang_local.rs +++ b/crates/socket-patch-core/src/patch/redirect/golang_local.rs @@ -203,6 +203,34 @@ pub async fn apply_go_redirect( return synthesized_result(purl, ©_dir, Vec::new(), true, None); } + // A hosted-mode `replace` (`scan --mode hosted`) owns this module. Taking + // it over here would silently discard the committed hosted redirect: the + // shared upsert would repoint the directive at the local copy while + // go.sum still carries the socket module's lines and is missing the + // original's (the hosted rewrite prunes them; they are only restorable + // from the redirect ledger, which this path does not read). The reverse + // direction — hosted taking over a local redirect — IS supported, because + // the hosted rewriter reconciles go.sum in the same pass. Fail closed and + // name the way out. + if read_replace_entries(project_root) + .await + .iter() + .any(|e| e.module == module && e.owner == Some(ReplaceOwner::Hosted)) + { + return synthesized_result( + purl, + ©_dir, + Vec::new(), + false, + Some(format!( + "go.mod holds a hosted-mode replace for {module} (written by `scan --mode \ + hosted`); refusing to overwrite it — revert the hosted redirect first \ + (restore go.mod/go.sum from version control, or drop the replace and run \ + `go mod tidy`), then re-run apply" + )), + ); + } + if dry_run { // Verify (read-only) against the pristine source for an accurate // "would patch" report, without creating the copy or editing go.mod. @@ -410,14 +438,18 @@ pub async fn verify_go_redirect_state( continue; } - // A vendor-owned `replace` outranks the go-patches redirect: the module - // is managed by `socket-patch vendor`, so this audit must not demand a - // go-patches copy/directive for it (that would report MissingCopy/ - // WrongReplacePath drift for every vendored module). - if entries - .iter() - .any(|e| e.module == module && e.owner == Some(ReplaceOwner::Vendor)) - { + // A vendor- or hosted-owned `replace` outranks the go-patches + // redirect: the module is managed by `socket-patch vendor` / + // `scan --mode hosted`, so this audit must not demand a go-patches + // copy/directive for it (that would report MissingCopy/MissingReplace + // drift for every module another socket mode legitimately took over). + if entries.iter().any(|e| { + e.module == module + && matches!( + e.owner, + Some(ReplaceOwner::Vendor) | Some(ReplaceOwner::Hosted) + ) + }) { continue; } diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index cf4ce17e..b38150a7 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -3867,6 +3867,25 @@ fn gradle_snippet( } // ── golang (go.mod fork-replace + go.sum pin) ──────────────────────────────── +/// go.mod and go.sum are whitespace-delimited line formats, and the golang +/// rewriter interpolates server-controlled strings into both — any embedded +/// whitespace/control character would split tokens or inject whole directives +/// (`"foo v1.0.0 => evil.example/x v1\nreplace …"`). Fail-closed token guard. +fn go_token_safe(s: &str) -> bool { + !s.is_empty() && !s.chars().any(|c| c.is_whitespace() || c.is_control()) +} + +/// Strict `h1:` dirhash shape: exactly `h1:` + the 44-char standard-base64 of +/// a sha256. Anything else (wrong algorithm, embedded whitespace, truncation) +/// must not reach go.sum — a malformed line poisons the whole file. +fn go_h1_shape(s: &str) -> bool { + s.strip_prefix("h1:").is_some_and(|b| { + b.len() == 44 + && b.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=') + }) +} + // The committable shape (validated empirically — `docs/design/golang-hosted.md`): // // go.mod: replace => patch.socket.dev/gopatch/ @@ -3953,6 +3972,22 @@ fn rewrite_golang( }); continue; } + // Every string interpolated into go.mod/go.sum must be a single clean + // token — whitespace or control characters would inject directives. + if [fname.as_str(), &dep.version, rhs_module, rhs_version] + .iter() + .any(|s| !go_token_safe(s)) + { + result.warnings.push(RewriteWarning { + code: "redirect_golang_unsafe_coords".into(), + detail: format!( + "{fname}@{}: module/version tokens contain whitespace or control \ + characters; refusing to write them into go.mod/go.sum", + dep.version + ), + }); + continue; + } // BOTH go.sum hashes must be pinnable up front — a replace without // them (or with a malformed hash) bricks every `-mod=readonly` build. let (Some(zip_h1), Some(gomod_h1)) = (&dep.integrity.dirhash_h1, &dep.integrity.go_mod_h1) @@ -3966,19 +4001,42 @@ fn rewrite_golang( }); continue; }; - if !zip_h1.starts_with("h1:") || !gomod_h1.starts_with("h1:") { + if !go_h1_shape(zip_h1) || !go_h1_shape(gomod_h1) { result.warnings.push(RewriteWarning { code: "redirect_golang_missing_integrity".into(), detail: format!( - "{fname}@{}: integrity hashes must be `h1:`-prefixed dirhashes", + "{fname}@{}: integrity hashes must be `h1:` + 44-char base64 dirhashes", dep.version ), }); continue; } + // Any pre-existing socket-owned directive for the module (this run is + // a refresh, or a takeover of a local/vendored redirect): capture its + // text — the ledger's `original` is the only pre-redirect record. + let prior = go_mod_edit::parse_replace_entries(&go_mod) + .into_iter() + .find(|e| e.module == fname && e.socket_owned()); + let prior_text = prior.as_ref().map(|e| { + let target = e.path.clone().unwrap_or_else(|| match &e.rhs_version { + Some(v) => format!("{} {v}", e.rhs_module.as_deref().unwrap_or_default()), + None => e.rhs_module.clone().unwrap_or_default(), + }); + let ver = e + .version + .as_deref() + .map(|v| format!(" {v}")) + .unwrap_or_default(); + format!("replace {}{ver} => {target}", e.module) + }); + // Stale-pin cross-check: `replace` is keyed on module+version, and a // pin the graph no longer selects is SILENTLY inert (the build links - // the unpatched module with zero warning) — refuse to write one. + // the unpatched module with zero warning) — refuse to write one, and + // reconcile away OUR OWN inert directive if one is already committed: + // 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) { if required != &dep.version { result.warnings.push(RewriteWarning { @@ -3989,6 +4047,43 @@ fn rewrite_golang( dep.version ), }); + let stale_hosted = prior + .as_ref() + .filter(|e| e.owner == Some(go_mod_edit::ReplaceOwner::Hosted)); + if let Some(stale) = stale_hosted { + if let Ok(Some(new)) = go_mod_edit::remove_replace_entry( + &go_mod, + &fname, + go_mod_edit::ReplaceOwner::Hosted, + ) { + go_mod = new; + mod_changed = true; + result.edits.push(FileEdit { + path: "go.mod".into(), + kind: "redirect_golang_stale_replace_removed".into(), + action: "removed".into(), + key: Some(fname.clone()), + original: prior_text.clone().map(Value::String), + new: None, + }); + } + if let Some(stale_rhs) = stale.rhs_module.as_deref() { + if let Some(new) = + go_sum_edit::remove_module_prefix_lines(&go_sum, stale_rhs) + { + go_sum = new; + sum_changed = true; + result.edits.push(FileEdit { + path: "go.sum".into(), + kind: "redirect_golang_stale_gosum_removed".into(), + action: "removed".into(), + key: Some(stale_rhs.to_string()), + original: None, + new: None, + }); + } + } + } continue; } } @@ -4015,9 +4110,16 @@ fn rewrite_golang( result.edits.push(FileEdit { path: "go.mod".into(), kind: "redirect_golang_replace".into(), - action: "added".into(), + // A takeover/refresh of an existing socket directive must + // keep its text in `original` — the ledger is the only + // pre-redirect record a future revert can restore from. + action: if prior_text.is_some() { + "updated".into() + } else { + "added".into() + }, key: Some(fname.clone()), - original: None, + original: prior_text.map(Value::String), new: Some(Value::String(format!( "replace {fname} {} => {rhs_module} {rhs_version}", dep.version @@ -8078,5 +8180,118 @@ snapshots: 1, "exactly one directive for the module: {go_mod}" ); + // The takeover is recorded faithfully: the replaced local directive's + // text rides in `original` (the ledger is the only pre-redirect + // record), and the action says updated, not added. + let edit = out + .edits + .iter() + .find(|e| e.kind == "redirect_golang_replace") + .unwrap(); + assert_eq!(edit.action, "updated"); + assert!( + edit.original + .as_ref() + .and_then(|v| v.as_str()) + .is_some_and(|s| s.contains(".socket/go-patches/")), + "taken-over directive captured: {:?}", + edit.original + ); + } + + /// Server-controlled tokens with embedded whitespace would inject whole + /// directives into the line-oriented go.mod/go.sum — refuse them all. + #[test] + fn golang_whitespace_in_tokens_fails_closed() { + for (mutate, what) in [ + ( + Box::new(|o: &mut DepOverride| o.name = "github.com/foo/bar v0 => x".into()) + as Box, + "name", + ), + ( + Box::new(|o: &mut DepOverride| o.version = "v1.4.2\nreplace evil".into()), + "version", + ), + ( + Box::new(|o: &mut DepOverride| { + o.registry_override + .as_mut() + .unwrap() + .identifiers + .go_module_version = Some("v1.0.0 h1:evil".into()) + }), + "rhs version", + ), + ] { + let files = golang_files(); + let mut ovr = golang_override(); + mutate(&mut ovr); + let out = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert!( + out.files.is_empty(), + "{what}: nothing may be written for a hostile token" + ); + assert!( + out.warnings + .iter() + .any(|w| w.code == "redirect_golang_unsafe_coords" + || w.code == "redirect_golang_version_mismatch"), + "{what}: expected a refusal warning, got {:?}", + out.warnings + ); + } + // Hash with embedded newline: strict h1 shape refuses it. + let files = golang_files(); + let mut ovr = golang_override(); + ovr.integrity.dirhash_h1 = Some("h1:AAAA\nBBBB".into()); + let out = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert!(out.files.is_empty()); + assert_eq!(out.warnings[0].code, "redirect_golang_missing_integrity"); + } + + /// A committed socket pin whose version the graph no longer selects is + /// silently inert — the rewriter must reconcile it away (and its go.sum + /// lines), otherwise the stale module path keeps confirming the dep as + /// redirected while go links the unpatched version. + #[test] + fn golang_version_mismatch_reconciles_stale_pin() { + let mut files = golang_files(); + files.insert( + "go.mod".to_string(), + format!( + "module example.com/app\n\ngo 1.21\n\nrequire github.com/foo/bar v1.5.0\n\n\ + replace github.com/foo/bar v1.4.2 => {} v1.4.2-socketpatch.1\n", + golang_socket_module() + ), + ); + files.insert( + "go.sum".to_string(), + format!( + "{} v1.4.2-socketpatch.1 h1:{}\n", + golang_socket_module(), + "A".repeat(43) + "=" + ), + ); + let ovr = golang_override(); + let out = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert_eq!(out.warnings[0].code, "redirect_golang_version_mismatch"); + let go_mod = &out.files["go.mod"]; + assert!( + !go_mod.contains("gopatch"), + "stale inert directive reconciled away: {go_mod}" + ); + assert!( + !out.files["go.sum"].contains("gopatch"), + "stale go.sum lines reconciled away" + ); + assert!(out + .edits + .iter() + .any(|e| e.kind == "redirect_golang_stale_replace_removed" + && e.original.as_ref().is_some_and(|v| v + .as_str() + .unwrap_or_default() + .contains("v1.4.2-socketpatch.1")))); } } 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 65580c5f..1c6e9456 100644 --- a/crates/socket-patch-core/src/vendor/go_mod_edit.rs +++ b/crates/socket-patch-core/src/vendor/go_mod_edit.rs @@ -485,8 +485,10 @@ fn inspect_existing( } /// Remove `owner`'s `replace` directive(s) for `module`, pruning an emptied -/// `replace ( … )` block. The other owner's directives are left untouched. -fn remove_replace_entry( +/// `replace ( … )` block. The other owners' directives are left untouched. +/// Pure — exposed so the hosted rewriter can reconcile away its own stale +/// (require-version-mismatched, silently inert) directive. +pub fn remove_replace_entry( content: &str, module: &str, owner: ReplaceOwner, diff --git a/crates/socket-patch-core/src/vendor/golang.rs b/crates/socket-patch-core/src/vendor/golang.rs index bb090387..fbc8f66d 100644 --- a/crates/socket-patch-core/src/vendor/golang.rs +++ b/crates/socket-patch-core/src/vendor/golang.rs @@ -96,8 +96,11 @@ pub async fn vendor_go_module( }; // Detect an existing socket-owned directive BEFORE the engine rewrites it: - // a go-patches owner means vendor is taking over an `apply` redirect; any - // prior socket path becomes the wiring record's `original`. + // a go-patches owner means vendor is taking over an `apply` redirect, a + // hosted owner means it is taking over a `scan --mode hosted` fork-replace; + // the prior target (path, or ` ` for hosted) becomes the + // wiring record's `original` — revert keys its hosted-takeover warning on + // that text's namespace prefix, so it must be recorded faithfully. let prior = read_replace_entries(project_root) .await .into_iter() @@ -105,15 +108,25 @@ pub async fn vendor_go_module( let takeover = prior .as_ref() .is_some_and(|e| e.owner == Some(ReplaceOwner::GoPatches)); - let prior_path = prior.as_ref().and_then(|e| e.path.clone()); + let hosted_takeover = prior + .as_ref() + .is_some_and(|e| e.owner == Some(ReplaceOwner::Hosted)); + let prior_target = prior.as_ref().and_then(|e| { + e.path.clone().or_else(|| { + e.rhs_module.as_ref().map(|m| match &e.rhs_version { + Some(v) => format!("{m} {v}"), + None => m.clone(), + }) + }) + }); // Re-run shape detection: the replace already points at THIS uuid's copy. // The engine rebuilds a missing/stale copy and its replace upsert is a // byte-stable no-op, so a wired re-run must return `entry: None` — the // first run's ledger entry holds the only pre-vendor original, and the - // `prior_path` recorded here would be our own vendored pointer. + // `prior_target` recorded here would be our own vendored pointer. let wired = - prior_path.as_deref() == Some(replace_target_path(&base_rel, module, version).as_str()); + prior_target.as_deref() == Some(replace_target_path(&base_rel, module, version).as_str()); let copy_dir = copy_dir_for(project_root, &base_rel, module, version); let copy_was_ok = wired && copy_matches_after_hashes(©_dir, &record.files).await; @@ -271,6 +284,22 @@ pub async fn vendor_go_module( )); } + if hosted_takeover { + // The hosted rewrite pruned the original module's go.sum lines and + // added the socket module's. The vendored directory replace bypasses + // go.sum entirely, so the build stays green — but the go.sum state + // only matters again once the replace is dropped, so say NOW what + // `vendor --revert` will require. + warnings.push(VendorWarning::new( + "vendor_takeover", + format!( + "took over the hosted-mode replace for `{module}` (written by `scan --mode \ + hosted`); after `vendor --revert`, re-run `scan --mode hosted` or run \ + `go mod tidy` to regenerate the original module's go.sum lines" + ), + )); + } + // ── marker + ledger entry ───────────────────────────────────────────── let base_purl = strip_purl_qualifiers(purl).to_string(); let marker = VendorMarker::new("golang", &base_purl, record, vendored_at); @@ -296,15 +325,16 @@ pub async fn vendor_go_module( wiring: vec![WiringRecord { file: "go.mod".to_string(), kind: "go_replace".to_string(), - // Rewritten whenever ANY socket-owned directive pre-existed (the - // go-patches takeover, or a re-vendor refreshing an older uuid). - action: if prior_path.is_some() { + // Rewritten whenever ANY socket-owned directive pre-existed (a + // go-patches or hosted takeover, or a re-vendor refreshing an + // older uuid). + action: if prior_target.is_some() { WiringAction::Rewritten } else { WiringAction::Added }, key: Some(module.to_string()), - original: prior_path.map(serde_json::Value::from), + original: prior_target.map(serde_json::Value::from), new: Some(serde_json::Value::from(replace_target_path( &base_rel, module, version, ))), @@ -565,6 +595,27 @@ pub async fn revert_go_vendor( )); } + // A hosted-mode prior is recognizable from the wiring `original` (the + // hosted module-target text under the socket namespace) — the ledger + // schema carries no hosted flag. Dropping the vendor replace re-exposes + // the go.sum state the hosted rewrite left behind (original module's + // lines pruned), which breaks `-mod=readonly` builds until regenerated. + if entry.wiring.iter().any(|w| { + w.original + .as_ref() + .and_then(|v| v.as_str()) + .is_some_and(|s| s.starts_with(go_mod_edit::HOSTED_GO_MODULE_PREFIX)) + }) { + out.warnings.push(VendorWarning::new( + "takeover_not_restored", + format!( + "the hosted-mode replace for `{module}` that vendoring took over was \ + not restored; re-run `scan --mode hosted`, or run `go mod tidy` to \ + regenerate the original module's go.sum lines" + ), + )); + } + out } From 59910cfc752ccda8fda378b4ced1b5ed5345994b Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 13 Aug 2026 11:22:29 -0700 Subject: [PATCH 3/5] docs(golang-hosted): record the reviewed cross-mode takeover policy Co-Authored-By: Claude Fable 5 --- docs/design/golang-hosted.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/design/golang-hosted.md b/docs/design/golang-hosted.md index 88b5d701..ce31fb8e 100644 --- a/docs/design/golang-hosted.md +++ b/docs/design/golang-hosted.md @@ -141,6 +141,16 @@ all — the patched graph builds even if the upstream registry is down.) - Wire schema — `Integrity.goModH1` (new) alongside `dirhashH1`; `RegistryOverrideIdentifiers.goModuleVersion` (new) alongside the previously-reserved `goModulePath`. +- Cross-mode policy (adversarially reviewed): takeover into hosted (from a + local `.socket/go-patches/` or vendored replace) and vendor-takes-over-hosted + are supported, in-place, and ledger-recorded with the replaced directive in + `original`; **local apply refuses** a Hosted-owned replace (taking it over + would strand the pruned go.sum lines) and `apply --check` exempts + hosted-owned modules from MissingReplace drift. A committed pin whose + version `require` no longer selects is reconciled away (directive + go.sum + lines removed) rather than left to confirm a redirect it no longer performs. + `vendor --revert` after a hosted takeover warns to re-run + `scan --mode hosted` or `go mod tidy`. - Tests — unit suite in `redirect/mod.rs`; golden fixture `tests/fixtures/redirect/golang/gomod/basic/` (the cross-language contract the depscan TS twin must match byte-identically); capstone From af40539e9bc92cfcb39ec2f2bf840010af5a81b6 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 13 Aug 2026 12:21:58 -0700 Subject: [PATCH 4/5] feat(hosted-golang): merge the identifiers-borne gopatch hash pair into integrity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server carries the hosted-Go hash pair (goZipDirhashH1 + goModH1) on the goproxy override's IDENTIFIERS, not the tarball artifact's integrity — the tarball dirhashH1 must stay the original-path flavor that released CLIs verify vendor-mode downloads against (the h1 dirhash covers entry names, so the two flavors hash differently despite identical contents). scan --mode hosted now merges the pair into the normalized DepOverride.integrity (both-or-neither: a half pair still trips the rewriter's fail-closed integrity check), mirroring the yarnBerry10c0 merge. Schema: identifiers gain goZipDirhashH1/goModH1 (additive). Design doc's server-requirements section updated to the final contract. Co-Authored-By: Claude Fable 5 --- .../src/commands/scan/hosted.rs | 20 +++++++++++++++++++ .../src/patch/redirect/mod.rs | 11 ++++++++++ docs/design/golang-hosted.md | 14 +++++++++---- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 37df1fa2..9e6383aa 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -228,6 +228,26 @@ pub(super) async fn run_redirect( if let Some(c) = berry_zip.and_then(|a| a.integrity.yarn_berry10c0.clone()) { integrity.yarn_berry10c0 = Some(c); } + // goproxy: the hosted-Go hash pair rides the override's + // identifiers (the tarball's dirhashH1 is the original-path + // flavor, kept for vendor-mode verification); the golang + // rewriter reads the normalized integrity, so merge — the + // gopatch-flavor zip h1 REPLACES dirhashH1 here. Only both + // together: a half-merged pair would trip the rewriter's + // fail-closed integrity check by design. + if let Some(ov) = reference + .registry_override + .as_ref() + .filter(|o| o.kind == "goproxy") + { + if let (Some(zip_h1), Some(gomod_h1)) = ( + ov.identifiers.go_zip_dirhash_h1.clone(), + ov.identifiers.go_mod_h1.clone(), + ) { + integrity.dirhash_h1 = Some(zip_h1); + integrity.go_mod_h1 = Some(gomod_h1); + } + } candidates.push(( purl.to_string(), sel.uuid.clone(), diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index b38150a7..d18827d8 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -67,6 +67,17 @@ pub struct RegistryOverrideIdentifiers { /// original's major version — a v2+ RHS would force a `/v2` module-path /// suffix, and the RHS version need not relate to the original's. pub go_module_version: Option, + /// `h1:` dirhash of the gopatch-flavor module zip. Rides the override's + /// identifiers — NOT the tarball artifact's integrity, whose `dirhashH1` + /// stays the original-path flavor for vendor-mode verification (the h1 + /// hashes entry NAMES, so the two flavors hash differently). The + /// DepOverride builder merges it into `integrity.dirhash_h1` for the + /// rewriter. + pub go_zip_dirhash_h1: Option, + /// `h1:` dirhash of the served `.mod` bytes (x/mod `HashGoMod`) — the + /// consumer's `/go.mod h1:` go.sum line. Merged into + /// `integrity.go_mod_h1` alongside [`Self::go_zip_dirhash_h1`]. + pub go_mod_h1: Option, pub nuget_id_lower: Option, pub nuget_version_norm: Option, pub maven_group_id: Option, diff --git a/docs/design/golang-hosted.md b/docs/design/golang-hosted.md index ce31fb8e..ad8a55fa 100644 --- a/docs/design/golang-hosted.md +++ b/docs/design/golang-hosted.md @@ -190,10 +190,16 @@ What the free-tier Go redirect needs: `proxy.golang.org` itself can resolve it (validated end-to-end with the `mod` VCS type; `sum.golang.org`'s own lookup does the same discovery). 4. **Reference API**: populate `registryOverride.kind = "goproxy"`, - `indexUrl` = the proxy base, `identifiers.goModulePath` / - `goModuleVersion` (`-socketpatch.`, always v0/v1-range), and - both integrity hashes (`dirhashH1` = the gopatch-flavor zip h1, `goModH1`). - FREE patches only — never emit a `goproxy` override for a paid-tier grant. + `indexUrl` = the proxy base (the server origin), and on `identifiers`: + `goModulePath` / `goModuleVersion` (`-socketpatch.`, always + v0/v1-range) plus the hash pair `goZipDirhashH1` (the gopatch-flavor + zip h1) and `goModH1`. The pair rides **identifiers**, not the tarball + artifact's integrity — the tarball `dirhashH1` must stay the + original-path flavor, which released CLIs verify vendor-mode downloads + against. DepOverride builders (this CLI's `scan/hosted.rs` and the + backend's hosted PR flow) merge the pair into the normalized + `integrity.dirhashH1`/`goModH1` that the rewriters read. FREE patches + only — never emit a `goproxy` override for a paid-tier grant. 5. **Write-once invariant (the kill-shot risk)**: once a `gopatch/@` is fetchable, `proxy.golang.org` caches its bytes immutably and consumers commit its hashes. Any rebuild that changes From 9b1a69ffb0d38eb0c58a1b45dc07578e500ee46a Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 13 Aug 2026 17:33:12 -0700 Subject: [PATCH 5/5] fix(go_mod_edit): make replace removal the exact inverse of the append MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tidy-stable append writes ONE blank stanza separator before its single-line directive; removal left that blank behind, so a vendor/apply -> revert round-trip no longer restored go.mod byte-identical (caught by e2e_vendor_golang_build in CI — a suite the local sweep after the append change missed). Removal now drops the preceding blank when it is not separating the directive from real user content below (next line EOF or blank); a blank guarding user content survives. Byte-identical round-trip pinned for both the path-target and hosted module-target flavors, plus the user-content-below case. Co-Authored-By: Claude Fable 5 --- .../socket-patch-cli/src/update_notifier.rs | 10 ++- .../tests/e2e_vendored_production.rs | 61 ++++++++++++++---- .../tests/ecosystem_dispatch_e2e.rs | 5 +- .../tests/update_notifier_e2e.rs | 35 +++++++--- .../src/vendor/go_mod_edit.rs | 64 +++++++++++++++++++ 5 files changed, 145 insertions(+), 30 deletions(-) diff --git a/crates/socket-patch-cli/src/update_notifier.rs b/crates/socket-patch-cli/src/update_notifier.rs index 1ecc22b0..3f67a665 100644 --- a/crates/socket-patch-cli/src/update_notifier.rs +++ b/crates/socket-patch-cli/src/update_notifier.rs @@ -445,9 +445,15 @@ mod tests { // ceiling — the override never silently changes shipped behavior. assert_eq!(grace_budget_from(None), Duration::from_millis(500)); assert_eq!(grace_budget_from(Some("")), Duration::from_millis(500)); - assert_eq!(grace_budget_from(Some("not-a-number")), Duration::from_millis(500)); + assert_eq!( + grace_budget_from(Some("not-a-number")), + Duration::from_millis(500) + ); // A valid value lifts the ceiling (the e2e suite's escape hatch). - assert_eq!(grace_budget_from(Some("30000")), Duration::from_millis(30_000)); + assert_eq!( + grace_budget_from(Some("30000")), + Duration::from_millis(30_000) + ); assert_eq!(grace_budget_from(Some("0")), Duration::from_millis(0)); } diff --git a/crates/socket-patch-cli/tests/e2e_vendored_production.rs b/crates/socket-patch-cli/tests/e2e_vendored_production.rs index 41d9b043..0cbd44a6 100644 --- a/crates/socket-patch-cli/tests/e2e_vendored_production.rs +++ b/crates/socket-patch-cli/tests/e2e_vendored_production.rs @@ -316,7 +316,9 @@ fn scan_vendored(cwd: &Path, extra: &[&str]) -> serde_json::Value { "scan --mode vendored failed (exit {code}).\nstdout:\n{stdout}\nstderr:\n{stderr}" ); let env: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { - panic!("scan --mode vendored did not emit JSON ({e}).\nstdout:\n{stdout}\nstderr:\n{stderr}") + panic!( + "scan --mode vendored did not emit JSON ({e}).\nstdout:\n{stdout}\nstderr:\n{stderr}" + ) }); assert_eq!( env["status"].as_str(), @@ -385,8 +387,16 @@ fn assert_download_uuid(env: &serde_json::Value, uuids: &[&str], leg: &str) { /// `vendor --revert --json` in `cwd`, asserting success and returning the /// number of reverted entries. fn vendor_revert(cwd: &Path, leg: &str) -> u64 { - let (code, stdout, stderr) = - run_socket(cwd, &["vendor", "--revert", "--json", "--cwd", cwd.to_str().unwrap()]); + let (code, stdout, stderr) = run_socket( + cwd, + &[ + "vendor", + "--revert", + "--json", + "--cwd", + cwd.to_str().unwrap(), + ], + ); assert_eq!( code, 0, "{leg}: vendor --revert failed (exit {code}).\nstdout:\n{stdout}\nstderr:\n{stderr}" @@ -730,7 +740,11 @@ fn npm_package_lock_vendored_install_proof() { let fresh = tmp.path().join("fresh"); std::fs::create_dir_all(&fresh).unwrap(); std::fs::copy(proj.join("package.json"), fresh.join("package.json")).unwrap(); - std::fs::copy(proj.join("package-lock.json"), fresh.join("package-lock.json")).unwrap(); + std::fs::copy( + proj.join("package-lock.json"), + fresh.join("package-lock.json"), + ) + .unwrap(); copy_dir_recursive(&proj.join(".socket"), &fresh.join(".socket")); let fresh_cache = tmp.path().join("fresh-npm-cache").display().to_string(); @@ -1327,7 +1341,11 @@ fn pypi_requirements_txt_vendored_install_proof() { // from the project root (bare relative paths resolve against the CWD). let fresh = tmp.path().join("fresh"); std::fs::create_dir_all(&fresh).unwrap(); - std::fs::copy(proj.join("requirements.txt"), fresh.join("requirements.txt")).unwrap(); + std::fs::copy( + proj.join("requirements.txt"), + fresh.join("requirements.txt"), + ) + .unwrap(); copy_dir_recursive(&proj.join(".socket"), &fresh.join(".socket")); let fresh_venv = fresh.join(".venv"); assert!( @@ -1415,7 +1433,11 @@ fn pypi_uv_lock_vendored_install_proof() { } let venv = proj.join(".venv"); let Some(site) = site_packages(&venv) else { - soft_skip!(LEG, "could not locate site-packages under {}", venv.display()); + soft_skip!( + LEG, + "could not locate site-packages under {}", + venv.display() + ); }; assert!( !urllib3_patched(&site), @@ -1551,7 +1573,9 @@ fn cargo_vendored_install_proof() { // Vendored directory carries the patch; the registry source stays pristine. let vendored_lib = proj - .join(format!(".socket/vendor/cargo/{CARGO_UUID}/{CARGO_NAME}-{CARGO_VERSION}")) + .join(format!( + ".socket/vendor/cargo/{CARGO_UUID}/{CARGO_NAME}-{CARGO_VERSION}" + )) .join("src/lib.rs"); assert_patched(&vendored_lib, CARGO_MARKER, LEG); if let Some(ref lib) = registry_lib { @@ -1745,7 +1769,9 @@ fn gem_bundler_vendored_known_platform_defect() { .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false); - let applied = env_json["vendor"]["summary"]["applied"].as_u64().unwrap_or(0); + let applied = env_json["vendor"]["summary"]["applied"] + .as_u64() + .unwrap_or(0); if code == 0 && applied >= 1 { // The CLI now vendors platform gems. Say so loudly — this leg should be // promoted to a full delivery proof (bundle install frozen) and this @@ -1759,10 +1785,13 @@ fn gem_bundler_vendored_known_platform_defect() { } // Otherwise: it must be exactly the known `platform_gem_unsupported` failure. - let events = env_json["vendor"]["events"].as_array().cloned().unwrap_or_default(); - let is_known = events.iter().any(|e| { - e["action"] == "failed" && e["errorCode"] == "platform_gem_unsupported" - }); + let events = env_json["vendor"]["events"] + .as_array() + .cloned() + .unwrap_or_default(); + let is_known = events + .iter() + .any(|e| e["action"] == "failed" && e["errorCode"] == "platform_gem_unsupported"); assert!( !gem_strict, "{LEG}: SOCKET_PATCH_VENDORED_E2E_GEM_STRICT=1 and vendoring {GEM_PURL} did not succeed \ @@ -1809,7 +1838,9 @@ fn golang_vendored_finds_no_free_patches() { .expect("write go.mod"); let env_json = scan_vendored(&proj, &["--ecosystems", "golang"]); - let applied = env_json["vendor"]["summary"]["applied"].as_u64().unwrap_or(0); + let applied = env_json["vendor"]["summary"]["applied"] + .as_u64() + .unwrap_or(0); assert_eq!( applied, 0, "{LEG}: golang vendored something, but production publishes no free golang patches. \ @@ -1849,7 +1880,9 @@ fn deno_vendored_is_unsupported() { .expect("write deno.json"); let env_json = scan_vendored(&proj, &["--ecosystems", "deno"]); - let applied = env_json["vendor"]["summary"]["applied"].as_u64().unwrap_or(0); + let applied = env_json["vendor"]["summary"]["applied"] + .as_u64() + .unwrap_or(0); assert_eq!( applied, 0, "{LEG}: deno vendored something, but vendored mode is not supported for deno:\n{env_json:#}" diff --git a/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs b/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs index 55770038..e5fa681c 100644 --- a/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs +++ b/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs @@ -788,10 +788,7 @@ fn rollback_dispatch_branch_maven() { let fixture = RollbackFixture { purl: purl.to_string(), verify_file, - envs: vec![( - "MAVEN_REPO_LOCAL".to_string(), - repo.display().to_string(), - )], + envs: vec![("MAVEN_REPO_LOCAL".to_string(), repo.display().to_string())], global: false, }; assert_rollback_restored(root, "maven", &fixture); diff --git a/crates/socket-patch-cli/tests/update_notifier_e2e.rs b/crates/socket-patch-cli/tests/update_notifier_e2e.rs index 09cbfdd0..6589c99f 100644 --- a/crates/socket-patch-cli/tests/update_notifier_e2e.rs +++ b/crates/socket-patch-cli/tests/update_notifier_e2e.rs @@ -132,8 +132,11 @@ async fn first_eligible_run_checks_and_notices() { .mount() .await; - let (code, stdout, stderr) = - run_installed(&install, &["apply"], &eligible_kit_await_fetch(&release.base_url)); + let (code, stdout, stderr) = run_installed( + &install, + &["apply"], + &eligible_kit_await_fetch(&release.base_url), + ); assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); assert!( stderr.contains("Update available") && stderr.contains("9.9.9"), @@ -196,8 +199,11 @@ async fn stale_state_rechecks() { .await; write_state(&install.state_dir, STALE, Some(CURRENT), None); - let (code, stdout, stderr) = - run_installed(&install, &["apply"], &eligible_kit_await_fetch(&release.base_url)); + let (code, stdout, stderr) = run_installed( + &install, + &["apply"], + &eligible_kit_await_fetch(&release.base_url), + ); assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); let state = read_state(&install.state_dir); @@ -225,8 +231,11 @@ async fn up_to_date_prints_nothing() { .await; write_state(&install.state_dir, STALE, Some(CURRENT), None); - let (code, _, stderr) = - run_installed(&install, &["apply"], &eligible_kit_await_fetch(&release.base_url)); + let (code, _, stderr) = run_installed( + &install, + &["apply"], + &eligible_kit_await_fetch(&release.base_url), + ); assert_eq!(code, 0); assert!( !stderr.contains("Update available"), @@ -288,8 +297,11 @@ async fn corrupt_state_recovers() { ) .unwrap(); - let (code, stdout, stderr) = - run_installed(&install, &["apply"], &eligible_kit_await_fetch(&release.base_url)); + let (code, stdout, stderr) = run_installed( + &install, + &["apply"], + &eligible_kit_await_fetch(&release.base_url), + ); assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); assert!( !stderr.contains("panicked"), @@ -316,8 +328,11 @@ async fn future_timestamp_tolerated() { .await; write_state(&install.state_dir, -48 * HOUR, Some("9.9.9"), None); - let (code, stdout, stderr) = - run_installed(&install, &["apply"], &eligible_kit_await_fetch(&release.base_url)); + let (code, stdout, stderr) = run_installed( + &install, + &["apply"], + &eligible_kit_await_fetch(&release.base_url), + ); assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); assert_install_pristine(&install); } 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 1c6e9456..1cb3840b 100644 --- a/crates/socket-patch-core/src/vendor/go_mod_edit.rs +++ b/crates/socket-patch-core/src/vendor/go_mod_edit.rs @@ -543,6 +543,22 @@ pub fn remove_replace_entry( if e.module == module && e.owner == Some(owner) { keep[i] = false; changed = true; + // The upsert appends its single-line directive behind + // ONE blank separator (the gofmt stanza shape `go mod + // tidy` itself produces). Removal must be its exact + // inverse or a vendor/apply→revert round-trip strands + // a blank line and the restored go.mod is no longer + // byte-identical. Drop the preceding blank only when + // it isn't doing separation work for what FOLLOWS + // (next line is EOF or itself blank) — a blank + // between the directive and real user content below + // stays. + if i > 0 + && lines[i - 1].trim().is_empty() + && (i + 1 >= lines.len() || lines[i + 1].trim().is_empty()) + { + keep[i - 1] = false; + } } } } @@ -1038,6 +1054,54 @@ replace ( ); } + /// ensure→drop must restore go.mod BYTE-IDENTICAL: the upsert appends a + /// blank stanza separator before its directive (the tidy-stable shape), + /// so removal must take that blank back out — vendor/apply revert pins + /// the round-trip byte-for-byte (e2e_vendor_golang_build). A blank that + /// separates the directive from real user content below must survive. + #[test] + fn test_upsert_then_remove_round_trips_byte_identical() { + for original in [ + "module m\n\ngo 1.21\n\nrequire github.com/foo/bar v1.4.2\n", + // No trailing blank structure at all. + "module m\n", + ] { + let upserted = + upsert_replace_entry(original, "github.com/foo/bar", "v1.4.2", GO_PATCHES_DIR) + .unwrap() + .unwrap(); + let restored = + remove_replace_entry(&upserted, "github.com/foo/bar", ReplaceOwner::GoPatches) + .unwrap() + .unwrap(); + assert_eq!(restored, original, "byte-identical round-trip"); + // Hosted flavor too (module-target directive). + let upserted = upsert_hosted_replace_entry( + original, + "github.com/foo/bar", + "v1.4.2", + HOSTED_MOD, + "v1.4.2-socketpatch.1", + ) + .unwrap() + .unwrap(); + let restored = + remove_replace_entry(&upserted, "github.com/foo/bar", ReplaceOwner::Hosted) + .unwrap() + .unwrap(); + assert_eq!(restored, original, "hosted byte-identical round-trip"); + } + // User content BELOW the directive: the separating blank stays. + let with_tail = "module m\n\nreplace github.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2\n// user note\n"; + let out = remove_replace_entry(with_tail, "github.com/foo/bar", ReplaceOwner::GoPatches) + .unwrap() + .unwrap(); + assert_eq!( + out, "module m\n\n// user note\n", + "blank separating user content below survives" + ); + } + #[test] fn test_remove_absent_is_noop() { assert!(remove_replace_entry(