diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index edbbc1f99..8ccbe7f54 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -1358,6 +1358,10 @@ async fn run_redirect_selected_untracked( // parent directory's and `$CARGO_HOME`'s): a directory source or local // registry in any of them refuses the cargo rewrite like a project one. let cargo_outer = || CargoCrawler::crates_io_source_for(&common.cwd); + // The other members of the go.work in effect: the workspace build + // selects the highest version any of them requires. + let go_workspace = + || socket_patch_core::vendor::go_build_list::other_workspace_members(&common.cwd); // The scan runs in the install's environment: a pnpm setting there // (`pnpm_config_trust_lockfile`) overrides the project's files. let pnpm_env = |var: &str| std::env::var(var).ok(); @@ -1375,6 +1379,7 @@ async fn run_redirect_selected_untracked( npm_outer: &npm_outer, yarn_classic_outer: &yarn_classic_outer, cargo_outer: &cargo_outer, + go_workspace: &go_workspace, pnpm_env: &pnpm_env, blocking: true, takeover_uuids: takeover_uuids.clone(), diff --git a/crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs b/crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs index 2c2aed542..95778f16b 100644 --- a/crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs +++ b/crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs @@ -132,10 +132,16 @@ fn get_hosted_with( "fake", ]; args.extend_from_slice(extra); + // The go.work in effect is the one above the project (#1753), whatever + // GOWORK the machine running the tests sets. let (code, stdout, stderr) = run_with_prebuilt( consumer, &args, - &[("GOMODCACHE", modcache.to_str().unwrap())], + &[ + ("GOMODCACHE", modcache.to_str().unwrap()), + ("GOWORK", "auto"), + ("GOENV", "off"), + ], ); assert_eq!( code, 0, @@ -267,6 +273,72 @@ async fn leftover_go_sum_lines_do_not_confirm_a_refused_rewrite() { assert!(!ledger.contains(UPURL), "{ledger}"); } +/// #1753: in a go.work workspace the build selects the highest version any +/// `use` member requires. Once another member requires v1.1.0, a hosted +/// pin for this member's v1.0.0 is inert: the scan must refuse to write it +/// and take back the one it wrote before, never report it as redirected. +#[tokio::test(flavor = "multi_thread")] +async fn hosted_pin_another_go_work_member_outranks_is_refused() { + let tmp = tempfile::tempdir().unwrap(); + let ws = tmp.path().join("ws"); + let consumer = ws.join("consumer"); + write_consumer(&consumer, &format!("require {UMOD} {UVER}\n"), UPSTREAM_SUM); + let other = ws.join("other"); + let other_mod = + |v: &str| format!("module example.com/other\n\ngo 1.21\n\nrequire {UMOD} {v}\n"); + std::fs::create_dir_all(&other).unwrap(); + std::fs::write(other.join("go.mod"), other_mod(UVER)).unwrap(); + std::fs::write( + ws.join("go.work"), + "go 1.21\n\nuse (\n\t./consumer\n\t./other\n)\n", + ) + .unwrap(); + let server = MockServer::start().await; + mount_hosted_grant(&server).await; + let modcache = tmp.path().join("modcache"); + let mismatch = |env: &serde_json::Value| { + env["warnings"] + .as_array() + .unwrap() + .iter() + .any(|w| w["code"] == "redirect_golang_version_mismatch") + }; + + // Control: the other member requires the same version, so the pin is + // the one the workspace builds. + let env = get_hosted(&consumer, &server, &modcache); + assert_eq!(hosted_pinned(&env), 1, "envelope: {env}"); + let go_mod = std::fs::read_to_string(consumer.join("go.mod")).unwrap(); + assert!( + go_mod.contains(&format!("replace {UMOD} {UVER} => {}", smod())), + "{go_mod}" + ); + + // The other member moves to v1.1.0: re-running takes the inert pin back. + std::fs::write(other.join("go.mod"), other_mod("v1.1.0")).unwrap(); + let env = get_hosted(&consumer, &server, &modcache); + assert_eq!(hosted_pinned(&env), 0, "envelope: {env}"); + assert!(mismatch(&env), "{env}"); + let go_mod = std::fs::read_to_string(consumer.join("go.mod")).unwrap(); + assert!( + !go_mod.contains(&smod()), + "the inert pin is removed: {go_mod}" + ); + let go_sum = std::fs::read_to_string(consumer.join("go.sum")).unwrap(); + assert!(!go_sum.contains(&smod()), "{go_sum}"); + + // A fresh project in that state never gets the pin. + write_consumer(&consumer, &format!("require {UMOD} {UVER}\n"), UPSTREAM_SUM); + let _ = std::fs::remove_dir_all(consumer.join(".socket")); + let env = get_hosted(&consumer, &server, &modcache); + assert_eq!(hosted_pinned(&env), 0, "envelope: {env}"); + assert!(mismatch(&env), "{env}"); + assert_eq!( + std::fs::read_to_string(consumer.join("go.mod")).unwrap(), + format!("module example.com/consumer\n\ngo 1.21\n\nrequire {UMOD} {UVER}\n") + ); +} + /// vendored → hosted takeover: the hosted run must revert the vendored /// state (ledger entry, committed copy, marker) before redirecting, so the /// project is fully hosted — never a hosted go.mod beside a vendor ledger diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index ead398946..05bd4c14a 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -42,6 +42,7 @@ use crate::patch::redirect::{ use crate::utils::pnpm_workspace::governing_workspace_file; use crate::utils::purl::purl_parts; use crate::utils::redact::url_host; +use crate::vendor::go_build_list::ModuleEvidence; use crate::vendor::lock_inventory::{bun_text_lock_drives, MemoryEntry, ProjectView}; use super::guidance::{ @@ -1546,6 +1547,11 @@ pub struct RewriteOptions<'a> { /// resolved only when a cargo dep is rewritten (the cargo rewriter's /// offline-source refusal). pub cargo_outer: &'a (dyn Fn() -> CratesIoSource + Send + Sync), + /// The other `use` members of the go.work in effect for the project, + /// resolved only when a golang dep is rewritten: the Go rewriter weighs + /// their requirements, since the workspace build selects the highest + /// version any member asks for (#1753). + pub go_workspace: &'a (dyn Fn() -> Vec + Send + Sync), /// A variable of the environment pnpm installs the project in, by /// name: a `pnpm_config_trust_lockfile` there beats the /// pnpm-workspace.yaml key the trust auto-config writes (#1514). @@ -2272,6 +2278,11 @@ async fn rewrite_once( } else { CratesIoSource::Registry }; + let go_members = if rewrite_overrides.iter().any(|o| o.ecosystem == "golang") { + (options.go_workspace)() + } else { + Vec::new() + }; let (files, mut rewrite) = if options.blocking { // Pure CPU over every lock text (the independent rewriter groups // run concurrently inside), so it runs on the blocking pool rather @@ -2288,6 +2299,7 @@ async fn rewrite_once( &gradle_unreadable, &yarn_outer, &cargo_outer, + &go_members, ); (files, rewrite) }) @@ -2307,6 +2319,7 @@ async fn rewrite_once( &gradle_unreadable, &yarn_outer, &cargo_outer, + &go_members, ); (files, rewrite) }; @@ -3800,6 +3813,7 @@ mod tests { npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, cargo_outer: &no_cargo_outer, + go_workspace: &Vec::new, pnpm_env: &no_env, blocking: false, takeover_uuids: Default::default(), @@ -3873,6 +3887,7 @@ mod tests { npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, cargo_outer: &no_cargo_outer, + go_workspace: &Vec::new, pnpm_env: &no_env, blocking: false, takeover_uuids: Default::default(), @@ -4188,6 +4203,7 @@ mod tests { npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, cargo_outer: &no_cargo_outer, + go_workspace: &Vec::new, pnpm_env: env, blocking: false, takeover_uuids: Default::default(), @@ -4624,6 +4640,7 @@ snapshots: npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, cargo_outer: &no_cargo_outer, + go_workspace: &Vec::new, pnpm_env: &no_env, blocking: false, takeover_uuids: Default::default(), @@ -4729,6 +4746,7 @@ snapshots: npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, cargo_outer: &no_cargo_outer, + go_workspace: &Vec::new, pnpm_env: &no_env, blocking: false, takeover_uuids: Default::default(), @@ -4905,6 +4923,7 @@ snapshots: npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, cargo_outer: &no_cargo_outer, + go_workspace: &Vec::new, pnpm_env: &no_env, blocking: false, takeover_uuids: Default::default(), @@ -5246,6 +5265,7 @@ snapshots: npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, cargo_outer: &no_cargo_outer, + go_workspace: &Vec::new, pnpm_env: &no_env, blocking: false, takeover_uuids: Default::default(), @@ -5462,6 +5482,7 @@ snapshots: npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, cargo_outer: &no_cargo_outer, + go_workspace: &Vec::new, pnpm_env: &no_env, blocking: false, takeover_uuids: Default::default(), @@ -5517,6 +5538,7 @@ snapshots: npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, cargo_outer: &no_cargo_outer, + go_workspace: &Vec::new, pnpm_env: &no_env, blocking: false, takeover_uuids: Default::default(), @@ -5715,6 +5737,7 @@ snapshots: npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, cargo_outer: &no_cargo_outer, + go_workspace: &Vec::new, pnpm_env: &no_env, blocking: false, takeover_uuids: Default::default(), @@ -6124,6 +6147,7 @@ snapshots: npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, cargo_outer: &no_cargo_outer, + go_workspace: &Vec::new, pnpm_env: &no_env, blocking: false, takeover_uuids: Default::default(), diff --git a/crates/socket-patch-core/src/hosted/memory/stages.rs b/crates/socket-patch-core/src/hosted/memory/stages.rs index 63811b36b..192b5097e 100644 --- a/crates/socket-patch-core/src/hosted/memory/stages.rs +++ b/crates/socket-patch-core/src/hosted/memory/stages.rs @@ -377,6 +377,7 @@ pub(crate) async fn rewrite( yarn_classic_outer: &yarn_classic_outer, // The in-memory engine has no project on disk to read. cargo_outer: &|| CratesIoSource::Registry, + go_workspace: &Vec::new, // Nor the environment the project's installs run in. pnpm_env: &|_| None, blocking: false, diff --git a/crates/socket-patch-core/src/patch/redirect/golang_equivalence_tests.rs b/crates/socket-patch-core/src/patch/redirect/golang_equivalence_tests.rs index e14ac1610..800a71cf0 100644 --- a/crates/socket-patch-core/src/patch/redirect/golang_equivalence_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/golang_equivalence_tests.rs @@ -16,7 +16,7 @@ fn run( overrides: &[DepOverride], ) -> RewriteResult { let mut got = RewriteResult::default(); - rewrite_golang(files, overrides, &mut got); + rewrite_golang(files, overrides, &[], &mut got); g.next(&(files, overrides), &got); got } diff --git a/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs b/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs index 7bbf2bed5..763cf6c3b 100644 --- a/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs @@ -40,7 +40,7 @@ fn serial_oracle( rewrite_nuget(files, overrides, &mut result); rewrite_gem(files, overrides, &mut result); rewrite_maven_pom(files, overrides, &mut result); - rewrite_golang(files, overrides, &mut result); + rewrite_golang(files, overrides, &[], &mut result); result } @@ -139,6 +139,7 @@ fn assert_same_with_metadata( &no_gradle_unreadable, &no_yarn_outer, &CratesIoSource::Registry, + &[], ); let merged = merge_group_outputs(&prefix, run_groups_concurrently(&prefix, &groups)).map( |mut merged| { diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 0b1e6e400..9da4f74d0 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -604,6 +604,7 @@ pub fn rewrite_registry_redirect_with_pipenv_version( &std::collections::BTreeSet::new(), &yarnrc::OuterYarnMirror::default(), &CratesIoSource::Registry, + &[], ) } @@ -684,6 +685,7 @@ pub fn rewrite_registry_redirect_withholding_vlt( gradle_unreadable: &std::collections::BTreeSet, yarn_outer: &yarnrc::OuterYarnMirror, cargo_outer: &CratesIoSource, + go_members: &[crate::vendor::go_build_list::ModuleEvidence], ) -> RewriteResult { let mut result = RewriteResult::default(); let overrides = withhold_pypi_platform_wheels(overrides, &mut result); @@ -711,6 +713,7 @@ pub fn rewrite_registry_redirect_withholding_vlt( gradle_unreadable, yarn_outer, cargo_outer, + go_members, ); let mut result = rewrite_groups_parallel(result, &groups); result.vlt_drives = vlt::vlt_drives(files, bun_lockb_present); @@ -739,6 +742,7 @@ fn rewriter_groups<'a>( gradle_unreadable: &'a std::collections::BTreeSet, yarn_outer: &'a yarnrc::OuterYarnMirror, cargo_outer: &'a CratesIoSource, + go_members: &'a [crate::vendor::go_build_list::ModuleEvidence], ) -> Vec> { vec![ Box::new(move |result| { @@ -765,7 +769,7 @@ fn rewriter_groups<'a>( Box::new(move |result| { gradle::rewrite_gradle_hosted(files, gradle_unreadable, overrides, result) }), - Box::new(move |result| rewrite_golang(files, overrides, result)), + Box::new(move |result| rewrite_golang(files, overrides, go_members, result)), Box::new(move |result| { sbt::rewrite_sbt(files, overrides, result); scala_guidance::warn(files, overrides, result); @@ -9121,6 +9125,7 @@ fn go_token_safe(s: &str) -> bool { fn rewrite_golang( files: &BTreeMap, overrides: &[DepOverride], + go_members: &[crate::vendor::go_build_list::ModuleEvidence], result: &mut RewriteResult, ) { use crate::vendor::go_build_list; @@ -9265,17 +9270,27 @@ fn rewrite_golang( // (`go_build_list`): `require` for a go >= 1.17 graph; for an older // one, the highest of `require` and go.sum's zip lines, since a // `/go.mod`-only line is written for every version MVS reads, not - // only the one it selects (#509). + // only the one it selects (#509). In a go.work workspace the other + // members' files are weighed too: MVS builds the highest version + // any of them asks for (#1753). let zips = go_sum.zip_versions(&fname); let zips: Vec<&str> = zips.iter().map(String::as_str).collect(); - let selection = go_build_list::select( + let own = go_build_list::select( go_mod_edit::prunes_module_graph(&go_mod), scan.required.as_deref(), &zips, &dep.version, ); + let selection = if go_members.is_empty() { + own.clone() + } else { + let members = go_members.iter().map(|m| m.selection(&fname, &dep.version)); + go_build_list::workspace_selection(own.clone(), members, &dep.version) + }; if let Some(selected) = selection.other_version() { - let basis = if scan.required.as_deref() == Some(selected) { + let basis = if own.other_version() != Some(selected) { + "another go.work workspace member makes the build select" + } else if scan.required.as_deref() == Some(selected) { "go.mod requires" } else { "go.sum shows the build selects" @@ -9329,14 +9344,16 @@ fn rewrite_golang( // outside this project's graph (local discovery crawls the whole // module cache). Its replace would be inert, and confirming it // would attest a patch no build links. + let detail = if go_members.is_empty() { + "neither go.mod's require nor a go.sum zip line shows the build selecting \ + this version — it is not in this project's build graph" + } else { + "no go.work workspace member's go.mod require or go.sum zip line shows the \ + build selecting this version" + }; result.warnings.push(RewriteWarning { code: "redirect_golang_not_in_module_graph".into(), - detail: format!( - "{fname}@{}: neither go.mod's require nor a go.sum zip line shows the \ - build selecting this version — it is not in this project's build graph; \ - nothing redirected", - dep.version - ), + detail: format!("{fname}@{}: {detail}; nothing redirected", dep.version), }); continue; } @@ -12789,6 +12806,7 @@ mod tests { &std::collections::BTreeSet::new(), &refusing[3], &CratesIoSource::Registry, + &[], ); assert!(!r.files.contains_key("yarn.lock")); assert!(r @@ -22999,6 +23017,78 @@ packages: assert!(out.warnings[0].detail.contains("v1.5.0")); } + /// A go.work member that is not the scanned module. + fn go_member(require: &str) -> crate::vendor::go_build_list::ModuleEvidence { + let go_mod = format!("module example.com/other\n\ngo 1.21\n\n{require}"); + crate::vendor::go_build_list::ModuleEvidence::from_files(&go_mod, None) + } + + /// #1753: in a go.work workspace the build selects the highest version + /// any member requires, so a pin for this module's v1.4.2 is inert when + /// another member requires v1.5.0. + #[test] + fn golang_workspace_member_requiring_a_higher_version_skips() { + let files = golang_files(); + let ovr = golang_override(); + let members = [go_member("require github.com/foo/bar v1.5.0\n")]; + let mut out = RewriteResult::default(); + rewrite_golang(&files, std::slice::from_ref(&ovr), &members, &mut out); + assert!(out.files.is_empty(), "nothing written: {:?}", out.files); + assert_eq!( + out.warnings + .iter() + .map(|w| w.code.as_str()) + .collect::>(), + ["redirect_golang_version_mismatch"] + ); + assert!( + out.warnings[0] + .detail + .contains("another go.work workspace member makes the build select v1.5.0"), + "{}", + out.warnings[0].detail + ); + } + + /// #1753: a member requiring a lower version, or not requiring the + /// module at all, leaves the build on this module's v1.4.2. A member + /// that proves the patched version puts a module this go.mod omits in + /// the build. + #[test] + fn golang_workspace_members_that_do_not_outrank_still_redirect() { + let ovr = golang_override(); + for members in [ + vec![go_member("require github.com/foo/bar v1.4.1\n")], + vec![go_member("require example.com/unrelated v1.0.0\n")], + vec![go_member("")], + ] { + let mut out = RewriteResult::default(); + rewrite_golang( + &golang_files(), + std::slice::from_ref(&ovr), + &members, + &mut out, + ); + assert!(out.warnings.is_empty(), "{members:?}: {:?}", out.warnings); + assert!(out.files["go.mod"].contains("replace github.com/foo/bar v1.4.2 =>")); + } + + 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(), + ); + files.insert("go.sum".to_string(), String::new()); + let mut out = RewriteResult::default(); + rewrite_golang(&files, std::slice::from_ref(&ovr), &[], &mut out); + assert_eq!(out.warnings[0].code, "redirect_golang_not_in_module_graph"); + let members = [go_member("require github.com/foo/bar v1.4.2\n")]; + let mut out = RewriteResult::default(); + rewrite_golang(&files, std::slice::from_ref(&ovr), &members, &mut out); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert!(out.files["go.mod"].contains("replace github.com/foo/bar v1.4.2 =>")); + } + /// 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 fce0b9f43..ba20567e9 100644 --- a/crates/socket-patch-core/src/vendor/go_build_list.rs +++ b/crates/socket-patch-core/src/vendor/go_build_list.rs @@ -25,6 +25,7 @@ //! the original's go.sum lines, so absence proves nothing. use std::cmp::Ordering; +use std::collections::HashMap; use std::path::Path; use crate::utils::fs::read_regular_to_string; @@ -109,28 +110,105 @@ pub fn select(pruned: bool, required: Option<&str>, zips: &[&str], version: &str /// `go.sum` (`None` when there is no readable go.mod). /// /// In workspace mode the build list also takes every `use` member's -/// requirements, and MVS selects the highest version any of them asks for -/// (#1751). So a version another member proves is weighed with this one's, -/// and a module absent from this go.mod may still be built: -/// [`Selection::NotInGraph`] becomes [`Selection::Unknown`] there unless a -/// member proves the version. +/// requirements, so their go.mod/go.sum are weighed too +/// ([`workspace_selection`]). pub async fn project_selection( project_root: &Path, module: &str, version: &str, ) -> Option { let own = dir_selection(project_root, module, version).await?; - let Some(members) = super::go_consumer_sync::workspace_member_dirs(project_root).await else { + let Some(dirs) = super::go_consumer_sync::workspace_member_dirs(project_root).await else { return Some(own); }; + let mut members = Vec::new(); + for dir in dirs { + members.extend(dir_selection(&dir, module, version).await); + } + Some(workspace_selection(own, members, version)) +} + +/// What one go.work member's `go.mod` and `go.sum` prove about the +/// versions it builds, parsed once for every module asked about. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ModuleEvidence { + pruned: bool, + required: HashMap, + zips: HashMap>, +} + +impl ModuleEvidence { + pub fn from_files(go_mod: &str, go_sum: Option<&str>) -> Self { + let mut zips: HashMap> = HashMap::new(); + for line in go_sum.into_iter().flat_map(go_sum_lines) { + if !line.go_mod { + let versions = zips.entry(line.module.to_string()).or_default(); + versions.push(line.version.to_string()); + } + } + Self { + pruned: prunes_module_graph(go_mod), + required: parse_required_versions(go_mod), + zips, + } + } + + /// [`selection`] for `module@version` from these files. + pub fn selection(&self, module: &str, version: &str) -> Selection { + let zips: Vec<&str> = self + .zips + .get(module) + .into_iter() + .flatten() + .map(String::as_str) + .collect(); + select( + self.pruned, + self.required.get(module).map(String::as_str), + &zips, + version, + ) + } +} + +/// The evidence of every other `use` member of the go.work in effect for +/// `project_root` (empty outside a workspace), read on the calling thread. +/// A member whose go.mod can't be read is left out, as +/// [`project_selection`] skips it. +pub fn other_workspace_members(project_root: &Path) -> Vec { + use super::go_consumer_sync::{same_dir_key, workspace_member_dirs_sync}; + use crate::utils::fs::read_regular_to_string_sync; + let Some(dirs) = workspace_member_dirs_sync(project_root) else { + return Vec::new(); + }; + let root = same_dir_key(project_root); + dirs.into_iter() + .filter(|dir| same_dir_key(dir) != root) + .filter_map(|dir| { + let go_mod = read_regular_to_string_sync(&dir.join("go.mod")).ok()?; + let go_sum = read_regular_to_string_sync(&dir.join("go.sum")).ok(); + Some(ModuleEvidence::from_files(&go_mod, go_sum.as_deref())) + }) + .collect() +} + +/// The selection for the whole workspace, from this module's own +/// [`Selection`] (`own`) and each other member's. +/// +/// MVS selects the highest version any member asks for (#1751). So a +/// version another member proves is weighed with this one's, and a module +/// absent from this go.mod may still be built: [`Selection::NotInGraph`] +/// becomes [`Selection::Unknown`] unless a member proves the version. +pub fn workspace_selection( + own: Selection, + members: impl IntoIterator, + version: &str, +) -> Selection { let mut highest = evidence(&own, version); // A member whose files can't tell may build a higher version than the // highest one proven, so then only a proof at or above ours decides. let mut unknown = own == Selection::Unknown; - for dir in members { - let Some(member) = dir_selection(&dir, module, version).await else { - continue; - }; + for member in members { unknown |= member == Selection::Unknown; if let Some(v) = evidence(&member, version) { if highest @@ -141,14 +219,14 @@ pub async fn project_selection( } } } - Some(match highest { + match highest { Some(h) if h == version => Selection::Selected, Some(h) if !unknown || go_semver_cmp(&h, version) == Ordering::Greater => { Selection::Other(h) } _ if own == Selection::NotInGraph => Selection::Unknown, _ => own, - }) + } } /// [`selection`] from `/go.mod` and `go.sum`, ignoring any workspace. @@ -314,6 +392,34 @@ mod tests { ); } + /// #1753: the hosted rewriter gets every member but the scanned one; a + /// member without a go.mod is left out, and outside a workspace there + /// are none. + #[test] + fn other_workspace_members_reads_every_member_but_this_one() { + let dir = tempfile::tempdir().unwrap(); + let ws = dir.path(); + let (a, b, c) = (ws.join("a"), ws.join("b"), ws.join("c")); + module(&a, "a", "v1.0.0"); + module(&b, "b", "v1.1.0"); + std::fs::create_dir_all(&c).unwrap(); + assert!(other_workspace_members(&a).is_empty()); + std::fs::write( + ws.join("go.work"), + "go 1.21\n\nuse (\n\t./a\n\t./b/\n\t./c\n)\n", + ) + .unwrap(); + let members = other_workspace_members(&a); + assert_eq!(members.len(), 1, "{members:?}"); + assert_eq!( + members[0].selection(UP, "v1.0.0"), + Selection::Other("v1.1.0".into()) + ); + let members = other_workspace_members(&b); + assert_eq!(members.len(), 1, "{members:?}"); + assert_eq!(members[0].selection(UP, "v1.0.0"), Selection::Selected); + } + /// A version below the patched one counts when it is the highest any /// member proves, unless a member's files can't tell. #[tokio::test] diff --git a/crates/socket-patch-core/src/vendor/go_consumer_sync.rs b/crates/socket-patch-core/src/vendor/go_consumer_sync.rs index b2b7e3c37..9fe44e6aa 100644 --- a/crates/socket-patch-core/src/vendor/go_consumer_sync.rs +++ b/crates/socket-patch-core/src/vendor/go_consumer_sync.rs @@ -309,6 +309,18 @@ pub(crate) async fn workspace_member_dirs(project_root: &Path) -> Option Option> { + let work_file = workspace_file(project_root)?; + Some( + match crate::utils::fs::read_regular_to_string_sync(&work_file) { + Ok(text) => use_dirs(&work_file, &go_mod_edit::normalize_for_read(&text)), + Err(_) => Vec::new(), + }, + ) +} + /// `work_file`'s text, normalized for parsing. async fn read_work_file(work_file: &Path) -> Option { let text = read_regular_to_string(work_file).await.ok()?; @@ -491,7 +503,7 @@ async fn workspace_issues(project_root: &Path, replaces: &[ReplaceEntry]) -> Vec /// exists once folded (`./x/../app` with no `x`, which go's `filepath.Join` /// folds) is folded lexically and then resolved, so it still matches a /// project under a symlinked dir (macOS `/var` -> `/private/var`). -fn same_dir_key(dir: &Path) -> PathBuf { +pub(crate) fn same_dir_key(dir: &Path) -> PathBuf { let abs = std::env::current_dir().unwrap_or_default().join(dir); std::fs::canonicalize(&abs).unwrap_or_else(|_| { let folded = crate::utils::relpath::normalize_lexically_keeping_escapes(&abs); diff --git a/docs/ecosystems.md b/docs/ecosystems.md index b840a0058..fce21858a 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -1514,12 +1514,13 @@ and `go.sum`: the `require` line in a go 1.17+ module, or, in an older module the highest of the required version and the versions `go.sum` has a module zip line for. `apply` and `scan --mode hosted` refuse to write a replace for any other version. In a workspace the build takes the highest version any -`use` member selects, so `apply`, `vendor`, `apply --check` and `vex` read -every member's `go.mod` and `go.sum` too: a member that requires a newer -version makes a replace pinned to the older one inert. `scan --mode hosted` -still reads only the module it runs in (#1753). 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. +`use` member selects, so `apply`, `vendor`, `apply --check`, `vex` and +`scan --mode hosted` read every member's `go.mod` and `go.sum` too: a member +that requires a newer version makes a replace pinned to the older one inert. +A hosted rescan removes a pin it wrote once another member outranks it. +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. Paid hosted Go references are unsupported: embedding credentials in module paths would expose them to module proxies and change module identity. Use vendored mode