Skip to content

Commit f004e53

Browse files
mikolalysenkoclaude
andcommitted
feat(golang): project-local go.mod [replace]-redirect backend + fail-closed guard
Adds Go support to `socket-patch setup` with automatic, fail-closed enforcement that all manifest patches stay applied after dependency changes — the analog of the cargo `[patch]`-redirect backend, adapted to Go's constraints. Mechanism (empirically validated on go 1.26): patch only the affected modules by copying them to `.socket/go-patches/<module>@<version>/` and redirecting the build with a `go.mod` `replace` directive. A local-path replace target is not go.sum-verified, so patched bytes build cleanly under `-mod=readonly`; the module cache stays pristine (so `go mod verify` keeps passing). Only patched modules are copied/committed — no full `go mod vendor`. Go has no build hook, so the gate is delivered as committed source: `internal/socketpatchguard/{guard.go,guard_test.go}` plus a generated blank import in each `package main` dir. The guard delegates to `apply --check`/`apply` (self-heal then fail-closed), is a silent no-op outside the module tree (shipped binaries are never bricked), and skips its init() under `go test`. NOTE: `go test` caching can mask external drift, so the authoritative gates are the init() guard (every run) and a `socket-patch apply --check --ecosystems golang` CI step; the version-pinned replace is cross-checked against go.mod to catch the silent-stale (MVS resolved a different version) hole. Core: - patch/go_mod_edit.rs: format-preserving go.mod replace/require editor. - patch/go_redirect.rs: apply/verify/remove/reconcile (6 drift kinds), port of cargo_redirect; synthesizes a go.mod for pre-modules copies. - patch/copy_tree.rs: shared perm-aware tree copy, extracted from cargo_redirect (now used by both backends). - go_setup/: module discovery, guard templates, main-dir detection, file wiring. CLI: - apply.rs: local-go dispatch + generalized `--check` (cargo+golang) + reconcile. - setup.rs: build_go_outcome + finalize_go (materializes redirects) + check/remove weaving (CargoOutcome generalized to SetupOutcome). All four cargo/golang feature combos build; new unit + e2e suites (incl. a hermetic go-toolchain capstone proving `go build` links the patch and the guard enforces+heals drift). No regressions in the cargo backend. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ed435b4 commit f004e53

14 files changed

Lines changed: 3609 additions & 176 deletions

File tree

crates/socket-patch-cli/src/commands/apply.rs

Lines changed: 227 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ use socket_patch_core::patch::cargo_redirect::{
1818
};
1919
#[cfg(feature = "cargo")]
2020
use socket_patch_core::utils::purl::parse_cargo_purl;
21+
#[cfg(feature = "golang")]
22+
use socket_patch_core::patch::go_redirect::{
23+
apply_go_redirect, reconcile_go_redirects, verify_go_redirect_state,
24+
};
25+
#[cfg(feature = "golang")]
26+
use socket_patch_core::utils::purl::parse_golang_purl;
2127

