Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<base>/<name>/<version>`, 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). |
Expand Down
77 changes: 77 additions & 0 deletions crates/socket-patch-cli/tests/e2e_golang.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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=<cache>` 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
Expand Down
50 changes: 40 additions & 10 deletions crates/socket-patch-core/src/crawlers/go_crawler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf> {
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
Expand Down Expand Up @@ -314,12 +314,13 @@ fn go_sum_scope(cwd: &Path) -> Option<Vec<(String, String)>> {

/// 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()),
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -691,15 +691,17 @@ 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<String> {
if let Ok(v) = std::env::var("SOCKET_GOSUMDB_URL") {
let v = v.trim().trim_end_matches('/').to_string();
if !v.is_empty() {
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;
}
Expand Down
183 changes: 183 additions & 0 deletions crates/socket-patch-core/src/utils/go_env.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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<String>) -> Option<String> {
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<String>) -> Option<PathBuf> {
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<String>) -> Option<PathBuf> {
let absolute = |v: Option<String>| {
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<String> {
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<String> {
let map: HashMap<String, String> = 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);
}
}
1 change: 1 addition & 0 deletions crates/socket-patch-core/src/utils/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading