From 6329c3ba8dfe0f1ce2e2ca2fd085c2cbbb9fe9de Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 10:24:46 +0000 Subject: [PATCH 1/4] Start fix for #1823 Assisted-by: Claude Code:claude-opus-5-5 From d183f6f262f42bec03e2ccba28bf051bcf3abacf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 10:42:25 +0000 Subject: [PATCH 2/4] Test Go patch of a module used only via replace Add an end-to-end regression for #1823: when go.mod replaces a fork with the patched module (`replace other.com/fork => upstream v1.0.0`), go records the upstream's go.sum lines but never applies a replace of the upstream itself. apply, apply --check and vex all reported the patch as in effect while go linked the unpatched code. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_golang_build.rs | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/crates/socket-patch-cli/tests/e2e_golang_build.rs b/crates/socket-patch-cli/tests/e2e_golang_build.rs index 6c913c704..d0574258c 100644 --- a/crates/socket-patch-cli/tests/e2e_golang_build.rs +++ b/crates/socket-patch-cli/tests/e2e_golang_build.rs @@ -1191,6 +1191,138 @@ fn go_work_member_requiring_a_newer_version_makes_the_replace_inert() { chmod_writable(tmp.path()); } +// ── a module built only as another module's replacement (#1823) ── + +const FORK: &str = "other.com/fork"; + +/// Point the consumer at `FORK`, replaced by the upstream module +/// (`replace_line`), and tidy it: go.sum then holds the upstream's lines +/// under the upstream's own path, but the build list holds only `FORK`. +fn fork_consumer(consumer: &Path, go_directive: &str, replace_line: &str, env: &[(&str, &str)]) { + std::fs::write( + consumer.join("go.mod"), + format!( + "module example.com/consumer\n\n{go_directive}\n\nrequire {FORK} {UVER}\n\n{replace_line}\n" + ), + ) + .unwrap(); + std::fs::write( + consumer.join("main.go"), + format!( + "package main\n\nimport (\n\t\"fmt\"\n\tu \"{FORK}\"\n)\n\nfunc main() {{ fmt.Println(\"OUT:\", u.Greeting()) }}\n" + ), + ) + .unwrap(); + let tidy = go(consumer, &["mod", "tidy"], env); + assert!( + tidy.status.success(), + "go mod tidy: {}", + String::from_utf8_lossy(&tidy.stderr) + ); +} + +/// #1823: `replace other.com/fork => example.com/upstream v1.0.0` makes go +/// download the upstream and record its go.sum lines under its own path, +/// but go doesn't chain replaces, so a replace of the upstream is inert. +/// apply must refuse instead of reporting the patch applied, and a replace +/// written before the fork replace appeared must fail `apply --check` and +/// be left out of VEX. Covers the versioned and versionless fork replace in +/// a pruned (go 1.21) and an unpruned (go 1.16) graph. +#[test] +fn module_built_only_as_a_replace_target_is_not_patched() { + if !golang_e2e_matrix::toolchain_ready("e2e_golang_build(replace-target)") { + return; + } + for (go_directive, replace_line) in [ + ("go 1.21", format!("replace {FORK} {UVER} => {UMOD} {UVER}")), + ("go 1.16", format!("replace {FORK} => {UMOD} {UVER}")), + ] { + let case = format!("{go_directive} / {replace_line}"); + let tmp = tempfile::tempdir().unwrap(); + let (consumer, modcache, proxy_url) = stage(tmp.path()); + let env = go_env(modcache.to_str().unwrap(), &proxy_url); + let readonly: Vec<(&str, &str)> = env + .iter() + .map(|&(k, v)| { + if k == "GOFLAGS" { + (k, "-mod=readonly") + } else { + (k, v) + } + }) + .collect(); + + // Applied while the consumer still required the upstream directly. + write_patch(&consumer); + let (code, so, se) = apply_json(&consumer, &modcache); + assert_eq!(code, 0, "{case}: direct apply:\n{so}\n{se}"); + assert!( + go_out(&consumer, &readonly).contains("OUT: PATCHED"), + "{case}" + ); + + // Now the upstream is only the fork's replacement: the Socket + // replace (kept from the earlier apply) is inert. + let socket_replace = replace_lines(&consumer.join("go.mod")); + assert_eq!(socket_replace.len(), 1, "{case}: {socket_replace:?}"); + fork_consumer( + &consumer, + go_directive, + &format!("{replace_line}\n\n{}", socket_replace[0]), + &env, + ); + let sum = std::fs::read_to_string(consumer.join("go.sum")).unwrap(); + assert!(sum.contains(&format!("{UMOD} {UVER} h1:")), "{case}: {sum}"); + assert!( + go_out(&consumer, &readonly).contains("OUT: PRISTINE"), + "{case}" + ); + let (code, so, se) = check(&consumer, &modcache); + assert_eq!( + code, 1, + "{case}: apply --check must flag the inert replace:\n{so}\n{se}" + ); + assert!( + so.contains(&format!("only as the replacement of {FORK}")) + || se.contains(&format!("only as the replacement of {FORK}")), + "{case}:\n{so}\n{se}" + ); + let r = VexRun { + offline: true, + product: Some("pkg:golang/example.com/consumer".into()), + ..VexRun::default() + } + .env("GOMODCACHE", &modcache) + .env("GOFLAGS", ""); + let out = run_vex(&vex_e2e_common::binary(), &consumer, &r); + assert_absent(out.doc.as_ref(), UPURL); + vex_e2e_common::assert_not_attested(&out.envelope, UPURL, "go_version_not_selected"); + + // A fresh apply over the fork shape refuses and writes nothing. + fork_consumer(&consumer, go_directive, &replace_line, &env); + let (code, so, se) = apply_json(&consumer, &modcache); + assert_ne!( + code, 0, + "{case}: apply must refuse an inert replace:\n{so}\n{se}" + ); + assert!( + so.contains(&format!("only as the replacement of {FORK}")), + "{case}:\n{so}\n{se}" + ); + assert_eq!( + replace_lines(&consumer.join("go.mod")), + vec![replace_line.clone()], + "{case}" + ); + assert!( + go_out(&consumer, &readonly).contains("OUT: PRISTINE"), + "{case}" + ); + + chmod_writable(tmp.path()); + } +} + // ── global mode: tools already built with `go install` (#422) ── const TOOL: &str = "example.com/tool"; From 96bc75d30e111fb1bf8d2bcb2405710ade38614e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 10:42:26 +0000 Subject: [PATCH 3/4] Refuse Go patches for replace-only modules A module the build uses only as the target of another module's replace (`replace other.com/fork => example.com/upstream v1.0.0`) has its go.sum zip line under its own path, so the build-list check took it as built. go doesn't chain replaces, so a Socket replace of it is silently ignored: apply reported success, apply --check passed and vex attested not_affected while the build stayed unpatched. The shared build-list check now ignores a zip line that a module-to-module replace in go.mod explains (go refuses a build that uses one module@version under two paths, so it can't be the module's own) and reports the module as a replace target. apply and vendor refuse it, apply --check flags it, vex leaves it out, the hosted rewriter refuses it (redirect_golang_not_in_module_graph) and VEX discovery treats an existing hosted pin of it as inert. Fixes #1823 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/src/commands/apply.rs | 3 +- crates/socket-patch-cli/src/commands/vex.rs | 6 + .../src/patch/redirect/golang_local.rs | 30 +++++ .../src/patch/redirect/mod.rs | 83 +++++++++--- .../src/vendor/go_build_list.rs | 123 ++++++++++++++++-- .../src/vex/discover/golang.rs | 57 +++++++- docs/ecosystems.md | 10 ++ 7 files changed, 276 insertions(+), 36 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 787acab34..da65c5f5a 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -361,7 +361,8 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { | GoDrift::MissingReplace { purl } | GoDrift::WrongReplacePath { purl, .. } | GoDrift::ResolvedVersionMismatch { purl, .. } - | GoDrift::UnprovenSelection { purl, .. } => purl.clone(), + | GoDrift::UnprovenSelection { purl, .. } + | GoDrift::ReplaceTargetOnly { purl, .. } => purl.clone(), GoDrift::OrphanReplace { module } => module.clone(), }; drifts.push((id, "go_redirect_drift".to_string(), d.to_string())); diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index 21f688fe7..50542af10 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -1037,6 +1037,12 @@ async fn generate_vex( ignored and the build links the unpatched module", entry.module ), + Some(Selection::ReplaceTarget(via)) => format!( + "the build uses {} {version} only as the replacement of {via}, and go \ + doesn't apply a replace to a replacement, so the replace pinned to \ + {version} is ignored and the build links the unpatched module", + entry.module + ), Some(Selection::Unknown) if socket_patch_core::vendor::go_workspace::resolve(&root) != socket_patch_core::vendor::go_workspace::GoWork::None => 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 28d39e7e0..572245625 100644 --- a/crates/socket-patch-core/src/patch/redirect/golang_local.rs +++ b/crates/socket-patch-core/src/patch/redirect/golang_local.rs @@ -88,6 +88,15 @@ pub enum Drift { purl: String, patched_version: String, }, + /// go.mod builds the patched version only as the target of another + /// module's `replace` (`replace => `). go + /// doesn't apply a replace to a replacement, so the Socket `replace` is + /// unused and the build links the UNPATCHED code (#1823). + ReplaceTargetOnly { + purl: String, + patched_version: String, + via: String, + }, } impl std::fmt::Display for Drift { @@ -138,6 +147,16 @@ impl std::fmt::Display for Drift { replacement directory nor the module cache (`go mod download` fetches it), \ and go may link the UNPATCHED module" ), + Drift::ReplaceTargetOnly { + purl, + patched_version, + via, + } => write!( + f, + "{purl}: the build uses {patched_version} only as the replacement of \ + {via}, and go doesn't apply a replace to a replacement — go would link \ + the UNPATCHED module" + ), } } } @@ -584,6 +603,11 @@ pub async fn verify_go_redirect_state( patched_version: version.to_string(), }) } + Some(Selection::ReplaceTarget(via)) => drifts.push(Drift::ReplaceTargetOnly { + purl: purl.clone(), + patched_version: version.to_string(), + via, + }), _ => {} } @@ -682,6 +706,12 @@ pub(crate) async fn selection_refusal( build requires it and no go.sum has a zip line for it): its replace would be \ ignored; nothing written" )), + Selection::ReplaceTarget(via) => Some(format!( + "the build uses {module} {version} only as the replacement of {via} \ + (go.mod's `replace {via} => {module} {version}`), and go doesn't apply a \ + replace to a replacement: its replace would be ignored and the build would \ + link the unpatched module; nothing written" + )), Selection::Selected | Selection::Unknown => None, } } diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 6e854c3a7..7bd69ccfd 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -9630,29 +9630,43 @@ fn rewrite_golang_target( let selection = selection.unwrap_or_else(|| { let zips = go_sum.zip_versions(fname); let zips: Vec<&str> = zips.iter().map(String::as_str).collect(); - go_build_list::select( - go_mod_edit::prunes_module_graph(go_mod), - scan.required.as_deref(), - &zips, - &dep.version, - ) + go_build_list::select(go_mod, fname, scan.required.as_deref(), &zips, &dep.version) }); - if let Some(selected) = selection.other_version() { - let basis = if in_workspace { - "the go.work workspace selects" - } else if scan.required.as_deref() == Some(selected) { - "go.mod requires" - } else { - "go.sum shows the build selects" - }; - result.warnings.push(RewriteWarning { - code: "redirect_golang_version_mismatch".into(), + // A pin the build provably doesn't use: a selected version other than + // the patched one, or a module go builds only as the target of another + // module's replace (go doesn't chain replaces, #1823). + let inert = match &selection { + go_build_list::Selection::Other(selected) => { + let basis = if in_workspace { + "the go.work workspace selects" + } else if scan.required.as_deref() == Some(selected.as_str()) { + "go.mod requires" + } else { + "go.sum shows the build selects" + }; + Some(RewriteWarning { + code: "redirect_golang_version_mismatch".into(), + detail: format!( + "{fname}: {basis} {selected} but the patch targets {} — \ + a version-pinned replace would be silently ignored", + dep.version + ), + }) + } + go_build_list::Selection::ReplaceTarget(via) => Some(RewriteWarning { + code: "redirect_golang_not_in_module_graph".into(), detail: format!( - "{fname}: {basis} {selected} but the patch targets {} — \ - a version-pinned replace would be silently ignored", - dep.version + "{fname}@{}: the build uses this version only as the replacement of \ + {via} (go.mod's `replace {via} => {fname} {}`), and go doesn't apply a \ + replace to a replacement — its replace would be ignored; nothing \ + redirected", + dep.version, dep.version ), - }); + }), + _ => None, + }; + if let Some(warning) = inert { + result.warnings.push(warning); let stale_hosted = prior.filter(|e| e.owner == Some(go_mod_edit::ReplaceOwner::Hosted)); if let Some(stale) = stale_hosted { if let Ok(Some(new)) = @@ -23408,6 +23422,35 @@ packages: assert!(out.warnings[0].detail.contains("v1.5.0")); } + /// #1823: a module go builds only as the target of another module's + /// replace has its go.sum lines under its own path, but go doesn't chain + /// replaces, so a replace of it would be inert. + #[test] + fn golang_replace_target_only_module_skips() { + for go in ["go 1.21", "go 1.16"] { + let mut files = golang_files(); + files.insert( + "go.mod".to_string(), + format!( + "module example.com/app\n\n{go}\n\nrequire other.com/fork v1.4.2\n\n\ + replace other.com/fork => github.com/foo/bar v1.4.2\n" + ), + ); + let ovr = golang_override(); + let out = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert!(out.files.is_empty(), "{go}: {:?}", out.files); + assert_eq!(out.warnings.len(), 1, "{go}: {:?}", out.warnings); + assert_eq!(out.warnings[0].code, "redirect_golang_not_in_module_graph"); + assert!( + out.warnings[0] + .detail + .contains("only as the replacement of other.com/fork"), + "{}", + out.warnings[0].detail + ); + } + } + /// 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. diff --git a/crates/socket-patch-core/src/vendor/go_build_list.rs b/crates/socket-patch-core/src/vendor/go_build_list.rs index 476aaa925..0851a21c2 100644 --- a/crates/socket-patch-core/src/vendor/go_build_list.rs +++ b/crates/socket-patch-core/src/vendor/go_build_list.rs @@ -26,13 +26,23 @@ //! With no evidence at or above the patched version, the answer is //! [`Selection::Unknown`]: a directory `replace` makes `go mod tidy` drop //! the original's go.sum lines, so absence proves nothing. +//! +//! Either way, a zip line is no evidence when go.mod names that +//! module@version as the target of a module-to-module `replace` +//! (`replace other.com/fork v1.0.0 => example.com/upstream v1.0.0`): go +//! records the target's go.sum lines under the target's own path, but the +//! build list holds only the replaced module, and go doesn't apply a +//! replace to a replacement. go also refuses a build that uses one +//! module@version under two module paths, so the zip can't be from the +//! target being built in its own right. Such a module is +//! [`Selection::ReplaceTarget`] (#1823). use std::cmp::Ordering; use std::path::Path; use crate::utils::fs::read_regular_to_string; -use super::go_mod_edit::{parse_required_versions, prunes_module_graph}; +use super::go_mod_edit::{parse_replace_entries, parse_required_versions, prunes_module_graph}; use super::go_sum_edit::{go_semver_cmp, go_sum_lines}; /// Whether the build list selects the patched version of a module. @@ -47,6 +57,11 @@ pub enum Selection { NotInGraph, /// go.mod and go.sum hold no evidence either way. Unknown, + /// The build compiles the patched version only as the replacement of + /// this other module (go.mod's `replace => `). + /// go doesn't chain replaces, so a replace of the module itself is + /// inert and the build links the unpatched code (#1823). + ReplaceTarget(String), } impl Selection { @@ -71,24 +86,38 @@ pub fn selection(go_mod: &str, go_sum: Option<&str>, module: &str, version: &str .collect() }) .unwrap_or_default(); - select( - prunes_module_graph(go_mod), - required.as_deref(), - &zips, - version, - ) + select(go_mod, module, required.as_deref(), &zips, version) } -/// [`selection`] from already-parsed parts: whether the graph is pruned, -/// the module's `require` version, and the versions go.sum holds a zip line -/// for. -pub fn select(pruned: bool, required: Option<&str>, zips: &[&str], version: &str) -> Selection { - if pruned { +/// [`selection`] from already-parsed parts: the root go.mod's text (whether +/// the graph is pruned, and its replaces), the module's `require` version, +/// and the versions go.sum holds a zip line for. +pub fn select( + go_mod: &str, + module: &str, + required: Option<&str>, + zips: &[&str], + version: &str, +) -> Selection { + // A zip line at the version a module-to-module replace targets is the + // replacement's, not the module's own (see the module docs). + let replaced = replaced_module(go_mod, module, version); + let zips: Vec<&str> = zips + .iter() + .copied() + .filter(|z| replaced.is_none() || *z != version) + .collect(); + let zips = zips.as_slice(); + let no_evidence = |fallback| match &replaced { + Some(via) => Selection::ReplaceTarget(via.clone()), + None => fallback, + }; + if prunes_module_graph(go_mod) { return match required { Some(r) if r == version => Selection::Selected, Some(r) => Selection::Other(r.to_string()), None if zips.contains(&version) => Selection::Selected, - None => Selection::NotInGraph, + None => no_evidence(Selection::NotInGraph), }; } let highest = required @@ -104,10 +133,23 @@ pub fn select(pruned: bool, required: Option<&str>, zips: &[&str], version: &str // that lower version is the one built (go records no zip line for // the patched version only once a directory replace covers it). Some(h) if zips.contains(&h) => Selection::Other(h.to_string()), - _ => Selection::Unknown, + _ => no_evidence(Selection::Unknown), } } +/// The module whose module-to-module `replace` in `go_mod` targets exactly +/// `module@version`, if any. +fn replaced_module(go_mod: &str, module: &str, version: &str) -> Option { + parse_replace_entries(go_mod) + .into_iter() + .find(|e| { + e.module != module + && e.rhs_module.as_deref() == Some(module) + && e.rhs_version.as_deref() == Some(version) + }) + .map(|e| e.module) +} + /// [`selection`] for a project directory. In workspace mode the build /// list spans every `use` member's module graph, so the answer is the /// workspace's ([`super::go_workspace::GoWorkspace::selection`] after @@ -234,6 +276,59 @@ mod tests { assert!(!prunes_module_graph("module c\ngodebug default=go1.21\n")); } + /// #1823: go records a module-to-module replace target's go.sum lines + /// under the target's path, but the build list holds only the replaced + /// module, so the zip line is no evidence the target itself is built. + #[test] + fn replace_target_zip_is_not_the_module_built() { + let s = sum(&[&format!("{UP} v1.0.0"), &format!("{UP} v1.0.0/go.mod")]); + let via = Selection::ReplaceTarget("other.com/fork".into()); + for go in ["go 1.21", "go 1.16", ""] { + for replace in [ + format!("replace other.com/fork v1.0.0 => {UP} v1.0.0"), + format!("replace other.com/fork => {UP} v1.0.0"), + format!("replace (\n\tother.com/fork => {UP} v1.0.0\n)"), + ] { + let m = format!("module c\n\n{go}\n\nrequire other.com/fork v1.0.0\n\n{replace}\n"); + assert_eq!( + selection(&m, Some(&s), UP, "v1.0.0"), + via, + "{go} / {replace}" + ); + } + } + } + + #[test] + fn replace_target_at_another_version_keeps_its_own_evidence() { + // The fork targets v1.0.0; a zip line at v1.1.0 is the module's own. + let m = format!( + "module c\n\ngo 1.21\n\nrequire other.com/fork v1.0.0\n\n\ + replace other.com/fork => {UP} v1.0.0\n" + ); + let s = sum(&[&format!("{UP} v1.0.0"), &format!("{UP} v1.1.0")]); + assert_eq!(selection(&m, Some(&s), UP, "v1.1.0"), Selection::Selected); + // A require of the module still decides in a pruned graph. + let m = format!( + "module c\n\ngo 1.21\n\nrequire {UP} v1.2.0\n\n\ + replace other.com/fork => {UP} v1.0.0\n" + ); + assert_eq!( + selection(&m, Some(&s), UP, "v1.0.0"), + Selection::Other("v1.2.0".into()) + ); + // A directory replace of the fork, or a replace of the module + // itself, is not a module-to-module target. + for replace in [ + "replace other.com/fork => ../fork".to_string(), + format!("replace {UP} v1.0.0 => ./.socket/go-patches/{UP}@v1.0.0"), + ] { + let m = format!("module c\n\ngo 1.21\n\n{replace}\n"); + let s = sum(&[&format!("{UP} v1.0.0")]); + assert_eq!(selection(&m, Some(&s), UP, "v1.0.0"), Selection::Selected); + } + } + #[test] fn incompatible_versions_compare_exactly() { let m = format!("module c\n\ngo 1.21\n\nrequire {UP} v2.0.0+incompatible\n"); diff --git a/crates/socket-patch-core/src/vex/discover/golang.rs b/crates/socket-patch-core/src/vex/discover/golang.rs index 2d763b8e9..7b4c44d51 100644 --- a/crates/socket-patch-core/src/vex/discover/golang.rs +++ b/crates/socket-patch-core/src/vex/discover/golang.rs @@ -298,7 +298,8 @@ impl<'a> Graph<'a> { None => { let root = self.root.unwrap_or_default(); go_build_list::select( - go_mod_edit::prunes_module_graph(root), + root, + module, self.required.get(module).map(String::as_str), zips, version, @@ -467,6 +468,23 @@ fn extract_file( // is the one built; in one, another member may raise the // module above the replaced version all the same. let proven = matches!(pin, SumPin::Zip(_)) && graph.workspace.is_none(); + // A module go builds only as another module's replacement + // is never redirected by its own replace, whatever the sum + // files hold: go doesn't chain replaces (#1823). + if let Selection::ReplaceTarget(via) = &selected { + out.diag( + DIAG_REF_INVALID, + file, + format!( + "{file}: replace {} {version} is inert: the build uses {} \ + {version} only as the replacement of {via}, and go doesn't \ + apply a replace to a replacement, so it uses the unpatched \ + module", + entry.module, entry.module + ), + ); + continue; + } if let Some(other) = selected.other_version().filter(|_| !proven) { out.diag( DIAG_REF_INVALID, @@ -1411,6 +1429,43 @@ mod tests { assert!(pinned(&gowork_off(&p).await)); } + /// #1823: a hosted pin of a module go builds only as another module's + /// replacement is inert, even with both sum lines of the socket module. + #[tokio::test] + async fn pin_of_a_module_built_only_as_a_replace_target_is_inert() { + let m = gopatch(UUID_A); + let sver = "v1.4.2-socketpatch.1"; + for go in ["go 1.21", "go 1.16"] { + let p = Project::new(); + p.write( + "go.mod", + format!( + "module example.com/app\n\n{go}\n\nrequire other.com/fork {VERSION}\n\n\ + replace other.com/fork => {MODULE} {VERSION}\n\n\ + replace {MODULE} {VERSION} => {m} {sver}\n" + ), + ) + .write( + "go.sum", + format!( + "{MODULE} {VERSION} {ZIP_H1}\n{MODULE} {VERSION}/go.mod {MOD_H1}\n\ + {m} {sver} {ZIP_H1}\n{m} {sver}/go.mod {MOD_H1}\n" + ), + ); + let out = run(&p).await; + assert_refs(&out, &[]); + assert_eq!(out.diagnostics.len(), 1, "{go}: {:?}", out.diagnostics); + assert_eq!(out.diagnostics[0].code, DIAG_REF_INVALID); + assert!( + out.diagnostics[0] + .detail + .contains("only as the replacement of other.com/fork"), + "{}", + out.diagnostics[0].detail + ); + } + } + /// In a go < 1.17 graph MVS keeps the `/go.mod` line of a version it only /// reads: a `/go.mod`-only pin whose version go.sum shows is not the one /// selected is inert. diff --git a/docs/ecosystems.md b/docs/ecosystems.md index feb2028eb..89bbebf64 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -1620,6 +1620,16 @@ After an upgrade such as `go get M@v1.0.1`, `apply --check` fails and `vex` leaves the patch out (`go_version_not_selected`) until you get a patch for the new version. +A module the build uses only as the target of another module's replacement +(`replace other.com/fork => example.com/upstream v1.0.0`) is not patched +either. go downloads the target and records its `go.sum` lines under the +target's own path, but the build list holds only `other.com/fork`, and go +doesn't apply a replace to a replacement. So a zip line at the target's version +doesn't count: `apply`, `vendor` and `scan --mode hosted` refuse the patch +(hosted: `redirect_golang_not_in_module_graph`), and a replace written before +the fork replace appeared fails `apply --check` and is left out of `vex` +(`go_version_not_selected`). + Paid hosted Go references are unsupported: embedding credentials in module paths would expose them to module proxies and change module identity. Use vendored mode for those patches; the CLI reports `redirect_golang_unsupported` when the required From 4b7a58ffb417e050c1d2d69c7b3695770af235c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 10:48:16 +0000 Subject: [PATCH 4/4] Only count a fork replace that is in effect go mod tidy keeps unused replace directives. A fork replace of a module go.mod doesn't require, one pinned to a version other than the required one, or a version-less one that a versioned replace of the same module shadows doesn't change what go builds, so it must not discount the patched module's own go.sum line. Also stop quoting a version-less directive in the refusal messages, which misquoted a versioned replace, and cover the agent-mode drift and refusal with a unit test. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/golang_local.rs | 47 +++++++++++++- .../src/patch/redirect/mod.rs | 7 +-- .../src/vendor/go_build_list.rs | 61 ++++++++++++++++--- 3 files changed, 103 insertions(+), 12 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/golang_local.rs b/crates/socket-patch-core/src/patch/redirect/golang_local.rs index 572245625..ea596828f 100644 --- a/crates/socket-patch-core/src/patch/redirect/golang_local.rs +++ b/crates/socket-patch-core/src/patch/redirect/golang_local.rs @@ -708,7 +708,7 @@ pub(crate) async fn selection_refusal( )), Selection::ReplaceTarget(via) => Some(format!( "the build uses {module} {version} only as the replacement of {via} \ - (go.mod's `replace {via} => {module} {version}`), and go doesn't apply a \ + (go.mod's replace of {via}), and go doesn't apply a \ replace to a replacement: its replace would be ignored and the build would \ link the unpatched module; nothing written" )), @@ -2100,6 +2100,51 @@ mod tests { ); } + /// #1823: a module the build uses only as another module's replacement + /// is refused by apply and flagged by `apply --check`. + #[tokio::test] + async fn test_replace_target_only_module_is_refused_and_flagged() { + let (dir, blobs, pristine, files, _after) = fixture().await; + let root = dir.path(); + assert!(apply_fixture(root, &blobs, &pristine, &files).await.success); + let fork = format!( + "module example.com/app\n\ngo 1.21\n\nrequire other.com/fork {VERSION}\n\n\ + replace other.com/fork => {MODULE} {VERSION}\n" + ); + let socket = + format!("\nreplace {MODULE} {VERSION} => ./.socket/go-patches/{MODULE}@{VERSION}\n"); + tokio::fs::write(root.join("go.mod"), format!("{fork}{socket}")) + .await + .unwrap(); + tokio::fs::write( + root.join("go.sum"), + format!("{MODULE} {VERSION} h1:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\n"), + ) + .await + .unwrap(); + let manifest = manifest_with(&files); + let desired: HashSet = [PURL.to_string()].into_iter().collect(); + let drifts = verify_go_redirect_state(root, &manifest, &desired) + .await + .unwrap_err(); + assert_eq!( + drifts, + vec![Drift::ReplaceTargetOnly { + purl: PURL.into(), + patched_version: VERSION.into(), + via: "other.com/fork".into(), + }] + ); + assert!(drifts[0].to_string().contains("UNPATCHED")); + + tokio::fs::write(root.join("go.mod"), &fork).await.unwrap(); + let refusal = selection_refusal(root, MODULE, VERSION).await.unwrap(); + assert!( + refusal.contains("only as the replacement of other.com/fork"), + "{refusal}" + ); + } + #[tokio::test] async fn test_verify_orphan_replace() { let (dir, blobs, pristine, files, _after) = fixture().await; diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 7bd69ccfd..11d6d54ec 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -9657,10 +9657,9 @@ fn rewrite_golang_target( code: "redirect_golang_not_in_module_graph".into(), detail: format!( "{fname}@{}: the build uses this version only as the replacement of \ - {via} (go.mod's `replace {via} => {fname} {}`), and go doesn't apply a \ - replace to a replacement — its replace would be ignored; nothing \ - redirected", - dep.version, dep.version + {via} (go.mod's replace of {via}), and go doesn't apply a replace to a \ + replacement — its replace would be ignored; nothing redirected", + dep.version ), }), _ => None, diff --git a/crates/socket-patch-core/src/vendor/go_build_list.rs b/crates/socket-patch-core/src/vendor/go_build_list.rs index 0851a21c2..bc4e310f6 100644 --- a/crates/socket-patch-core/src/vendor/go_build_list.rs +++ b/crates/socket-patch-core/src/vendor/go_build_list.rs @@ -138,16 +138,32 @@ pub fn select( } /// The module whose module-to-module `replace` in `go_mod` targets exactly -/// `module@version`, if any. +/// `module@version`, if that replace is the one in effect: go.mod requires +/// the replaced module, and no replace pinned to the required version +/// shadows a version-less one (go prefers the versioned replace). `go mod +/// tidy` keeps unused replaces, so one of a module the build doesn't use, or +/// pinned to a version it doesn't require, explains nothing. fn replaced_module(go_mod: &str, module: &str, version: &str) -> Option { - parse_replace_entries(go_mod) - .into_iter() + let required = parse_required_versions(go_mod); + let entries = parse_replace_entries(go_mod); + entries + .iter() + .filter(|e| e.module != module) + .filter(|e| { + e.rhs_module.as_deref() == Some(module) && e.rhs_version.as_deref() == Some(version) + }) .find(|e| { - e.module != module - && e.rhs_module.as_deref() == Some(module) - && e.rhs_version.as_deref() == Some(version) + let Some(req) = required.get(&e.module) else { + return false; + }; + match e.version.as_deref() { + Some(v) => v == req, + None => !entries + .iter() + .any(|o| o.module == e.module && o.version.as_deref() == Some(req.as_str())), + } }) - .map(|e| e.module) + .map(|e| e.module.clone()) } /// [`selection`] for a project directory. In workspace mode the build @@ -299,6 +315,37 @@ mod tests { } } + /// `go mod tidy` keeps unused replaces: one of a module go.mod doesn't + /// require, one pinned to another version than the required one, or a + /// version-less one a versioned replace shadows doesn't explain the zip. + #[test] + fn inactive_replace_target_keeps_the_zip_as_evidence() { + let s = sum(&[&format!("{UP} v1.0.0")]); + for go in ["go 1.21", "go 1.16"] { + for (require, replace) in [ + ("", format!("replace other.com/fork => {UP} v1.0.0")), + ( + "require other.com/fork v1.0.0\n", + format!("replace other.com/fork v0.5.0 => {UP} v1.0.0"), + ), + ( + "require other.com/fork v1.0.0\n", + format!( + "replace other.com/fork => {UP} v1.0.0\n\ + replace other.com/fork v1.0.0 => ../fork" + ), + ), + ] { + let m = format!("module c\n\n{go}\n\n{require}\n{replace}\n"); + assert_eq!( + selection(&m, Some(&s), UP, "v1.0.0"), + Selection::Selected, + "{go} / {replace}" + ); + } + } + } + #[test] fn replace_target_at_another_version_keeps_its_own_evidence() { // The fork targets v1.0.0; a zip line at v1.1.0 is the module's own.