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
12 changes: 9 additions & 3 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,7 @@ Discovery is read-only, never touches the network, and never fails the run: a ma
| bun | `bun.lock`; `bun.lockb` only when there is no `bun.lock` (bun reads exactly one) | URL tuple / binary remote-tarball resolution; version from the URL leaf. A user URL or `file:` tarball tuple / record whose `<name>-<ver>.tgz` leaf names the same `name@version` is installed from its own spec, so every ref for that `name@version` is dropped | `.socket/vendor/npm/<uuid>/<name>-<ver>.tgz` tuple / local-tarball resolution | `sha512-…`, required. A 2-tuple that Bun < 1.3.10 re-saved without its digest is still a reference, but it attests only from an installed tree. |
| vlt | `vlt-lock.json` (lockfileVersion absent, `0` or `1`; a BOM-prefixed, unparseable, non-object or other-version lock is not read: diagnosed, no ref). `vlt.json` (read only for its `modifiers`) and `node_modules/.vlt-lock.json` are never wiring. | a registry node (any segment) whose slot [3] is a `/patch/npm/…` URL on the patch host with the leaf `<bare>-<ver>.tgz` of its DepID's `name@version`, whose embedded `<name>/<ver>` path (when present) is that `name@version`, and slot [1] == name; version from the DepID | a `file` node `.socket/vendor/npm/<uuid>/<name>-<ver>/node_modules/<name>` (or a user-installed `<name>-<ver>.tgz`) with slot [1] == name; version from the path. A same-`name@version` registry node, or a diagnosed Socket-shaped one, beside it is diagnosed, no ref. | slot [2] `sha512-…`, required (a hosted node without one is no reference). A same-`name@version` node on another registry, or a diagnosed Socket-shaped one, keeps the reference but withholds the lockfile basis: only an installed tree whose every store copy verifies attests. So does a lock some vlt release discards, the conditions of `redirect_vlt_lockfile_version_missing`, `redirect_vlt_old_lockfile_ignored` and `redirect_vlt_scalar_registry_ignored` (which in-run `--vex` withholds too): every hosted reference in it keeps no pin, and one `patched_ref_unattributable` names them. |
| cargo | `Cargo.lock`, `Cargo.toml`, `.cargo/config` (else `.cargo/config.toml`) | `Cargo.lock` `source = "sparse+…/<uuid>/index/"`, confirmed by `Cargo.toml`: a crate the root manifest declares must pin `registry = "socket-patch-<uuid>"`. A reverted pin is diagnosed, no ref. Another `[[package]]` of the same `name@version` from a non-Socket source (crates.io, git), which cargo locks beside the pin for a dependent added later and builds unpatched (#679), withholds the ref (`patched_ref_unattributable`, never attested) while `rollback` / `remove` still unwind it. | `[patch.<source>] <key> = { path = ".socket/vendor/cargo/<uuid>/<name>-<ver>" }` — primarily the root `Cargo.toml` (v5 `vendor`; key-agnostic: `<name>` is `package` when renamed, else the key, so `<name>-socket-<uuid8>` keys count), also the project config (pre-v5 wiring), live only while the lock holds a sourceless entry for it that is not in `[[patch.unused]]`; a manifest entry cargo ignores — the project config redefines its key with another path, or a `[patch."https://github.com/rust-lang/crates.io-index"]` table replaces `[patch.crates-io]` — is diagnosed (`patched_ref_invalid`), no ref | `checksum` (v1: `[metadata]`), required |
| golang | `go.mod`, `go.work`, `go.sum`, `go.work.sum` | `replace M v => patch.socket.dev/gopatch/<uuid> <sver>` | `replace M v => ./.socket/vendor/golang/<uuid>/M@v` | the socket module's `/go.mod` go.sum line, required. The zip line (`h1:` of the module tree) is the locked integrity when present; `go mod tidy` prunes it when no package of the module is loaded and the build stays green, so the `/go.mod` line alone still attests from the lockfile, unless the main modules' `require`s or go.sum show the build selects another version (MVS keeps the `/go.mod` line of versions it only reads): then the replace is inert. With a `go.work` the main modules are its `use` members and the build selects by MVS over their module graph: each member's `require`s and the `go.mod` of every dependency go loads (read from its replacement directory or the module cache when the root is on disk). go keeps no member's `go.mod` tidy in workspace mode, so a member's dependency can raise the module past every `require`. A pin, zip line or not, whose replaced version a member's or a dependency's `require` raises is inert, as is one of a module that is itself a `use` member (go builds it from that directory). Sum-file zip lines are no evidence of the selection (go only appends to `go.work.sum`), and a `/go.mod`-only pin is never the lockfile basis in a workspace (`integrity_required = true`). Any mode writes the replace of a module a workspace shares into the root `go.work` (source `go.work`); a `go.work` root without a `go.mod` also reads each in-root member's `go.mod` replaces (source `<member>/go.mod`). The sum files are the ones go reads: the root `go.sum`, or with a `go.work`, `go.work.sum` and each in-root `use` member's `go.sum` (the root's only when `.` is a member); under `GOWORK=off`, `go.work` and its replaces are ignored; a `GOWORK` naming any file other than the root's `go.work` (or a relative path, which go rejects) leaves the workspace unread: each Go ref is reported as `patched_ref_invalid` instead. A conflicting hash or a malformed `h1:` for the socket module+version, or a line of other than three tokens anywhere in a sum file, is no pin. A replace that `require` no longer selects (`require M v'`) is inert: diagnosed, no ref. |
| golang | `go.mod`, `go.work`, `go.sum`, `go.work.sum` | `replace M v => patch.socket.dev/gopatch/<uuid> <sver>` | `replace M v => ./.socket/vendor/golang/<uuid>/M@v` | the socket module's `/go.mod` go.sum line, required. The zip line (`h1:` of the module tree) is the locked integrity when present; `go mod tidy` prunes it when no package of the module is loaded and the build stays green, so the `/go.mod` line alone still attests from the lockfile, unless the main modules' `require`s or go.sum show the build selects another version (MVS keeps the `/go.mod` line of versions it only reads): then the replace is inert. With a `go.work` the main modules are its `use` members and the build selects by MVS over their module graph: each member's `require`s and the `go.mod` of every dependency go loads (read from its replacement directory or the module cache when the root is on disk). go keeps no member's `go.mod` tidy in workspace mode, so a member's dependency can raise the module past every `require`. A pin, zip line or not, whose replaced version a member's or a dependency's `require` raises is inert, as is one of a module that is itself a `use` member (go builds it from that directory). Sum-file zip lines are no evidence of the selection (go only appends to `go.work.sum`), and a `/go.mod`-only pin is never the lockfile basis in a workspace (`integrity_required = true`). Any mode writes the replace of a module a workspace shares into the root `go.work` (source `go.work`); a `go.work` root without a `go.mod` also reads each in-root member's `go.mod` replaces (source `<member>/go.mod`). The sum files are the ones go reads: the root `go.sum`, or with a `go.work`, `go.work.sum` and each in-root `use` member's `go.sum` (the root's only when `.` is a member); under `GOWORK=off`, `go.work` and its replaces are ignored; a `GOWORK` naming any file other than the root's `go.work` (or a relative path, which go rejects) leaves the workspace unread: each Go ref is reported as `patched_ref_invalid` instead, as it is when `GOFLAGS` sets a `-modfile` other than the root `go.mod`. A conflicting hash or a malformed `h1:` for the socket module+version, or a line of other than three tokens anywhere in a sum file, is no pin. A replace that `require` no longer selects (`require M v'`) is inert: diagnosed, no ref. |
| pypi | `uv.lock` (confirmed by `pyproject.toml` `[tool.uv.sources]` when present), PEP 723 `<script>.py.lock`, `pylock.toml` / `pylock.<name>.toml`, `poetry.lock`, `pdm.lock`, `Pipfile.lock`, `requirements.txt` + its in-root `-r` includes, PEP 508 direct references in `pyproject.toml` / `hatch.toml` | Socket-host artifact url | `.socket/vendor/pypi/<uuid>/<wheel>` naming the entry's own dist | sha256, required |
| gem | `Gemfile.lock` and `gems.locked` (the `Gemfile` / `gems.rb` only to cross-check a merged multi-remote `GEM` section) | a `GEM` section whose remote ends `patch-registry/gem/<token>/<uuid>` | `PATH` remote `.socket/vendor/gem/<uuid>/<name>-<ver>` | `CHECKSUMS` sha256, required only when the lock has a `CHECKSUMS` section (bundler ≥ 2.6) |
| composer | `composer.lock` (`packages` + `packages-dev`) | `dist.url` on the patch host | `dist: {type: "path", url: ".socket/vendor/composer/<uuid>/…", reference: "<uuid>"}` | `dist.shasum`, required |
Expand Down Expand Up @@ -659,7 +659,13 @@ are patched by `socket-patch apply` like every other ecosystem:
--check` is a read-only audit of the committed redirect.
The `go.work` in effect is resolved as go resolves it: `GOWORK` naming an absolute file, none under
`GOWORK=off`, else (unset, empty or `auto`) the nearest `go.work` at or above the project; a relative
`GOWORK` is invalid to go, so nothing is written or attested. When a `go.work` is in effect, `apply`,
`GOWORK` is invalid to go, so nothing is written or attested. A `-modfile` in `GOFLAGS` (#1799)
that names any file other than the project's `go.mod` (a relative one is taken from the project
directory) makes go build from that file and ignore `go.mod`'s replaces, and in workspace mode go
refuses `-modfile` outright: `apply`, `vendor` and `scan --mode hosted` write nothing
(`redirect_golang_workspace_conflict` in hosted mode), a replace already written fails
`apply --check` / `vendor --check` with `go_workspace_replace_ineffective`, and `vex` omits the patch
with `go_workspace_override` (discovered Go refs become `patched_ref_invalid`). When a `go.work` is in effect, `apply`,
`vendor` and `scan --mode hosted` refuse a `replace` that it would override (a go.work `replace` of
that version of the module, or a version-less one; one of another version leaves the member's in
force), ignore (the project isn't a `use` member) or reject (another member replaces the same
Expand Down Expand Up @@ -1406,7 +1412,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`. 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_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`, `GOFLAGS`, `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
1 change: 1 addition & 0 deletions crates/socket-patch-cli/src/commands/vex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -959,6 +959,7 @@ async fn generate_vex(
module,
version,
conflict,
..
} = &issue
else {
continue;
Expand Down
120 changes: 120 additions & 0 deletions crates/socket-patch-cli/tests/e2e_golang_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1337,3 +1337,123 @@ fn global_apply_flags_tools_built_from_the_unpatched_module() {

chmod_writable(&modcache);
}

// ── GOFLAGS=-modfile (#1799) ─────────────────────────────────────────

/// `socket-patch <args>` in `dir` with `GOFLAGS` (and `GOENV`) set.
fn run_goflags(
dir: &Path,
args: &[&str],
modcache: &Path,
goflags: &str,
goenv: &str,
) -> (i32, String, String) {
let mut full = args.to_vec();
full.extend(["--cwd", dir.to_str().unwrap()]);
common::run_with_env(
dir,
&full,
&[
("GOMODCACHE", modcache.to_str().unwrap()),
("GOFLAGS", goflags),
("GOENV", goenv),
],
)
}

/// #1799: with `-modfile` in `GOFLAGS` go builds from another go.mod, so a
/// replace in go.mod never reaches the build. apply must refuse instead of
/// reporting the patch applied; one written before `-modfile` was set fails
/// `apply --check` and is left out of VEX, whether `-modfile` comes from
/// the environment or the `go env -w` file.
#[test]
fn goflags_modfile_keeps_the_go_mod_replace_from_the_build() {
if !golang_e2e_matrix::toolchain_ready("e2e_golang_build(goflags-modfile)") {
return;
}
let tmp = tempfile::tempdir().unwrap();
let (consumer, modcache, proxy_url) = stage(tmp.path());
let mc = modcache.to_str().unwrap();
let env = go_env(mc, &proxy_url);
assert!(go(&consumer, &["mod", "tidy"], &env).status.success());
std::fs::copy(consumer.join("go.mod"), consumer.join("go.alt.mod")).unwrap();
std::fs::copy(consumer.join("go.sum"), consumer.join("go.alt.sum")).unwrap();
let alt_env: Vec<(&str, &str)> = env
.iter()
.map(|&(k, v)| match k {
"GOFLAGS" => (k, "-mod=mod -modfile=go.alt.mod"),
_ => (k, v),
})
.collect();
assert!(go_out(&consumer, &alt_env).contains("OUT: PRISTINE"));
write_patch(&consumer);
let modfile = "-modfile=go.alt.mod";
let no_goenv = "off";

// The dry run predicts the refusal; the real run writes nothing.
for dry in [true, false] {
let mut args = vec!["apply", "--offline", "--ecosystems", "golang", "--json"];
if dry {
args.push("--dry-run");
}
let (code, so, se) = run_goflags(&consumer, &args, &modcache, modfile, no_goenv);
assert_ne!(code, 0, "apply (dry run {dry}) must refuse:\n{so}\n{se}");
assert!(so.contains("-modfile=go.alt.mod"), "{so}");
assert!(replace_lines(&consumer.join("go.mod")).is_empty());
assert!(
!consumer.join(".socket/go-patches").exists(),
"a refused apply leaves no copy behind"
);
}
assert!(go_out(&consumer, &alt_env).contains("OUT: PRISTINE"));

// Applied without -modfile: the replace patches the default build...
let (code, so, se) = run_goflags(
&consumer,
&["apply", "--offline", "--ecosystems", "golang", "--json"],
&modcache,
"",
no_goenv,
);
assert_eq!(code, 0, "apply without -modfile:\n{so}\n{se}");
assert!(go_out(&consumer, &env).contains("OUT: PATCHED"));
// ...but not the -modfile one, which neither --check nor vex may pass.
assert!(go_out(&consumer, &alt_env).contains("OUT: PRISTINE"));
let goenv_file = tmp.path().join("goenv");
std::fs::write(&goenv_file, format!("GOFLAGS={modfile}\n")).unwrap();
for (goflags, goenv) in [(modfile, no_goenv), ("", goenv_file.to_str().unwrap())] {
let (code, so, se) = run_goflags(
&consumer,
&["apply", "--check", "--ecosystems", "golang"],
&modcache,
goflags,
goenv,
);
assert_eq!(code, 1, "apply --check must flag the replace:\n{so}\n{se}");
assert!(se.contains("-modfile=go.alt.mod"), "{se}");

let r = VexRun {
offline: true,
product: Some("pkg:golang/example.com/consumer@v0.0.1".into()),
..VexRun::default()
}
.env("GOMODCACHE", &modcache)
.env("GOFLAGS", goflags)
.env("GOENV", goenv);
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_workspace_override");
}

// -modfile naming the project's own go.mod is go's default.
let (code, so, se) = run_goflags(
&consumer,
&["apply", "--check", "--ecosystems", "golang"],
&modcache,
"-modfile=go.mod",
no_goenv,
);
assert_eq!(code, 0, "-modfile=go.mod is in sync:\n{so}\n{se}");

chmod_writable(tmp.path());
}
21 changes: 14 additions & 7 deletions crates/socket-patch-core/src/hosted/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -487,15 +487,15 @@ async fn read_advisory(
/// The Go workspace in effect for the project, handed to the golang
/// rewriter under [`crate::patch::redirect::GO_WORKSPACE_KEY`]. On disk it is
/// the one go resolves (`GOWORK`, else a go.work at or above the root, with
/// members anywhere); in memory a root `go.work` and its in-root members.
/// A root go.work and go.work.sum are candidate files the rewriter writes.
/// members anywhere, plus a `-modfile` that GOFLAGS sets); in memory a root
/// `go.work` and its in-root members. A root go.work and go.work.sum are candidate files the rewriter writes.
async fn read_go_workspace(
view: &ProjectView<'_>,
unreadable: &BTreeSet<String>,
out: &mut CandidateFiles,
) {
use crate::vendor::go_workspace::{self, GoWorkspace, Loaded, Member};
let (root, loaded) = match view {
let (root, loaded, modfile) = match view {
ProjectView::Memory(_) => 'memory: {
let Some(work) = read_advisory(view, unreadable, "go.work").await else {
return;
Expand All @@ -507,6 +507,7 @@ async fn read_go_workspace(
break 'memory (
std::path::PathBuf::new(),
Loaded::Invalid(format!("go.work: {problem}, so go refuses to build")),
None,
);
}
let mut members = Vec::new();
Expand Down Expand Up @@ -539,7 +540,7 @@ async fn read_go_workspace(
members,
graph: None,
};
(std::path::PathBuf::new(), Loaded::Workspace(ws))
(std::path::PathBuf::new(), Loaded::Workspace(ws), None)
}
ProjectView::Disk(_) | ProjectView::Snapshot(_) => {
let Some(root) = view.disk_root_reading(std::iter::empty::<&std::path::Path>()) else {
Expand All @@ -560,13 +561,19 @@ async fn read_go_workspace(
}
}
view.disk_root_reading(&touched);
(root, loaded)
// `-modfile` in GOFLAGS sends the build to another go.mod (#1799).
let modfile = go_workspace::modfile_setting(&root);
(root, loaded, modfile)
}
};
if loaded == Loaded::None {
if loaded == Loaded::None && modfile.is_none() {
return;
}
let hosted = crate::patch::redirect::HostedGoWorkspace { root, loaded };
let hosted = crate::patch::redirect::HostedGoWorkspace {
root,
loaded,
modfile,
};
if let Ok(json) = serde_json::to_string(&hosted) {
out.files
.insert(crate::patch::redirect::GO_WORKSPACE_KEY.to_string(), json);
Expand Down
Loading
Loading