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..9e6383aa 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() { @@ -213,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(), @@ -225,6 +260,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 +523,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/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_golang_hosted_build.rs b/crates/socket-patch-cli/tests/e2e_golang_hosted_build.rs new file mode 100644 index 00000000..184e4687 --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_golang_hosted_build.rs @@ -0,0 +1,395 @@ +#![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"); + // 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); + } + 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() +} + +/// 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('/', "_")); + 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 _cleanup = ChmodGuard(tmp.path().to_path_buf()); + 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(); + + // ── 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-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-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/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 ddf3f9ec..d18827d8 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,29 @@ 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, + /// `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, @@ -170,7 +198,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 +3877,309 @@ 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) ──────────────────────────────── +/// 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/ +// 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; + } + // 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) + else { + result.warnings.push(RewriteWarning { + code: "redirect_golang_missing_integrity".into(), + detail: format!( + "{fname}@{} has no dirhashH1/goModH1 integrity pair", + dep.version + ), + }); + continue; + }; + 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:` + 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, 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 { + 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 + ), + }); + 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; + } + } + + 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(), + // 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: prior_text.map(Value::String), + 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 +7905,404 @@ 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}" + ); + // 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 4facc9a6..1cb3840b 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,15 +478,17 @@ 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(()) } /// 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, @@ -459,6 +543,22 @@ 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; + } } } } @@ -706,6 +806,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"; @@ -809,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( 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/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 } 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..ad8a55fa --- /dev/null +++ b/docs/design/golang-hosted.md @@ -0,0 +1,211 @@ +# 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`. +- 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 + `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 (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 + 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