From 57eda2d415c6f67e8b22d19c3455d76c603a176d Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 13:54:32 -0400 Subject: [PATCH] Honor Go settings written with go env -w socket-patch read GOPRIVATE, GOPROXY, GOMODCACHE and the other Go settings only from the process environment. go itself also reads the per-user file `go env -w` writes ($GOENV, default os.UserConfigDir()/go/env), which is where the Go docs tell users to put them. So a private module path set that way was requested from proxy.golang.org, a configured mirror was bypassed, and a relocated module cache was not found (apply: "matched no installed package"). A new utils::go_env resolver follows go's order: a non-empty environment variable, then the GOENV file (GOENV=off disables it), then the caller's default. goproxy_base, gosumdb_base, the crawler's GOMODCACHE/GOPATH lookup and the GOWORK check now use it. Fixes #344 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- crates/socket-patch-cli/tests/e2e_golang.rs | 77 ++++++++ .../src/crawlers/go_crawler.rs | 50 ++++- .../src/patch/redirect/upstream/client.rs | 6 +- crates/socket-patch-core/src/utils/go_env.rs | 183 ++++++++++++++++++ crates/socket-patch-core/src/utils/mod.rs | 1 + .../src/vendor/registry_fetch.rs | 60 +++++- 7 files changed, 365 insertions(+), 14 deletions(-) create mode 100644 crates/socket-patch-core/src/utils/go_env.rs diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index bb4592f4b..0e470e3c8 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1143,7 +1143,7 @@ Env-only knobs used for hosted upstream restoration and JVM metadata verificatio | Env var | Default | Notes | |---|---|---| | `SOCKET_NPM_REGISTRY` | `https://registry.npmjs.org` | Base for npm version documents (`//`, a scoped name's `/` as `%2f`; `dist.tarball` / `integrity` / `shasum`) the npm-family and vlt upstream restore reads, unless the project names another registry (yarn berry `npmRegistryServer`, pnpm's lock-sibling `.npmrc` `registry` / `@scope:registry` or pnpm-workspace.yaml `registry` / `registries` as the pnpm major reads them, vlt's node registry), whose document is read first. | -| `SOCKET_GOPROXY` | `https://proxy.golang.org` | Go module proxy used for upstream restoration, honoring `GOPROXY`, `GONOPROXY` and `GOPRIVATE`. | +| `SOCKET_GOPROXY` | `https://proxy.golang.org` | Go module proxy used for upstream restoration, honoring `GOPROXY`, `GONOPROXY` and `GOPRIVATE`. Like every Go setting socket-patch reads (`GOMODCACHE`, `GOPATH`, `GOWORK`, `GOSUMDB`, `GONOSUMDB`), each resolves as go resolves it: the environment, then the `go env -w` file (`$GOENV`, default `os.UserConfigDir()/go/env`; `GOENV=off` disables it), then go's default. | | `SOCKET_MAVEN_REGISTRY` | `https://repo1.maven.org/maven2` | maven2 base for the fallback upstream-pom download. | | `SOCKET_CRATES_INDEX` | `https://index.crates.io` | v5.0 upstream restore: the crates.io sparse index whose `checksum` a restored `Cargo.lock` entry gets back. | | `SOCKET_GOSUMDB_URL` | `https://sum.golang.org` | v5.0 upstream restore: the checksum database the restored go.sum lines are checked against. Without it, `GOSUMDB=off` or a module matching `GONOSUMDB` (default `GOPRIVATE`) skips the database and the hashes come from the module proxy's bytes alone (`SOCKET_GOPROXY` above). | diff --git a/crates/socket-patch-cli/tests/e2e_golang.rs b/crates/socket-patch-cli/tests/e2e_golang.rs index 095a5d5b5..e2d49c309 100644 --- a/crates/socket-patch-cli/tests/e2e_golang.rs +++ b/crates/socket-patch-cli/tests/e2e_golang.rs @@ -282,6 +282,83 @@ async fn scan_discovers_go_modules() { ); } +/// #344: a module cache configured with `go env -w GOMODCACHE=…` (the +/// `$GOENV` file, not the environment) is the one scanned, exactly as +/// `go env GOMODCACHE` and `go build` resolve it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn scan_finds_the_cache_named_by_the_go_env_file() { + let server = MockServer::start().await; + mount_batch(&server).await; + let api_url = server.uri(); + + let dir = tempfile::tempdir().unwrap(); + let cache_dir = dir.path().join("configured-cache"); + let gin_dir = cache_dir.join("github.com/gin-gonic/gin@v1.9.1"); + std::fs::create_dir_all(&gin_dir).unwrap(); + std::fs::write( + gin_dir.join("go.mod"), + "module github.com/gin-gonic/gin\n\ngo 1.21\n", + ) + .unwrap(); + let project = dir.path().join("project"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write( + project.join("go.mod"), + "module example.com/myproject\n\ngo 1.21\n", + ) + .unwrap(); + // What `go env -w GOMODCACHE=` writes. + let goenv = dir.path().join("goenv"); + std::fs::write(&goenv, format!("GOMODCACHE={}\n", cache_dir.display())).unwrap(); + // HOME points at an empty dir so the `$HOME/go/pkg/mod` default finds + // nothing. + let home = dir.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + + let (project2, api) = (project.clone(), api_url.clone()); + let output = tokio::task::spawn_blocking(move || { + common::hermetic::command(&binary()) + .args(["scan", "--json", "--cwd", project2.to_str().unwrap()]) + .current_dir(&project2) + .env("GOENV", &goenv) + .env("HOME", &home) + .env("USERPROFILE", &home) + .env("SOCKET_API_URL", &api) + .env("SOCKET_API_TOKEN", "sktsec_dummy_e2e_golang_token_api") + .env("SOCKET_ORG_SLUG", ORG) + .env_remove("GOMODCACHE") + .env_remove("GOPATH") + .env_remove("SOCKET_ECOSYSTEMS") + .env_remove("SOCKET_GLOBAL") + .env_remove("SOCKET_GLOBAL_PREFIX") + .env_remove("SOCKET_JSON") + .env_remove("SOCKET_SILENT") + .env_remove("SOCKET_OFFLINE") + .env_remove("SOCKET_PROXY_URL") + .env_remove("SOCKET_BATCH_SIZE") + .output() + .expect("Failed to run socket-patch binary") + }) + .await + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!( + "scan --json must emit JSON ({e}):\n{stdout}\n{}", + String::from_utf8_lossy(&output.stderr) + ) + }); + assert_eq!( + json["scannedPackages"], 1, + "the go env -w GOMODCACHE cache must be crawled; got:\n{json:#}" + ); + let purls = batched_purls(&server).await; + assert_eq!( + purls, + BTreeSet::from(["pkg:golang/github.com/gin-gonic/gin@v1.9.1".to_string()]) + ); +} + /// Verify `socket-patch scan` discovers AND case-decodes Go modules. /// /// Go's module cache stores uppercase letters as `!`+lowercase, so diff --git a/crates/socket-patch-core/src/crawlers/go_crawler.rs b/crates/socket-patch-core/src/crawlers/go_crawler.rs index 439b77ab4..4451b88e1 100644 --- a/crates/socket-patch-core/src/crawlers/go_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/go_crawler.rs @@ -175,14 +175,14 @@ impl GoCrawler { // ------------------------------------------------------------------ /// Get `GOMODCACHE`, falling back to `$GOPATH/pkg/mod/` or `$HOME/go/pkg/mod/`. + /// Each setting resolves as go resolves it: the environment, then the + /// `go env -w` file ([`crate::utils::go_env`]), so a cache configured + /// with `go env -w GOMODCACHE=…` is the one crawled (#344). fn get_gomodcache() -> Option { - if let Ok(cache) = std::env::var("GOMODCACHE") { - let p = PathBuf::from(cache); - if !p.as_os_str().is_empty() { - return Some(p); - } + if let Some(cache) = crate::utils::go_env::go_env("GOMODCACHE") { + return Some(PathBuf::from(cache)); } - if let Ok(gopath) = std::env::var("GOPATH") { + if let Some(gopath) = crate::utils::go_env::go_env("GOPATH") { // GOPATH may list several directories separated by the OS path // separator (`:` on Unix, `;` on Windows). Go uses the FIRST // entry for the module cache, so split rather than treating the @@ -314,12 +314,13 @@ fn go_sum_scope(cwd: &Path) -> Option> { /// Whether the go command would build `cwd` in workspace mode: `GOWORK` /// names a file, or (`GOWORK` unset or empty) a `go.work` exists in `cwd` -/// or an ancestor. `GOWORK=off` disables workspaces. +/// or an ancestor. `GOWORK=off` disables workspaces. `GOWORK` resolves +/// from the environment, then the `go env -w` file. fn workspace_in_effect(cwd: &Path) -> bool { - match std::env::var_os("GOWORK") { + match crate::utils::go_env::go_env("GOWORK") { Some(v) if v == "off" => false, - Some(v) if !v.is_empty() => true, - _ => go_work_at_or_above(cwd, &std::env::current_dir().unwrap_or_default()), + Some(_) => true, + None => go_work_at_or_above(cwd, &std::env::current_dir().unwrap_or_default()), } } @@ -1565,6 +1566,8 @@ mod tests { // own project. Twin of `m2_repo_path`'s / `nuget_home`'s guards. let gopath_a = tempfile::tempdir().unwrap(); let home_b = tempfile::tempdir().unwrap(); + // No `go env -w` file: the developer's own must not leak in. + let _goenv = EnvGuard::set("GOENV", "off"); // Case A: empty GOMODCACHE falls through to GOPATH, and the empty // FIRST GOPATH entry is skipped in favor of the next non-empty one @@ -1602,6 +1605,33 @@ mod tests { ); } + /// #344: GOMODCACHE / GOPATH written with `go env -w` (the `$GOENV` + /// file) locate the cache go itself uses; the environment still wins. + #[test] + #[serial_test::serial] + fn test_get_gomodcache_reads_the_go_env_file() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("env"); + let cache = dir.path().join("custom-cache"); + let gopath = dir.path().join("gopath"); + let _gomodcache = EnvGuard::unset("GOMODCACHE"); + let _gopath = EnvGuard::unset("GOPATH"); + let _goenv = EnvGuard::set("GOENV", file.to_str().unwrap()); + + std::fs::write(&file, format!("GOMODCACHE={}\n", cache.display())).unwrap(); + assert_eq!(GoCrawler::get_gomodcache(), Some(cache.clone())); + + std::fs::write(&file, format!("GOPATH={}\n", gopath.display())).unwrap(); + assert_eq!( + GoCrawler::get_gomodcache(), + Some(gopath.join("pkg").join("mod")) + ); + + let env_cache = dir.path().join("env-cache"); + let _env = EnvGuard::set("GOMODCACHE", env_cache.to_str().unwrap()); + assert_eq!(GoCrawler::get_gomodcache(), Some(env_cache)); + } + #[tokio::test] async fn test_parse_versioned_dir_second_visit_same_purl_returns_none() { // The `seen` dedup contract: crawl_all threads one HashSet through diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/client.rs b/crates/socket-patch-core/src/patch/redirect/upstream/client.rs index 1c055b800..cc5a68d9a 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/client.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/client.rs @@ -691,7 +691,9 @@ pub(crate) const DEFAULT_GOSUMDB: &str = "https://sum.golang.org"; /// The checksum database go would consult for `module`, or `None` when go /// would not (`GOSUMDB=off`, or the module matches `GONOSUMDB` / /// `GOPRIVATE`) — the hashes are then computed from the module proxy's -/// bytes instead. An explicit `SOCKET_GOSUMDB_URL` always wins. +/// bytes instead. An explicit `SOCKET_GOSUMDB_URL` always wins. The Go +/// settings resolve from the environment, then the `go env -w` file +/// ([`crate::utils::go_env`]). fn gosumdb_base(module: &str) -> Option { if let Ok(v) = std::env::var("SOCKET_GOSUMDB_URL") { let v = v.trim().trim_end_matches('/').to_string(); @@ -699,7 +701,7 @@ fn gosumdb_base(module: &str) -> Option { return Some(v); } } - let nonempty = |key: &str| std::env::var(key).ok().filter(|v| !v.trim().is_empty()); + let nonempty = |key: &str| crate::utils::go_env::go_env(key).filter(|v| !v.trim().is_empty()); if nonempty("GOSUMDB").is_some_and(|v| v.trim() == "off") { return None; } diff --git a/crates/socket-patch-core/src/utils/go_env.rs b/crates/socket-patch-core/src/utils/go_env.rs new file mode 100644 index 000000000..73955a4b1 --- /dev/null +++ b/crates/socket-patch-core/src/utils/go_env.rs @@ -0,0 +1,183 @@ +//! Go configuration lookup the way the `go` command resolves it (#344). +//! +//! go reads each setting from the process environment first, then from the +//! per-user Go environment file that `go env -w` writes, then falls back to +//! its built-in default. The Go docs tell users to configure `GOPRIVATE`, +//! `GOPROXY` and `GOMODCACHE` with `go env -w`, so consulting only the +//! environment silently ignores those settings: a private module path is +//! requested from `proxy.golang.org`, a corporate mirror is bypassed, and a +//! relocated module cache is not found. +//! +//! The file is `$GOENV` when set (`GOENV=off` disables it), else +//! `os.UserConfigDir()/go/env`: `$XDG_CONFIG_HOME/go/env` (or +//! `~/.config/go/env`) on Linux and other Unix, `~/Library/Application +//! Support/go/env` on macOS, `%AppData%\go\env` on Windows. +//! +//! Out of scope: `$GOROOT/go.env` (Go 1.21+), the toolchain's own lowest +//! layer. Upstream Go ships it with the same values as the built-in +//! defaults, and locating it would mean running `go`. + +use std::path::PathBuf; + +/// The effective value of Go setting `key`: a non-empty environment variable +/// wins, else the Go environment file's non-empty value, else `None` (the +/// caller applies go's default). An empty environment variable falls +/// through to the file, as go's own `cfg.Getenv` does. +pub(crate) fn go_env(key: &str) -> Option { + go_env_with(key, |k| std::env::var(k).ok()) +} + +/// [`go_env`] over an injected environment lookup. +pub(crate) fn go_env_with(key: &str, env: impl Fn(&str) -> Option) -> Option { + if let Some(v) = env(key).filter(|v| !v.is_empty()) { + return Some(v); + } + let file = go_env_file(&env)?; + let text = crate::utils::fs::read_regular_to_string_sync(&file).ok()?; + lookup_in_env_file(&text, key).filter(|v| !v.is_empty()) +} + +/// The Go environment file go would read, or `None` when `GOENV=off` or no +/// user config directory resolves. +fn go_env_file(env: &impl Fn(&str) -> Option) -> Option { + match env("GOENV").filter(|v| !v.is_empty()) { + Some(v) if v == "off" => None, + Some(v) => Some(PathBuf::from(v)), + None => Some(user_config_dir(env)?.join("go").join("env")), + } +} + +/// Go's `os.UserConfigDir`. +fn user_config_dir(env: &impl Fn(&str) -> Option) -> Option { + let absolute = |v: Option| { + v.filter(|v| !v.is_empty()) + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + }; + if cfg!(windows) { + return absolute(env("AppData").or_else(|| env("APPDATA"))); + } + let home = || absolute(env("HOME")); + if cfg!(target_os = "macos") { + return Some(home()?.join("Library").join("Application Support")); + } + match env("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) { + // go refuses a relative XDG_CONFIG_HOME rather than falling back. + Some(xdg) => absolute(Some(xdg)), + None => Some(home()?.join(".config")), + } +} + +/// `key`'s value in a Go environment file (`cmd/go/internal/cfg` +/// `readEnvFile`): one `KEY=VALUE` per line, the value taken verbatim after +/// the first `=`; a line not starting with an uppercase letter (blank, a +/// `#` comment) or without `=` is skipped. A later line for the same key +/// wins. +fn lookup_in_env_file(text: &str, key: &str) -> Option { + text.lines() + .map(|line| line.strip_suffix('\r').unwrap_or(line)) + .filter(|line| line.starts_with(|c: char| c.is_ascii_uppercase())) + .filter_map(|line| line.split_once('=')) + .rfind(|(k, _)| *k == key) + .map(|(_, v)| v.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let map: HashMap = pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + move |k: &str| map.get(k).cloned() + } + + #[test] + fn parses_go_env_w_output() { + let text = "# comment\nGOPROXY=http://127.0.0.1:18702\r\nGOPRIVATE=example.com,*.corp\n\ + lower=x\nNOEQUALS\nGOFLAGS=-mod=mod -tags=a=b\nGOPROXY=https://mirror\n"; + assert_eq!( + lookup_in_env_file(text, "GOPROXY").as_deref(), + Some("https://mirror") + ); + assert_eq!( + lookup_in_env_file(text, "GOPRIVATE").as_deref(), + Some("example.com,*.corp") + ); + assert_eq!( + lookup_in_env_file(text, "GOFLAGS").as_deref(), + Some("-mod=mod -tags=a=b") + ); + assert_eq!(lookup_in_env_file(text, "lower"), None); + assert_eq!(lookup_in_env_file(text, "NOEQUALS"), None); + assert_eq!(lookup_in_env_file(text, "GONOPROXY"), None); + } + + /// env (non-empty) → `$GOENV` file → `None`; an empty env var falls + /// through to the file and `GOENV=off` disables it. + #[test] + fn env_then_goenv_file_then_default() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("env"); + std::fs::write(&file, "GOPRIVATE=example.com\nGOMODCACHE=/cache\n").unwrap(); + let goenv = file.to_str().unwrap(); + + let env = env_of(&[("GOENV", goenv)]); + assert_eq!( + go_env_with("GOPRIVATE", &env).as_deref(), + Some("example.com") + ); + assert_eq!(go_env_with("GOPROXY", &env), None); + + let env = env_of(&[("GOENV", goenv), ("GOPRIVATE", "env.example")]); + assert_eq!( + go_env_with("GOPRIVATE", &env).as_deref(), + Some("env.example") + ); + let env = env_of(&[("GOENV", goenv), ("GOPRIVATE", "")]); + assert_eq!( + go_env_with("GOPRIVATE", &env).as_deref(), + Some("example.com") + ); + + let env = env_of(&[("GOENV", "off"), ("HOME", "/nonexistent")]); + assert_eq!(go_env_with("GOPRIVATE", &env), None); + let env = env_of(&[("GOENV", dir.path().join("missing").to_str().unwrap())]); + assert_eq!(go_env_with("GOPRIVATE", &env), None); + } + + /// Without `GOENV` the file is `os.UserConfigDir()/go/env`. + #[test] + fn default_file_is_under_the_user_config_dir() { + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + let config = if cfg!(windows) { + base.join("roaming") + } else if cfg!(target_os = "macos") { + base.join("Library").join("Application Support") + } else { + base.join("xdg") + }; + std::fs::create_dir_all(config.join("go")).unwrap(); + std::fs::write(config.join("go").join("env"), "GOPROXY=https://mirror\n").unwrap(); + let env = env_of(&[ + ("HOME", base.to_str().unwrap()), + ("XDG_CONFIG_HOME", base.join("xdg").to_str().unwrap()), + ("AppData", base.join("roaming").to_str().unwrap()), + ]); + assert_eq!( + go_env_with("GOPROXY", &env).as_deref(), + Some("https://mirror") + ); + // A relative XDG_CONFIG_HOME / HOME resolves nothing. + let env = env_of(&[ + ("HOME", "rel"), + ("XDG_CONFIG_HOME", "rel"), + ("AppData", "rel"), + ]); + assert_eq!(go_env_with("GOPROXY", &env), None); + } +} diff --git a/crates/socket-patch-core/src/utils/mod.rs b/crates/socket-patch-core/src/utils/mod.rs index cf4d79805..ea204bc69 100644 --- a/crates/socket-patch-core/src/utils/mod.rs +++ b/crates/socket-patch-core/src/utils/mod.rs @@ -7,6 +7,7 @@ pub(crate) mod durability; pub mod env_compat; pub mod failpoint; pub mod fs; +pub(crate) mod go_env; pub mod group_commit; pub(crate) mod http; pub(crate) mod line_endings; diff --git a/crates/socket-patch-core/src/vendor/registry_fetch.rs b/crates/socket-patch-core/src/vendor/registry_fetch.rs index dc3f3cbd9..7c6eb9b69 100644 --- a/crates/socket-patch-core/src/vendor/registry_fetch.rs +++ b/crates/socket-patch-core/src/vendor/registry_fetch.rs @@ -851,6 +851,11 @@ pub const DEFAULT_GOPROXY: &str = "https://proxy.golang.org"; /// the module matches GONOPROXY (defaulting to GOPRIVATE). Falling back to a /// public proxy there would send a private module path off the machine. /// A non-empty `SOCKET_GOPROXY` is an explicit choice and always wins. +/// +/// GOPROXY / GONOPROXY / GOPRIVATE resolve as go resolves them: the +/// environment, then the `go env -w` file ([`crate::utils::go_env`]), so a +/// GOPRIVATE set with `go env -w` keeps a private path off the public +/// proxy and a mirror set that way is used (#344). pub(crate) fn goproxy_base(module: &str) -> Result { if let Ok(v) = std::env::var("SOCKET_GOPROXY") { let v = v.trim_end_matches('/').to_string(); @@ -858,7 +863,7 @@ pub(crate) fn goproxy_base(module: &str) -> Result { return Ok(v); } } - let nonempty = |key: &str| std::env::var(key).ok().filter(|v| !v.trim().is_empty()); + let nonempty = |key: &str| crate::utils::go_env::go_env(key).filter(|v| !v.trim().is_empty()); if let Some((key, patterns)) = nonempty("GONOPROXY") .map(|v| ("GONOPROXY", v)) .or_else(|| nonempty("GOPRIVATE").map(|v| ("GOPRIVATE", v))) @@ -1979,12 +1984,61 @@ mod tests { ); } + /// #344: GOPROXY / GOPRIVATE / GONOPROXY written with `go env -w` (the + /// `$GOENV` file) are honored like the environment variables: a private + /// module is refused rather than requested from the public proxy, and a + /// mirror is used. The environment still wins over the file. + #[test] + #[serial_test::serial] + fn goproxy_base_reads_the_go_env_file() { + let keys = [ + "GOENV", + "GOPROXY", + "GOPRIVATE", + "GONOPROXY", + "SOCKET_GOPROXY", + ]; + let saved: Vec<_> = keys.iter().map(|k| (*k, std::env::var(k).ok())).collect(); + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("env"); + std::fs::write( + &file, + "GOPROXY=http://127.0.0.1:18702\nGOPRIVATE=example.com\n", + ) + .unwrap(); + for k in &keys[1..] { + std::env::remove_var(k); + } + std::env::set_var("GOENV", &file); + let private = goproxy_base("example.com/upstream"); + let public = goproxy_base("golang.org/x/text"); + std::env::set_var("GOPROXY", "https://env.example"); + let env_wins = goproxy_base("golang.org/x/text"); + std::env::set_var("GOENV", "off"); + std::env::remove_var("GOPROXY"); + let off = goproxy_base("example.com/upstream"); + for (k, v) in saved { + match v { + Some(v) => std::env::set_var(k, v), + None => std::env::remove_var(k), + } + } + let err = private.unwrap_err(); + assert!(err.contains("matches GOPRIVATE"), "{err}"); + assert_eq!(public.as_deref(), Ok("http://127.0.0.1:18702")); + assert_eq!(env_wins.as_deref(), Ok("https://env.example")); + assert_eq!(off.as_deref(), Ok(DEFAULT_GOPROXY)); + } + #[test] #[serial_test::serial] fn goproxy_base_env_precedence() { const MODULE: &str = "example.com/m"; let saved_socket = std::env::var("SOCKET_GOPROXY").ok(); let saved = std::env::var("GOPROXY").ok(); + // No `go env -w` file: the developer's own must not leak in. + let saved_goenv = std::env::var("GOENV").ok(); + std::env::set_var("GOENV", "off"); // SOCKET_GOPROXY wins over GOPROXY (trailing slash trimmed). std::env::set_var("SOCKET_GOPROXY", "https://socket.example/"); @@ -2011,6 +2065,10 @@ mod tests { Some(v) => std::env::set_var("GOPROXY", v), None => std::env::remove_var("GOPROXY"), } + match saved_goenv { + Some(v) => std::env::set_var("GOENV", v), + None => std::env::remove_var("GOENV"), + } assert_eq!(socket_wins.as_deref(), Ok("https://socket.example")); assert_eq!( empty_falls_through.as_deref(),