2228
use crate::commands::lock_cli::{acquire_or_emit, lock_broken_event};
2329
use socket_patch_core::utils::purl::strip_purl_qualifiers;
@@ -255,12 +261,112 @@ async fn reconcile_local_cargo(common: &GlobalArgs, target_manifest_purls: &Hash
255261
#[cfg(not(feature = "cargo"))]
256262
async fn reconcile_local_cargo(_common: &GlobalArgs, _target_manifest_purls: &HashSet<String>) {}
257263

258-
/// Read-only verification of committed cargo redirects (CI / GitHub-App audit).
259-
/// Lock-free, crawl-free, offline-safe. Exits 0 when in sync, 1 on drift.
260-
#[cfg(feature = "cargo")]
261-
async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 {
262-
use socket_patch_core::patch::cargo_redirect::Drift;
264+
// ── local-go redirect helpers ────────────────────────────────────────────────
265+
// The Go analog of the cargo helpers above: in local mode a `pkg:golang/…` PURL
266+
// redirects to a project-local patched copy under `.socket/go-patches/` wired via
267+
// a `go.mod` `replace` directive. Inert stubs without the `golang` feature.
268+
269+
/// True for a golang PURL in local mode (no `--global` / `--global-prefix`).
270+
#[cfg(feature = "golang")]
271+
fn is_local_go(purl: &str, common: &GlobalArgs) -> bool {
272+
!common.global
273+
&& common.global_prefix.is_none()
274+
&& Ecosystem::from_purl(purl) == Some(Ecosystem::Golang)
275+
}
276+
277+
/// Whether local-go redirects are in scope (local mode + golang not filtered out
278+
/// by `--ecosystems`). Gates reconcile / `--check`.
279+
#[cfg(feature = "golang")]
280+
fn go_in_local_scope(common: &GlobalArgs) -> bool {
281+
if common.global || common.global_prefix.is_some() {
282+
return false;
283+
}
284+
match &common.ecosystems {
285+
None => true,
286+
Some(list) => list
287+
.iter()
288+
.any(|e| e.eq_ignore_ascii_case("golang") || e.eq_ignore_ascii_case("go")),
289+
}
290+
}
291+
292+
/// Materialise a local-go redirect for `purl`, or `None` if `purl` isn't a
293+
/// local-go target (the caller then falls back to in-place apply, i.e. the
294+
/// `--global` module-cache path).
295+
#[cfg(feature = "golang")]
296+
async fn try_local_go_apply(
297+
purl: &str,
298+
pkg_path: &Path,
299+
patch: &PatchRecord,
300+
sources: &PatchSources<'_>,
301+
common: &GlobalArgs,
302+
force: bool,
303+
) -> Option<ApplyResult> {
304+
if !is_local_go(purl, common) {
305+
return None;
306+
}
307+
// `pkg_path` is the pristine, case-encoded module-cache dir; `module`/
308+
// `version` are the decoded PURL components keying the copy + `replace`.
309+
let (module, version) = parse_golang_purl(purl)?;
310+
Some(
311+
apply_go_redirect(
312+
purl,
313+
module,
314+
version,
315+
pkg_path,
316+
&common.cwd,
317+
&patch.files,
318+
sources,
319+
Some(&patch.uuid),
320+
common.dry_run,
321+
force,
322+
)
323+
.await,
324+
)
325+
}
326+
327+
#[cfg(not(feature = "golang"))]
328+
async fn try_local_go_apply(
329+
_purl: &str,
330+
_pkg_path: &Path,
331+
_patch: &PatchRecord,
332+
_sources: &PatchSources<'_>,
333+
_common: &GlobalArgs,
334+
_force: bool,
335+
) -> Option<ApplyResult> {
336+
None
337+
}
338+
339+
/// After the apply loop: prune local-go redirects whose patches were dropped
340+
/// from the manifest. No-op unless local go is in scope.
341+
#[cfg(feature = "golang")]
342+
async fn reconcile_local_go(common: &GlobalArgs, target_manifest_purls: &HashSet<String>) {
343+
if !go_in_local_scope(common) {
344+
return;
345+
}
346+
let desired: HashSet<String> = target_manifest_purls
347+
.iter()
348+
.filter(|p| Ecosystem::from_purl(p) == Some(Ecosystem::Golang))
349+
.cloned()
350+
.collect();
351+
let removed = reconcile_go_redirects(&common.cwd, &desired, common.dry_run).await;
352+
if !removed.is_empty() && !common.silent && !common.json {
353+
let verb = if common.dry_run { "Would remove" } else { "Removed" };
354+
println!("{verb} {} stale go patch redirect(s):", removed.len());
355+
for purl in &removed {
356+
println!(" {purl}");
357+
}
358+
}
359+
}
263360

361+
#[cfg(not(feature = "golang"))]
362+
async fn reconcile_local_go(_common: &GlobalArgs, _target_manifest_purls: &HashSet<String>) {}
363+
364+
/// Read-only verification of committed local redirects (cargo + go) for CI /
365+
/// GitHub-App auditing and the build-time guard probe. Lock-free, crawl-free,
366+
/// offline-safe. Exits 0 when in sync, 1 on drift. Verifies every redirect
367+
/// ecosystem that is both compiled in and in `--ecosystems` scope.
368+
#[cfg(any(feature = "cargo", feature = "golang"))]
369+
async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 {
264370
let manifest = match read_manifest(manifest_path).await {
265371
Ok(Some(m)) => m,
266372
// The caller already confirmed the manifest file exists. `Ok(None)` means
@@ -271,82 +377,116 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 {
271377
Err(e) => {
272378
if !args.common.silent && !args.common.json {
273379
eprintln!(
274-
"Cargo patch redirect check could not read the manifest ({e}); \
380+
"Patch redirect check could not read the manifest ({e}); \
275381
treating as drift (fail-closed)."
276382
);
277383
}
278384
return 1;
279385
}
280386
};
281387

282-
let desired: HashSet<String> = if cargo_in_local_scope(&args.common) {
283-
manifest
284-
.patches
285-
.keys()
286-
.filter(|p| Ecosystem::from_purl(p) == Some(Ecosystem::Cargo))
287-
.cloned()
288-
.collect()
289-
} else {
290-
HashSet::new()
291-
};
292-
293-
match verify_cargo_redirect_state(&args.common.cwd, &manifest, &desired).await {
294-
Ok(()) => {
295-
if args.common.json {
296-
println!("{}", Envelope::new(Command::Apply).to_pretty_json());
297-
} else if !args.common.silent {
298-
println!(
299-
"Cargo patch redirects are in sync ({} checked).",
300-
desired.len()
301-
);
388+
// (purl_or_name, reason_code, detail) for each drift across ecosystems.
389+
let mut drifts: Vec<(String, &'static str, String)> = Vec::new();
390+
let mut checked: usize = 0;
391+
392+
#[cfg(feature = "cargo")]
393+
{
394+
use socket_patch_core::patch::cargo_redirect::Drift as CargoDrift;
395+
if cargo_in_local_scope(&args.common) {
396+
let desired: HashSet<String> = manifest
397+
.patches
398+
.keys()
399+
.filter(|p| Ecosystem::from_purl(p) == Some(Ecosystem::Cargo))
400+
.cloned()
401+
.collect();
402+
checked += desired.len();
403+
if let Err(ds) = verify_cargo_redirect_state(&args.common.cwd, &manifest, &desired).await
404+
{
405+
for d in &ds {
406+
let id = match d {
407+
CargoDrift::MissingCopy { purl }
408+
| CargoDrift::StaleCopy { purl, .. }
409+
| CargoDrift::MissingEntry { purl }
410+
| CargoDrift::WrongEntryPath { purl, .. }
411+
| CargoDrift::ResolvedVersionMismatch { purl, .. } => purl.clone(),
412+
CargoDrift::OrphanEntry { name } => name.clone(),
413+
};
414+
drifts.push((id, "cargo_redirect_drift", d.to_string()));
415+
}
302416
}
303-
0
304417
}
305-
Err(drifts) => {
306-
if args.common.json {
307-
let mut env = Envelope::new(Command::Apply);
308-
for d in &drifts {
309-
let purl = match d {
310-
Drift::MissingCopy { purl }
311-
| Drift::StaleCopy { purl, .. }
312-
| Drift::MissingEntry { purl }
313-
| Drift::WrongEntryPath { purl, .. }
314-
| Drift::ResolvedVersionMismatch { purl, .. } => purl.clone(),
315-
Drift::OrphanEntry { name } => name.clone(),
418+
}
419+
420+
#[cfg(feature = "golang")]
421+
{
422+
use socket_patch_core::patch::go_redirect::Drift as GoDrift;
423+
if go_in_local_scope(&args.common) {
424+
let desired: HashSet<String> = manifest
425+
.patches
426+
.keys()
427+
.filter(|p| Ecosystem::from_purl(p) == Some(Ecosystem::Golang))
428+
.cloned()
429+
.collect();
430+
checked += desired.len();
431+
if let Err(ds) = verify_go_redirect_state(&args.common.cwd, &manifest, &desired).await {
432+
for d in &ds {
433+
let id = match d {
434+
GoDrift::MissingCopy { purl }
435+
| GoDrift::StaleCopy { purl, .. }
436+
| GoDrift::MissingReplace { purl }
437+
| GoDrift::WrongReplacePath { purl, .. }
438+
| GoDrift::ResolvedVersionMismatch { purl, .. } => purl.clone(),
439+
GoDrift::OrphanReplace { module } => module.clone(),
316440
};
317-
env.record(
318-
PatchEvent::new(PatchAction::Failed, purl)
319-
.with_reason("cargo_redirect_drift", d.to_string()),
320-
);
441+
drifts.push((id, "go_redirect_drift", d.to_string()));
321442
}
322-
env.mark_partial_failure();
323-
println!("{}", env.to_pretty_json());
324-
} else if !args.common.silent {
325-
eprintln!("Cargo patch redirects are OUT OF SYNC:");
326-
for d in &drifts {
327-
eprintln!(" {d}");
328-
}
329-
eprintln!("Run `socket-patch apply` to regenerate them.");
330443
}
331-
1
332444
}
333445
}
446+
447+
if drifts.is_empty() {
448+
if args.common.json {
449+
println!("{}", Envelope::new(Command::Apply).to_pretty_json());
450+
} else if !args.common.silent {
451+
println!("Patch redirects are in sync ({checked} checked).");
452+
}
453+
0
454+
} else {
455+
if args.common.json {
456+
let mut env = Envelope::new(Command::Apply);
457+
for (id, code, detail) in &drifts {
458+
env.record(
459+
PatchEvent::new(PatchAction::Failed, id.clone())
460+
.with_reason(*code, detail.clone()),
461+
);
462+
}
463+
env.mark_partial_failure();
464+
println!("{}", env.to_pretty_json());
465+
} else if !args.common.silent {
466+
eprintln!("Patch redirects are OUT OF SYNC:");
467+
for (_, _, detail) in &drifts {
468+
eprintln!(" {detail}");
469+
}
470+
eprintln!("Run `socket-patch apply` to regenerate them.");
471+
}
472+
1
473+
}
334474
}
335475

336-
#[cfg(not(feature = "cargo"))]
476+
#[cfg(not(any(feature = "cargo", feature = "golang")))]
337477
async fn run_check(args: &ApplyArgs, _manifest_path: &Path) -> i32 {
338-
// Fail-closed: `--check` is the cargo patch-redirect audit. A socket-patch
339-
// built WITHOUT the `cargo` feature cannot verify those redirects, so it must
340-
// NOT report "in sync" (exit 0). The build-time guard probes whatever
478+
// Fail-closed: `--check` is the redirect audit. A socket-patch built WITHOUT
479+
// any redirect ecosystem (cargo/golang) cannot verify those redirects, so it
480+
// must NOT report "in sync" (exit 0). The build-time guard probes whatever
341481
// `socket-patch` is on the build machine's PATH; if a feature-off binary
342482
// answered 0 here, the guard would silently proceed against possibly
343483
// stale/unpatched copies — defeating its whole purpose. Exit non-zero with a
344484
// clear reason so the guard fails the build instead.
345485
if !args.common.silent && !args.common.json {
346486
eprintln!(
347-
"socket-patch: this build has no cargo support, so it cannot verify cargo \
487+
"socket-patch: this build has no cargo/golang support, so it cannot verify \
348488
patch redirects (`--check`). Install a socket-patch built with the `cargo` \
349-
feature, or point SOCKET_PATCH_BIN at one."
489+
and/or `golang` feature, or point SOCKET_PATCH_BIN at one."
350490
);
351491
}
352492
2
@@ -954,6 +1094,7 @@ async fn apply_patches_inner(
9541094
// now lists zero in-scope cargo patches (the all-removed case). No-op
9551095
// unless local cargo is in scope.
9561096
reconcile_local_cargo(&args.common, &target_manifest_purls).await;
1097+
reconcile_local_go(&args.common, &target_manifest_purls).await;
9571098

9581099
let crawler_options = CrawlerOptions {
9591100
cwd: args.common.cwd.clone(),
@@ -1101,11 +1242,11 @@ async fn apply_patches_inner(
11011242
packages_path: Some(&packages_path),
11021243
diffs_path: Some(&diffs_path),
11031244
};
1104-
// Local cargo redirects to a project-local patched copy
1105-
// (`apply_cargo_redirect`); everything else — npm/pypi, and cargo
1106-
// under --global/--global-prefix — patches in place via
1107-
// `apply_package_patch`. In a build without the `cargo` feature
1108-
// `try_local_cargo_apply` is an inert `None`.
1245+
// Local cargo/go redirect to a project-local patched copy
1246+
// (`apply_cargo_redirect` / `apply_go_redirect`); everything else —
1247+
// npm/pypi, and cargo/go under --global/--global-prefix — patches in
1248+
// place via `apply_package_patch`. Without the respective feature the
1249+
// `try_local_*_apply` helpers are inert `None`s.
11091250
let result = match try_local_cargo_apply(
11101251
purl,
11111252
pkg_path,
@@ -1117,18 +1258,30 @@ async fn apply_patches_inner(
11171258
.await
11181259
{
11191260
Some(r) => r,
1120-
None => {
1121-
apply_package_patch(
1122-
purl,
1123-
pkg_path,
1124-
&patch.files,
1125-
&sources,
1126-
Some(&patch.uuid),
1127-
args.common.dry_run,
1128-
args.force,
1129-
)
1130-
.await
1131-
}
1261+
None => match try_local_go_apply(
1262+
purl,
1263+
pkg_path,
1264+
patch,
1265+
&sources,
1266+
&args.common,
1267+
args.force,
1268+
)
1269+
.await
1270+
{
1271+
Some(r) => r,
1272+
None => {
1273+
apply_package_patch(
1274+
purl,
1275+
pkg_path,
1276+
&patch.files,
1277+
&sources,
1278+
Some(&patch.uuid),
1279+
args.common.dry_run,
1280+
args.force,
1281+
)
1282+
.await
1283+
}
1284+
},
11321285
};
11331286

11341287
if !result.success {

0 commit comments

Comments
 (0)