diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 9a0148e..09c01c2 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -18,6 +18,12 @@ use socket_patch_core::patch::cargo_redirect::{ }; #[cfg(feature = "cargo")] use socket_patch_core::utils::purl::parse_cargo_purl; +#[cfg(feature = "golang")] +use socket_patch_core::patch::go_redirect::{ + apply_go_redirect, reconcile_go_redirects, verify_go_redirect_state, +}; +#[cfg(feature = "golang")] +use socket_patch_core::utils::purl::parse_golang_purl; use crate::commands::lock_cli::{acquire_or_emit, lock_broken_event}; use socket_patch_core::utils::purl::strip_purl_qualifiers; @@ -255,12 +261,112 @@ async fn reconcile_local_cargo(common: &GlobalArgs, target_manifest_purls: &Hash #[cfg(not(feature = "cargo"))] async fn reconcile_local_cargo(_common: &GlobalArgs, _target_manifest_purls: &HashSet) {} -/// Read-only verification of committed cargo redirects (CI / GitHub-App audit). -/// Lock-free, crawl-free, offline-safe. Exits 0 when in sync, 1 on drift. -#[cfg(feature = "cargo")] -async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { - use socket_patch_core::patch::cargo_redirect::Drift; +// ── local-go redirect helpers ──────────────────────────────────────────────── +// The Go analog of the cargo helpers above: in local mode a `pkg:golang/…` PURL +// redirects to a project-local patched copy under `.socket/go-patches/` wired via +// a `go.mod` `replace` directive. Inert stubs without the `golang` feature. + +/// True for a golang PURL in local mode (no `--global` / `--global-prefix`). +#[cfg(feature = "golang")] +fn is_local_go(purl: &str, common: &GlobalArgs) -> bool { + !common.global + && common.global_prefix.is_none() + && Ecosystem::from_purl(purl) == Some(Ecosystem::Golang) +} + +/// Whether local-go redirects are in scope (local mode + golang not filtered out +/// by `--ecosystems`). Gates reconcile / `--check`. +#[cfg(feature = "golang")] +fn go_in_local_scope(common: &GlobalArgs) -> bool { + if common.global || common.global_prefix.is_some() { + return false; + } + match &common.ecosystems { + None => true, + Some(list) => list + .iter() + .any(|e| e.eq_ignore_ascii_case("golang") || e.eq_ignore_ascii_case("go")), + } +} + +/// Materialise a local-go redirect for `purl`, or `None` if `purl` isn't a +/// local-go target (the caller then falls back to in-place apply, i.e. the +/// `--global` module-cache path). +#[cfg(feature = "golang")] +async fn try_local_go_apply( + purl: &str, + pkg_path: &Path, + patch: &PatchRecord, + sources: &PatchSources<'_>, + common: &GlobalArgs, + force: bool, +) -> Option { + if !is_local_go(purl, common) { + return None; + } + // `pkg_path` is the pristine, case-encoded module-cache dir; `module`/ + // `version` are the decoded PURL components keying the copy + `replace`. + let (module, version) = parse_golang_purl(purl)?; + Some( + apply_go_redirect( + purl, + module, + version, + pkg_path, + &common.cwd, + &patch.files, + sources, + Some(&patch.uuid), + common.dry_run, + force, + ) + .await, + ) +} + +#[cfg(not(feature = "golang"))] +async fn try_local_go_apply( + _purl: &str, + _pkg_path: &Path, + _patch: &PatchRecord, + _sources: &PatchSources<'_>, + _common: &GlobalArgs, + _force: bool, +) -> Option { + None +} + +/// After the apply loop: prune local-go redirects whose patches were dropped +/// from the manifest. No-op unless local go is in scope. +#[cfg(feature = "golang")] +async fn reconcile_local_go(common: &GlobalArgs, target_manifest_purls: &HashSet) { + if !go_in_local_scope(common) { + return; + } + let desired: HashSet = target_manifest_purls + .iter() + .filter(|p| Ecosystem::from_purl(p) == Some(Ecosystem::Golang)) + .cloned() + .collect(); + let removed = reconcile_go_redirects(&common.cwd, &desired, common.dry_run).await; + if !removed.is_empty() && !common.silent && !common.json { + let verb = if common.dry_run { "Would remove" } else { "Removed" }; + println!("{verb} {} stale go patch redirect(s):", removed.len()); + for purl in &removed { + println!(" {purl}"); + } + } +} +#[cfg(not(feature = "golang"))] +async fn reconcile_local_go(_common: &GlobalArgs, _target_manifest_purls: &HashSet) {} + +/// Read-only verification of committed local redirects (cargo + go) for CI / +/// GitHub-App auditing and the build-time guard probe. Lock-free, crawl-free, +/// offline-safe. Exits 0 when in sync, 1 on drift. Verifies every redirect +/// ecosystem that is both compiled in and in `--ecosystems` scope. +#[cfg(any(feature = "cargo", feature = "golang"))] +async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { let manifest = match read_manifest(manifest_path).await { Ok(Some(m)) => m, // The caller already confirmed the manifest file exists. `Ok(None)` means @@ -271,7 +377,7 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { Err(e) => { if !args.common.silent && !args.common.json { eprintln!( - "Cargo patch redirect check could not read the manifest ({e}); \ + "Patch redirect check could not read the manifest ({e}); \ treating as drift (fail-closed)." ); } @@ -279,74 +385,108 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { } }; - let desired: HashSet = if cargo_in_local_scope(&args.common) { - manifest - .patches - .keys() - .filter(|p| Ecosystem::from_purl(p) == Some(Ecosystem::Cargo)) - .cloned() - .collect() - } else { - HashSet::new() - }; - - match verify_cargo_redirect_state(&args.common.cwd, &manifest, &desired).await { - Ok(()) => { - if args.common.json { - println!("{}", Envelope::new(Command::Apply).to_pretty_json()); - } else if !args.common.silent { - println!( - "Cargo patch redirects are in sync ({} checked).", - desired.len() - ); + // (purl_or_name, reason_code, detail) for each drift across ecosystems. + let mut drifts: Vec<(String, &'static str, String)> = Vec::new(); + let mut checked: usize = 0; + + #[cfg(feature = "cargo")] + { + use socket_patch_core::patch::cargo_redirect::Drift as CargoDrift; + if cargo_in_local_scope(&args.common) { + let desired: HashSet = manifest + .patches + .keys() + .filter(|p| Ecosystem::from_purl(p) == Some(Ecosystem::Cargo)) + .cloned() + .collect(); + checked += desired.len(); + if let Err(ds) = verify_cargo_redirect_state(&args.common.cwd, &manifest, &desired).await + { + for d in &ds { + let id = match d { + CargoDrift::MissingCopy { purl } + | CargoDrift::StaleCopy { purl, .. } + | CargoDrift::MissingEntry { purl } + | CargoDrift::WrongEntryPath { purl, .. } + | CargoDrift::ResolvedVersionMismatch { purl, .. } => purl.clone(), + CargoDrift::OrphanEntry { name } => name.clone(), + }; + drifts.push((id, "cargo_redirect_drift", d.to_string())); + } } - 0 } - Err(drifts) => { - if args.common.json { - let mut env = Envelope::new(Command::Apply); - for d in &drifts { - let purl = match d { - Drift::MissingCopy { purl } - | Drift::StaleCopy { purl, .. } - | Drift::MissingEntry { purl } - | Drift::WrongEntryPath { purl, .. } - | Drift::ResolvedVersionMismatch { purl, .. } => purl.clone(), - Drift::OrphanEntry { name } => name.clone(), + } + + #[cfg(feature = "golang")] + { + use socket_patch_core::patch::go_redirect::Drift as GoDrift; + if go_in_local_scope(&args.common) { + let desired: HashSet = manifest + .patches + .keys() + .filter(|p| Ecosystem::from_purl(p) == Some(Ecosystem::Golang)) + .cloned() + .collect(); + checked += desired.len(); + if let Err(ds) = verify_go_redirect_state(&args.common.cwd, &manifest, &desired).await { + for d in &ds { + let id = match d { + GoDrift::MissingCopy { purl } + | GoDrift::StaleCopy { purl, .. } + | GoDrift::MissingReplace { purl } + | GoDrift::WrongReplacePath { purl, .. } + | GoDrift::ResolvedVersionMismatch { purl, .. } => purl.clone(), + GoDrift::OrphanReplace { module } => module.clone(), }; - env.record( - PatchEvent::new(PatchAction::Failed, purl) - .with_reason("cargo_redirect_drift", d.to_string()), - ); + drifts.push((id, "go_redirect_drift", d.to_string())); } - env.mark_partial_failure(); - println!("{}", env.to_pretty_json()); - } else if !args.common.silent { - eprintln!("Cargo patch redirects are OUT OF SYNC:"); - for d in &drifts { - eprintln!(" {d}"); - } - eprintln!("Run `socket-patch apply` to regenerate them."); } - 1 } } + + if drifts.is_empty() { + if args.common.json { + println!("{}", Envelope::new(Command::Apply).to_pretty_json()); + } else if !args.common.silent { + println!("Patch redirects are in sync ({checked} checked)."); + } + 0 + } else { + if args.common.json { + let mut env = Envelope::new(Command::Apply); + for (id, code, detail) in &drifts { + env.record( + PatchEvent::new(PatchAction::Failed, id.clone()) + .with_reason(*code, detail.clone()), + ); + } + env.mark_partial_failure(); + println!("{}", env.to_pretty_json()); + } else if !args.common.silent { + eprintln!("Patch redirects are OUT OF SYNC:"); + for (_, _, detail) in &drifts { + eprintln!(" {detail}"); + } + eprintln!("Run `socket-patch apply` to regenerate them."); + } + 1 + } } -#[cfg(not(feature = "cargo"))] +#[cfg(not(any(feature = "cargo", feature = "golang")))] async fn run_check(args: &ApplyArgs, _manifest_path: &Path) -> i32 { - // Fail-closed: `--check` is the cargo patch-redirect audit. A socket-patch - // built WITHOUT the `cargo` feature cannot verify those redirects, so it must - // NOT report "in sync" (exit 0). The build-time guard probes whatever + // Fail-closed: `--check` is the redirect audit. A socket-patch built WITHOUT + // any redirect ecosystem (cargo/golang) cannot verify those redirects, so it + // must NOT report "in sync" (exit 0). The build-time guard probes whatever // `socket-patch` is on the build machine's PATH; if a feature-off binary // answered 0 here, the guard would silently proceed against possibly // stale/unpatched copies — defeating its whole purpose. Exit non-zero with a // clear reason so the guard fails the build instead. if !args.common.silent && !args.common.json { eprintln!( - "socket-patch: this build has no cargo support, so it cannot verify cargo \ + "socket-patch: this build has no cargo/golang support, so it cannot verify \ patch redirects (`--check`). Install a socket-patch built with the `cargo` \ - feature, or point SOCKET_PATCH_BIN at one." + and/or `golang` feature, or point SOCKET_PATCH_BIN at one." ); } 2 @@ -954,6 +1094,7 @@ async fn apply_patches_inner( // now lists zero in-scope cargo patches (the all-removed case). No-op // unless local cargo is in scope. reconcile_local_cargo(&args.common, &target_manifest_purls).await; + reconcile_local_go(&args.common, &target_manifest_purls).await; let crawler_options = CrawlerOptions { cwd: args.common.cwd.clone(), @@ -1101,11 +1242,11 @@ async fn apply_patches_inner( packages_path: Some(&packages_path), diffs_path: Some(&diffs_path), }; - // Local cargo redirects to a project-local patched copy - // (`apply_cargo_redirect`); everything else — npm/pypi, and cargo - // under --global/--global-prefix — patches in place via - // `apply_package_patch`. In a build without the `cargo` feature - // `try_local_cargo_apply` is an inert `None`. + // Local cargo/go redirect to a project-local patched copy + // (`apply_cargo_redirect` / `apply_go_redirect`); everything else — + // npm/pypi, and cargo/go under --global/--global-prefix — patches in + // place via `apply_package_patch`. Without the respective feature the + // `try_local_*_apply` helpers are inert `None`s. let result = match try_local_cargo_apply( purl, pkg_path, @@ -1117,18 +1258,30 @@ async fn apply_patches_inner( .await { Some(r) => r, - None => { - apply_package_patch( - purl, - pkg_path, - &patch.files, - &sources, - Some(&patch.uuid), - args.common.dry_run, - args.force, - ) - .await - } + None => match try_local_go_apply( + purl, + pkg_path, + patch, + &sources, + &args.common, + args.force, + ) + .await + { + Some(r) => r, + None => { + apply_package_patch( + purl, + pkg_path, + &patch.files, + &sources, + Some(&patch.uuid), + args.common.dry_run, + args.force, + ) + .await + } + }, }; if !result.success { diff --git a/crates/socket-patch-cli/src/commands/setup.rs b/crates/socket-patch-cli/src/commands/setup.rs index 6b2bcd6..6824748 100644 --- a/crates/socket-patch-cli/src/commands/setup.rs +++ b/crates/socket-patch-cli/src/commands/setup.rs @@ -4,6 +4,8 @@ use socket_patch_core::cargo_setup::{ add_guard_dep, discover_cargo_project, is_guard_dep_present, remove_guard_dep, CargoEditResult, CargoSetupStatus, }; +#[cfg(feature = "golang")] +use socket_patch_core::go_setup::{self, GoSetupStatus}; use socket_patch_core::crawlers::python_crawler::is_python_project; use socket_patch_core::package_json::detect::{is_setup_configured_str, PackageManager}; use socket_patch_core::package_json::find::{ @@ -34,7 +36,13 @@ fn manager_name(pm: PackageManager) -> &'static str { /// Compose the `+`-joined telemetry manager tag across the ecosystems in scope /// (e.g. `npm+pypi+cargo`), or `none`. -fn telemetry_manager_str(npm: bool, py: bool, cargo: bool, npm_pm: PackageManager) -> String { +fn telemetry_manager_str( + npm: bool, + py: bool, + cargo: bool, + go: bool, + npm_pm: PackageManager, +) -> String { let mut parts: Vec<&str> = Vec::new(); if npm { parts.push(manager_name(npm_pm)); @@ -45,6 +53,9 @@ fn telemetry_manager_str(npm: bool, py: bool, cargo: bool, npm_pm: PackageManage if cargo { parts.push("cargo"); } + if go { + parts.push("golang"); + } if parts.is_empty() { "none".to_string() } else { @@ -280,7 +291,7 @@ fn update_status_str(s: &UpdateStatus) -> &'static str { /// the `cargo` feature is off), so the shared reporting code never has to name /// the cargo-only types. #[derive(Default)] -struct CargoOutcome { +struct SetupOutcome { /// A cargo project was discovered (gates the `no_files` decision). present: bool, /// Items changed (guard dep added/removed + `[env]` written/removed). @@ -296,15 +307,15 @@ struct CargoOutcome { /// Build the cargo outcome for a setup (`remove=false`) or remove /// (`remove=true`) run at the given `dry_run` setting. #[cfg(feature = "cargo")] -async fn build_cargo_outcome(common: &GlobalArgs, remove: bool, dry_run: bool) -> CargoOutcome { +async fn build_cargo_outcome(common: &GlobalArgs, remove: bool, dry_run: bool) -> SetupOutcome { use socket_patch_core::patch::cargo_config; let project = match discover_cargo_project(&common.cwd).await { Some(p) => p, - None => return CargoOutcome::default(), + None => return SetupOutcome::default(), }; - let mut out = CargoOutcome { + let mut out = SetupOutcome { present: true, ..Default::default() }; @@ -365,8 +376,141 @@ async fn build_cargo_outcome(common: &GlobalArgs, remove: bool, dry_run: bool) - } #[cfg(not(feature = "cargo"))] -async fn build_cargo_outcome(_common: &GlobalArgs, _remove: bool, _dry_run: bool) -> CargoOutcome { - CargoOutcome::default() +async fn build_cargo_outcome(_common: &GlobalArgs, _remove: bool, _dry_run: bool) -> SetupOutcome { + SetupOutcome::default() +} + +// ───────────────────────────────────────────────────────────────────────── +// Go (project-local go.mod `replace`-redirect guard) helpers +// ───────────────────────────────────────────────────────────────────────── + +/// Build the Go branch's contribution to a setup/remove run: write (or remove) +/// the `internal/socketpatchguard` package + the per-`main` blank-import files. +/// A no-op `Default` when the `golang` feature is off. +#[cfg(feature = "golang")] +async fn build_go_outcome(common: &GlobalArgs, remove: bool, dry_run: bool) -> SetupOutcome { + let module = match go_setup::discover_go_module(&common.cwd).await { + Some(m) => m, + None => return SetupOutcome::default(), + }; + + let mut out = SetupOutcome { + present: true, + ..Default::default() + }; + + let mut results: Vec = Vec::new(); + if remove { + results.push(go_setup::remove_guard(&module.root, dry_run).await); + results.extend(go_setup::remove_main_imports(&module.root, dry_run).await); + } else { + results.push(go_setup::add_guard(&module.root, dry_run).await); + results.extend( + go_setup::add_main_imports(&module.root, &module.module_path, dry_run).await, + ); + } + + let mut added_paths: Vec = Vec::new(); + for r in &results { + match r.status { + GoSetupStatus::Updated => { + out.changed += 1; + added_paths.push(r.path.clone()); + } + GoSetupStatus::AlreadyConfigured => out.already += 1, + GoSetupStatus::Error => out.errors += 1, + } + out.json_files.push(serde_json::json!({ + "kind": r.kind, + "path": r.path, + "status": go_status_str(&r.status, remove), + "error": r.error, + })); + } + + if !added_paths.is_empty() { + let header = if remove { + "Go: remove socket-patch guard wiring from:" + } else { + "Go: add socket-patch guard wiring to:" + }; + out.preview.push(header.to_string()); + for p in &added_paths { + out.preview.push(format!(" + {}", pathdiff(p, &common.cwd))); + } + } + + out +} + +#[cfg(not(feature = "golang"))] +async fn build_go_outcome(_common: &GlobalArgs, _remove: bool, _dry_run: bool) -> SetupOutcome { + SetupOutcome::default() +} + +#[cfg(feature = "golang")] +fn go_status_str(s: &GoSetupStatus, for_remove: bool) -> &'static str { + match (s, for_remove) { + (GoSetupStatus::Updated, false) => "updated", + (GoSetupStatus::Updated, true) => "removed", + (GoSetupStatus::AlreadyConfigured, false) => "already_configured", + (GoSetupStatus::AlreadyConfigured, true) => "not_configured", + (GoSetupStatus::Error, _) => "error", + } +} + +/// Materialise the Go `replace` redirects right after wiring the guard (the +/// "automatic" step) so the first `go test`/`go run` finds patches already in +/// sync instead of self-healing on first run. Best-effort and offline: runs the +/// same `apply` the guard would, capturing output so it never corrupts setup's +/// (possibly JSON) stdout. A non-zero exit becomes a warning — the guard heals +/// it on first run. No-op without the `golang` feature. +#[cfg(feature = "golang")] +async fn finalize_go(common: &GlobalArgs) -> Vec { + let exe = match std::env::current_exe() { + Ok(e) => e, + Err(e) => { + return vec![format!( + "could not locate socket-patch to materialize go patches ({e}); \ + run `socket-patch apply --ecosystems golang`" + )] + } + }; + let root = common.cwd.display().to_string(); + match tokio::process::Command::new(&exe) + .args(["apply", "--offline", "--ecosystems", "golang", "--cwd", &root, "--silent"]) + .output() + .await + { + Ok(o) if o.status.success() => Vec::new(), + Ok(o) => vec![format!( + "materializing go patches exited with {}; the guard will heal on first `go test`/run", + o.status + .code() + .map(|c| c.to_string()) + .unwrap_or_else(|| "signal".into()) + )], + Err(e) => vec![format!( + "could not run apply to materialize go patches ({e}); the guard will heal on first run" + )], + } +} + +#[cfg(not(feature = "golang"))] +async fn finalize_go(_common: &GlobalArgs) -> Vec { + Vec::new() +} + +/// Combine two ecosystem outcomes (cargo + go) into one for the shared +/// preview/envelope printers, which take a single [`SetupOutcome`]. +fn merge_outcomes(mut a: SetupOutcome, b: SetupOutcome) -> SetupOutcome { + a.present |= b.present; + a.changed += b.changed; + a.already += b.already; + a.errors += b.errors; + a.json_files.extend(b.json_files); + a.preview.extend(b.preview); + a } /// The guard version string `setup` writes — major.minor of this CLI, so the @@ -461,6 +605,49 @@ async fn append_cargo_check_entries( false } +/// Append Go check entries (the guard package + one per `package main` blank +/// import) to the shared `run_check` entries list. Returns whether a Go module +/// was found. Checks the SETUP wiring only — redirect sync is `apply --check`. +#[cfg(feature = "golang")] +async fn append_go_check_entries( + common: &GlobalArgs, + entries: &mut Vec<(&'static str, String, CheckState, Option)>, +) -> bool { + let module = match go_setup::discover_go_module(&common.cwd).await { + Some(m) => m, + None => return false, + }; + let guard_state = if go_setup::guard_files_present(&module.root).await { + CheckState::Configured + } else { + CheckState::NeedsConfiguration + }; + entries.push(( + "go_guard", + module.root.join(go_setup::GUARD_DIR).display().to_string(), + guard_state, + None, + )); + for dir in go_setup::find_main_package_dirs(&module.root).await { + let path = go_setup::import_file_path(&dir); + let state = if tokio::fs::metadata(&path).await.is_ok() { + CheckState::Configured + } else { + CheckState::NeedsConfiguration + }; + entries.push(("go_import", path.display().to_string(), state, None)); + } + true +} + +#[cfg(not(feature = "golang"))] +async fn append_go_check_entries( + _common: &GlobalArgs, + _entries: &mut Vec<(&'static str, String, CheckState, Option)>, +) -> bool { + false +} + // ───────────────────────────────────────────────────────────────────────── // check // ───────────────────────────────────────────────────────────────────────── @@ -478,7 +665,7 @@ enum CheckState { /// configured and none failed to parse. async fn run_check(args: &SetupArgs) -> i32 { if !args.common.json { - println!("Searching for package.json / Python / Cargo manifests..."); + println!("Searching for package.json / Python / Cargo / Go manifests..."); } let npm_files = discover(args).await; @@ -526,6 +713,7 @@ async fn run_check(args: &SetupArgs) -> i32 { } append_cargo_check_entries(&args.common, &mut entries).await; + append_go_check_entries(&args.common, &mut entries).await; if entries.is_empty() { return report_no_files(args, "no_files"); @@ -613,15 +801,19 @@ fn render_removed(new: &Option) -> String { async fn run_remove(args: &SetupArgs) -> i32 { let common = &args.common; if !common.json { - println!("Searching for package.json / Python / Cargo manifests..."); + println!("Searching for package.json / Python / Cargo / Go manifests..."); } let npm_files = discover(args).await; let py_plan = plan_python(common).await; let cargo_preview = build_cargo_outcome(common, true, true).await; - if npm_files.is_empty() && py_plan.is_none() && !cargo_preview.present { + let go_preview = build_go_outcome(common, true, true).await; + if npm_files.is_empty() && py_plan.is_none() && !cargo_preview.present && !go_preview.present { return report_no_files(args, "no_files"); } + let cargo_present = cargo_preview.present; + let go_present = go_preview.present; + let cargo_preview = merge_outcomes(cargo_preview, go_preview); // Preview (dry_run=true never writes). let mut npm_preview = Vec::new(); @@ -703,8 +895,9 @@ async fn run_remove(args: &SetupArgs) -> i32 { py_results = edit_python_manifests(plan, true, false).await; warnings = finalize_python(plan, &py_results, &common.cwd).await; } - // Real cargo removal (guard dep + [env] root). - let cargo_results = build_cargo_outcome(common, true, false).await; + // Real cargo + go removal (guard dep/[env] root; go guard package + imports). + let cargo_results = + merge_outcomes(build_cargo_outcome(common, true, false).await, build_go_outcome(common, true, false).await); let errs = npm_results.iter().filter(|r| r.status == RemoveStatus::Error).count() + py_results.iter().filter(|r| r.status == PthStatus::Error).count() @@ -733,12 +926,19 @@ async fn run_remove(args: &SetupArgs) -> i32 { if py_plan.is_some() { println!("\nAlso run `pip uninstall socket-patch-hook` to remove the installed .pth."); } - if cargo_results.present { + if cargo_present { println!( "\nNote: existing patched-crate copies under .socket/cargo-patches/ and any \ managed [patch.crates-io] entries are removed on `socket-patch rollback`." ); } + if go_present { + println!( + "\nNote: the Go guard wiring was removed; existing patched-module copies under \ + .socket/go-patches/ and managed go.mod `replace` directives are removed on \ + `socket-patch rollback`." + ); + } } if errs > 0 { @@ -751,7 +951,7 @@ async fn run_remove(args: &SetupArgs) -> i32 { fn print_remove_preview( npm: &[RemoveResult], py: &[PthEditResult], - cargo: &CargoOutcome, + cargo: &SetupOutcome, common: &GlobalArgs, ) { let to_remove: Vec<_> = npm.iter().filter(|r| r.status == RemoveStatus::Removed).collect(); @@ -788,7 +988,7 @@ fn print_remove_envelope( status: &str, npm: &[RemoveResult], py: &[PthEditResult], - cargo: &CargoOutcome, + cargo: &SetupOutcome, warnings: &[String], ) { let removed = npm.iter().filter(|r| r.status == RemoveStatus::Removed).count() @@ -861,10 +1061,11 @@ async fn run_setup(args: &SetupArgs) -> i32 { let npm_files = discover(args).await; let py_plan = plan_python(common).await; - // Cargo preview (dry-run); `.present` also tells us a cargo project exists. + // Cargo + Go previews (dry-run); `.present` also tells us each project exists. let cargo_preview = build_cargo_outcome(common, false, true).await; + let go_preview = build_go_outcome(common, false, true).await; - if npm_files.is_empty() && py_plan.is_none() && !cargo_preview.present { + if npm_files.is_empty() && py_plan.is_none() && !cargo_preview.present && !go_preview.present { if common.json { println!( "{}", @@ -878,17 +1079,22 @@ async fn run_setup(args: &SetupArgs) -> i32 { .unwrap() ); } else { - println!("No package.json, Python, or Cargo project found"); + println!("No package.json, Python, Cargo, or Go project found"); } return 0; } + let cargo_present = cargo_preview.present; + let go_present = go_preview.present; + let cargo_preview = merge_outcomes(cargo_preview, go_preview); + let npm_pm = detect_package_manager(&common.cwd).await; let telemetry_manager = telemetry_manager_str( !npm_files.is_empty(), py_plan.is_some(), - cargo_preview.present, + cargo_present, + go_present, npm_pm, ); track_patch_setup( @@ -986,8 +1192,16 @@ async fn run_setup(args: &SetupArgs) -> i32 { py_results = edit_python_manifests(plan, false, false).await; warnings = finalize_python(plan, &py_results, &common.cwd).await; } - // Real cargo edit (guard dep + [env] root). - let cargo_results = build_cargo_outcome(common, false, false).await; + // Real cargo + go edits (cargo guard dep/[env] root; go guard package + + // per-main blank imports). + let cargo_results = + merge_outcomes(build_cargo_outcome(common, false, false).await, build_go_outcome(common, false, false).await); + + // Materialise the go.mod `replace` redirects now so the first `go test`/run + // is already in sync (the "automatic" step). Best-effort → warnings only. + if go_present { + warnings.extend(finalize_go(common).await); + } let errors = npm_results.iter().filter(|r| r.status == UpdateStatus::Error).count() + py_results.iter().filter(|r| r.status == PthStatus::Error).count() @@ -1022,12 +1236,21 @@ async fn run_setup(args: &SetupArgs) -> i32 { plan.pm.as_str() ); } - if cargo_results.present { + if cargo_present { println!( "\nCommit Cargo.toml (socket-patch-guard), .cargo/config.toml, and your \ .socket/ patches so the guard re-applies cargo patches in CI." ); } + if go_present { + println!( + "\nCommit go.mod (the `replace` directives), internal/socketpatchguard/, the \ + generated socket_patch_guard_import.go files, .socket/go-patches/, and your \ + .socket/ patches. Enforcement: `go test ./...` gates at CI time (the guard \ + reads the patch state in-process, so the test cache re-runs it on any drift), \ + and the init() guard gates every `go run`/binary launch." + ); + } } if errors > 0 { @@ -1040,7 +1263,7 @@ async fn run_setup(args: &SetupArgs) -> i32 { fn print_setup_preview( npm: &[UpdateResult], py: &[PthEditResult], - cargo: &CargoOutcome, + cargo: &SetupOutcome, common: &GlobalArgs, ) { let npm_changes: Vec<_> = npm.iter().filter(|r| r.status == UpdateStatus::Updated).collect(); @@ -1098,7 +1321,7 @@ fn print_setup_envelope( status: &str, npm: &[UpdateResult], py: &[PthEditResult], - cargo: &CargoOutcome, + cargo: &SetupOutcome, npm_pm: PackageManager, py_plan: Option<&PythonPlan>, warnings: &[String], diff --git a/crates/socket-patch-cli/tests/e2e_golang_build.rs b/crates/socket-patch-cli/tests/e2e_golang_build.rs new file mode 100644 index 0000000..8d78f40 --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_golang_build.rs @@ -0,0 +1,293 @@ +#![cfg(all(unix, feature = "golang"))] +//! Full go-toolchain capstone for the Go `replace`-redirect guard: proves the +//! patched bytes are actually LINKED by `go build`, and that the committed guard +//! enforces drift at runtime (`init()`) and self-heals. +//! +//! Hermetic + offline: a tiny upstream module is served from a local file +//! GOPROXY into a temp GOMODCACHE, so no network and no pre-cached module are +//! needed. Skips when `go`/`zip` aren't installed. + +use std::path::Path; +use std::process::Command; + +#[path = "common/mod.rs"] +mod common; + +use common::{binary, git_sha256, has_command, run_with_env}; + +const UMOD: &str = "example.com/upstream"; +const UVER: &str = "v1.0.0"; +const UPURL: &str = "pkg:golang/example.com/upstream@v1.0.0"; +const PRISTINE_LIB: &str = "package upstream\n\nfunc Greeting() string { return \"PRISTINE\" }\n"; +const PATCHED_LIB: &str = "package upstream\n\nfunc Greeting() string { return \"PATCHED\" }\n"; + +/// Env for every `go` invocation: hermetic file-proxy + temp cache, sums off. +fn go_env<'a>(modcache: &'a str, proxy_url: &'a str) -> Vec<(&'a str, &'a str)> { + vec![ + ("GOMODCACHE", modcache), + ("GOPROXY", proxy_url), + ("GOSUMDB", "off"), + ("GOFLAGS", "-mod=mod"), + ] +} + +fn go(dir: &Path, args: &[&str], env: &[(&str, &str)]) -> std::process::Output { + let mut cmd = Command::new("go"); + cmd.args(args).current_dir(dir); + for (k, v) in env { + cmd.env(k, v); + } + cmd.output().expect("run go") +} + +/// Build the upstream module into a file-proxy and `go mod download` it into a +/// temp GOMODCACHE. Returns (consumer_dir, modcache, proxy_url). +fn stage(tmp: &Path) -> (std::path::PathBuf, std::path::PathBuf, String) { + // Staging dir holding `@/` for zipping. + let stage = tmp.join("stage").join(format!("{UMOD}@{UVER}")); + std::fs::create_dir_all(&stage).unwrap(); + std::fs::write(stage.join("go.mod"), format!("module {UMOD}\n\ngo 1.21\n")).unwrap(); + std::fs::write(stage.join("lib.go"), PRISTINE_LIB).unwrap(); + + // File-proxy layout: proxy//@v/.{info,mod,zip}. + let pxv = tmp.join("proxy").join(UMOD).join("@v"); + std::fs::create_dir_all(&pxv).unwrap(); + std::fs::write(pxv.join(format!("{UVER}.info")), format!("{{\"Version\":\"{UVER}\"}}")).unwrap(); + std::fs::write(pxv.join(format!("{UVER}.mod")), format!("module {UMOD}\n\ngo 1.21\n")).unwrap(); + let zip_out = pxv.join(format!("{UVER}.zip")); + let zip_status = Command::new("zip") + .args(["-q", "-r", zip_out.to_str().unwrap(), &format!("{UMOD}@{UVER}")]) + .current_dir(tmp.join("stage")) + .status() + .expect("run zip"); + assert!(zip_status.success(), "zip failed"); + + let modcache = tmp.join("modcache"); + std::fs::create_dir_all(&modcache).unwrap(); + let proxy_url = format!("file://{}", tmp.join("proxy").display()); + + // Consumer module that calls the patched symbol. + let consumer = tmp.join("consumer"); + std::fs::create_dir_all(&consumer).unwrap(); + std::fs::write( + consumer.join("go.mod"), + format!("module example.com/consumer\n\ngo 1.21\n\nrequire {UMOD} {UVER}\n"), + ) + .unwrap(); + std::fs::write( + consumer.join("main.go"), + format!( + "package main\n\nimport (\n\t\"fmt\"\n\t\"{UMOD}\"\n)\n\nfunc main() {{ fmt.Println(\"OUT:\", upstream.Greeting()) }}\n" + ), + ) + .unwrap(); + + let env = go_env(modcache.to_str().unwrap(), &proxy_url); + let dl = go(&consumer, &["mod", "download", &format!("{UMOD}@{UVER}")], &env); + assert!(dl.status.success(), "go mod download failed: {}", String::from_utf8_lossy(&dl.stderr)); + + (consumer, modcache, proxy_url) +} + +/// Hand-build the patch manifest + blob (apply will read these offline). +fn write_patch(consumer: &Path) { + let socket = consumer.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let before = git_sha256(PRISTINE_LIB.as_bytes()); + let after = git_sha256(PATCHED_LIB.as_bytes()); + let manifest = format!( + "{{\"patches\":{{\"{UPURL}\":{{\"uuid\":\"u\",\"exportedAt\":\"t\",\"files\":{{\"lib.go\":{{\"beforeHash\":\"{before}\",\"afterHash\":\"{after}\"}}}},\"vulnerabilities\":{{}},\"description\":\"\",\"license\":\"\",\"tier\":\"\"}}}}}}" + ); + std::fs::write(socket.join("manifest.json"), manifest).unwrap(); + std::fs::write(socket.join("blobs").join(&after), PATCHED_LIB).unwrap(); +} + +fn chmod_writable(dir: &Path) { + use std::os::unix::fs::PermissionsExt; + for e in walkdir(dir) { + let _ = std::fs::set_permissions(&e, std::fs::Permissions::from_mode(0o755)); + } +} +fn walkdir(dir: &Path) -> Vec { + let mut out = vec![dir.to_path_buf()]; + if let Ok(rd) = std::fs::read_dir(dir) { + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + out.extend(walkdir(&p)); + } else { + out.push(p); + } + } + } + out +} + +#[test] +fn go_build_links_patch_and_guard_enforces_drift() { + if !has_command("go") || !has_command("zip") { + eprintln!("skipping e2e_golang_build: `go`/`zip` not installed"); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let (consumer, modcache, proxy_url) = stage(tmp.path()); + let cs = consumer.to_str().unwrap(); + let mc = modcache.to_str().unwrap(); + let goenv = go_env(mc, &proxy_url); + let bin = binary(); + let bin_s = bin.to_str().unwrap(); + + // Baseline build links PRISTINE. + let base = go(&consumer, &["run", "."], &goenv); + assert!(base.status.success(), "baseline run failed: {}", String::from_utf8_lossy(&base.stderr)); + assert!(String::from_utf8_lossy(&base.stdout).contains("OUT: PRISTINE")); + + // Patch + apply (socket-patch reads only the cache; no `go`). + write_patch(&consumer); + let (code, so, se) = run_with_env( + &consumer, + &["apply", "--offline", "--ecosystems", "golang", "--cwd", cs], + &[("GOMODCACHE", mc)], + ); + assert_eq!(code, 0, "apply failed.\n{so}\n{se}"); + + // The patched bytes are now LINKED by go build. + let patched = go(&consumer, &["run", "."], &goenv); + assert!(patched.status.success(), "patched run failed: {}", String::from_utf8_lossy(&patched.stderr)); + assert!( + String::from_utf8_lossy(&patched.stdout).contains("OUT: PATCHED"), + "patched symbol not linked: {}", + String::from_utf8_lossy(&patched.stdout) + ); + + // ── setup wires the guard; go test (cold) passes in sync ───────── + let (code, so, se) = run_with_env( + &consumer, + &["setup", "--cwd", cs, "--yes"], + &[("GOMODCACHE", mc), ("SOCKET_PATCH_BIN", bin_s)], + ); + assert_eq!(code, 0, "setup failed.\n{so}\n{se}"); + assert!(consumer.join("internal/socketpatchguard/guard.go").exists()); + assert!(consumer.join("socket_patch_guard_import.go").exists()); + + let test_env: Vec<(&str, &str)> = goenv.iter().cloned().chain([("SOCKET_PATCH_BIN", bin_s)]).collect(); + let t = go(&consumer, &["test", "-count=1", "./..."], &test_env); + assert!( + t.status.success(), + "guard test should pass in sync:\n{}\n{}", + String::from_utf8_lossy(&t.stdout), + String::from_utf8_lossy(&t.stderr) + ); + + // ── warm-cache drift: `go test` (NO -count=1) must NOT serve a stale PASS ── + // Prime the cache with a passing run, then corrupt the copy and run again + // WITHOUT -count=1. The guard reads the patch state in-process, so the test + // cache must re-run the gate and FAIL (this is the test-cache-masking fix). + let warm = consumer.join(".socket/go-patches/example.com").join(format!("upstream@{UVER}")).join("lib.go"); + let _ = go(&consumer, &["test", "./internal/socketpatchguard/"], &test_env); // prime cache (no -count=1) + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&warm, std::fs::Permissions::from_mode(0o644)); + } + std::fs::write(&warm, "package upstream\n\nfunc Greeting() string { return \"WARM-DRIFT\" }\n").unwrap(); + let warm_test = go(&consumer, &["test", "./internal/socketpatchguard/"], &test_env); // NO -count=1 + assert!( + !warm_test.status.success(), + "WARM-CACHE go test must catch drift (not serve a cached PASS):\n{}\n{}", + String::from_utf8_lossy(&warm_test.stdout), + String::from_utf8_lossy(&warm_test.stderr) + ); + // (heal happened during that run; restore is verified by the -count=1 block below) + + // ── drift: corrupt the committed copy → guard test fails closed ── + let copy_file = consumer + .join(".socket/go-patches/example.com") + .join(format!("upstream@{UVER}")) + .join("lib.go"); + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(©_file, std::fs::Permissions::from_mode(0o644)); + } + std::fs::write(©_file, "package upstream\n\nfunc Greeting() string { return \"DRIFT\" }\n").unwrap(); + + let t2 = go(&consumer, &["test", "-count=1", "./internal/socketpatchguard/"], &test_env); + assert!( + !t2.status.success(), + "guard test must FAIL on drift (it self-heals + fails):\n{}\n{}", + String::from_utf8_lossy(&t2.stdout), + String::from_utf8_lossy(&t2.stderr) + ); + + // The heal restored the patched bytes; a re-run passes. + let t3 = go(&consumer, &["test", "-count=1", "./internal/socketpatchguard/"], &test_env); + assert!( + t3.status.success(), + "guard test should pass after self-heal:\n{}\n{}", + String::from_utf8_lossy(&t3.stdout), + String::from_utf8_lossy(&t3.stderr) + ); + + // Best-effort: relax perms so the temp cache cleans up. + chmod_writable(tmp.path()); +} + +#[test] +fn guard_is_noop_outside_module_tree() { + if !has_command("go") || !has_command("zip") { + eprintln!("skipping e2e_golang_build: `go`/`zip` not installed"); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let (consumer, modcache, proxy_url) = stage(tmp.path()); + let cs = consumer.to_str().unwrap(); + let mc = modcache.to_str().unwrap(); + let goenv = go_env(mc, &proxy_url); + let bin = binary(); + + // Patch + apply + wire the guard, then build a real binary. + write_patch(&consumer); + assert_eq!( + run_with_env(&consumer, &["apply", "--offline", "--ecosystems", "golang", "--cwd", cs], &[("GOMODCACHE", mc)]).0, + 0 + ); + run_with_env( + &consumer, + &["setup", "--cwd", cs, "--yes"], + &[("GOMODCACHE", mc), ("SOCKET_PATCH_BIN", bin.to_str().unwrap())], + ); + let build = go(&consumer, &["build", "-o", "app", "."], &goenv); + assert!(build.status.success(), "go build failed: {}", String::from_utf8_lossy(&build.stderr)); + + // Copy the binary OUT of the module tree (simulating a shipped binary with + // no .socket/ alongside it) and run it from a dir with no go.mod ancestor. + let outside = tmp.path().join("shipped"); + std::fs::create_dir_all(&outside).unwrap(); + let app = outside.join("app"); + std::fs::copy(consumer.join("app"), &app).unwrap(); + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&app, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + // The guard's init() must be a SILENT no-op here: the binary runs normally + // even though socket-patch isn't on PATH and there is no .socket/manifest. + let out = Command::new(&app) + .current_dir(&outside) + .env_remove("SOCKET_PATCH_BIN") + .env("PATH", "/usr/bin:/bin") // ensure no socket-patch on PATH + .output() + .expect("run shipped binary"); + assert!( + out.status.success(), + "shipped binary outside the module tree must NOT be bricked by the guard:\nstdout:{}\nstderr:{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + assert!( + String::from_utf8_lossy(&out.stdout).contains("OUT: PATCHED"), + "the binary should still run its (patched) code: {}", + String::from_utf8_lossy(&out.stdout) + ); + + chmod_writable(tmp.path()); +} diff --git a/crates/socket-patch-cli/tests/e2e_golang_redirect.rs b/crates/socket-patch-cli/tests/e2e_golang_redirect.rs new file mode 100644 index 0000000..393dd97 --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_golang_redirect.rs @@ -0,0 +1,216 @@ +#![cfg(all(unix, feature = "golang"))] +//! End-to-end for the Go `replace`-redirect backend, driven through the CLI +//! binary. No `go` toolchain needed: `apply`/`--check` only read a pristine +//! extracted module-cache dir and write project-local copies + a `go.mod` +//! `replace` — they never invoke `go`. A fake `GOMODCACHE` supplies the +//! pristine source so the whole flow runs offline and hermetically. +//! +//! Covers: apply materialises the copy + `replace` (cache left pristine); +//! `apply --check` is in sync; and each drift kind (`MissingReplace`, +//! `StaleCopy`, `ResolvedVersionMismatch`) is detected and self-healed. + +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +#[path = "common/mod.rs"] +mod common; + +use common::{git_sha256, git_sha256_file, run_with_env, write_blob, write_minimal_manifest, PatchEntry}; + +const MODULE: &str = "github.com/foo/bar"; +const VERSION: &str = "v1.4.2"; +const PURL: &str = "pkg:golang/github.com/foo/bar@v1.4.2"; +const PRISTINE: &[u8] = b"package bar\n\nfunc Hello() string { return \"hi\" }\n"; +const PATCHED: &[u8] = b"package bar\n\nfunc Hello() string { return \"PATCHED\" }\n"; + +const COPY_REL: &str = ".socket/go-patches/github.com/foo/bar@v1.4.2"; +const REPLACE_LINE: &str = + "replace github.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2"; + +/// Stage a fake extracted module-cache dir + a consumer go.mod + the synthetic +/// patch manifest/blob. Returns (root, gomodcache, cache_src_file). +fn stage(root: &Path) -> (std::path::PathBuf, std::path::PathBuf) { + // Fake GOMODCACHE with the pristine extracted module. + let gomodcache = root.join("modcache"); + let cache_dir = gomodcache.join(format!("{MODULE}@{VERSION}")); + std::fs::create_dir_all(&cache_dir).unwrap(); + std::fs::write(cache_dir.join("bar.go"), PRISTINE).unwrap(); + std::fs::write(cache_dir.join("go.mod"), "module github.com/foo/bar\n\ngo 1.21\n").unwrap(); + + // Consumer module. + std::fs::write( + root.join("go.mod"), + format!("module example.com/app\n\ngo 1.21\n\nrequire {MODULE} {VERSION}\n"), + ) + .unwrap(); + + // Synthetic manifest + after-hash blob. + let socket = root.join(".socket"); + write_minimal_manifest( + &socket, + PURL, + "go-uuid-0001", + &[PatchEntry { + file_name: "bar.go", + before_hash: &git_sha256(PRISTINE), + after_hash: &git_sha256(PATCHED), + }], + ); + write_blob(&socket, &git_sha256(PATCHED), PATCHED); + + (gomodcache, cache_dir) +} + +fn apply(root: &Path, gomodcache: &Path) -> (i32, String, String) { + run_with_env( + root, + &["apply", "--offline", "--ecosystems", "golang", "--cwd", root.to_str().unwrap()], + &[("GOMODCACHE", gomodcache.to_str().unwrap())], + ) +} + +fn check(root: &Path, gomodcache: &Path) -> i32 { + run_with_env( + root, + &["apply", "--check", "--offline", "--ecosystems", "golang", "--cwd", root.to_str().unwrap()], + &[("GOMODCACHE", gomodcache.to_str().unwrap())], + ) + .0 +} + +#[test] +fn apply_materializes_redirect_and_check_passes() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let (gomodcache, cache_dir) = stage(root); + + let (code, stdout, stderr) = apply(root, &gomodcache); + assert_eq!(code, 0, "apply failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + + // go.mod gained the socket-owned replace. + let gomod = std::fs::read_to_string(root.join("go.mod")).unwrap(); + assert!(gomod.contains(REPLACE_LINE), "replace directive missing:\n{gomod}"); + + // The copy holds the patched bytes (== afterHash); the module cache is pristine. + let copy_file = root.join(COPY_REL).join("bar.go"); + assert_eq!(std::fs::read(©_file).unwrap(), PATCHED); + assert_eq!(git_sha256_file(©_file), git_sha256(PATCHED)); + assert_eq!( + std::fs::read(cache_dir.join("bar.go")).unwrap(), + PRISTINE, + "the module cache must be left pristine" + ); + // The copy carries a go.mod (valid replace target). + assert!(root.join(COPY_REL).join("go.mod").exists()); + + // In sync. + assert_eq!(check(root, &gomodcache), 0, "apply --check should be in sync"); + + // Idempotent re-apply: still in sync, replace unchanged. + assert_eq!(apply(root, &gomodcache).0, 0); + assert_eq!( + std::fs::read_to_string(root.join("go.mod")).unwrap().matches(REPLACE_LINE).count(), + 1, + "re-apply must not duplicate the replace" + ); +} + +#[test] +fn check_detects_missing_replace_and_heals() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let (gomodcache, _cache) = stage(root); + apply(root, &gomodcache); + assert_eq!(check(root, &gomodcache), 0); + + // Simulate a `go mod tidy`/`go mod vendor` that wiped our replace. + let gomod = std::fs::read_to_string(root.join("go.mod")).unwrap(); + let stripped: String = gomod.lines().filter(|l| !l.contains("go-patches")).collect::>().join("\n"); + std::fs::write(root.join("go.mod"), format!("{stripped}\n")).unwrap(); + + assert_eq!(check(root, &gomodcache), 1, "missing replace must be drift"); + + // Heal. + assert_eq!(apply(root, &gomodcache).0, 0); + assert_eq!(check(root, &gomodcache), 0, "re-apply heals the replace"); + assert!(std::fs::read_to_string(root.join("go.mod")).unwrap().contains(REPLACE_LINE)); +} + +#[test] +fn check_detects_stale_copy_and_heals() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let (gomodcache, _cache) = stage(root); + apply(root, &gomodcache); + + // Corrupt the committed copy. + let copy_file = root.join(COPY_REL).join("bar.go"); + let _ = std::fs::set_permissions(©_file, std::fs::Permissions::from_mode(0o644)); + std::fs::write(©_file, b"package bar\n// tampered\n").unwrap(); + + assert_eq!(check(root, &gomodcache), 1, "stale copy must be drift"); + + // Heal: re-apply restores the exact patched bytes. + assert_eq!(apply(root, &gomodcache).0, 0); + assert_eq!(std::fs::read(©_file).unwrap(), PATCHED); + assert_eq!(check(root, &gomodcache), 0); +} + +#[test] +fn check_detects_resolved_version_mismatch() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let (gomodcache, _cache) = stage(root); + apply(root, &gomodcache); + assert_eq!(check(root, &gomodcache), 0); + + // Bump the required version: the v1.4.2 replace is now unused, so the build + // would silently link the UNPATCHED v1.5.0 — must be flagged. + std::fs::write( + root.join("go.mod"), + format!( + "module example.com/app\n\ngo 1.21\n\nrequire {MODULE} v1.5.0\n\n{REPLACE_LINE}\n" + ), + ) + .unwrap(); + assert_eq!( + check(root, &gomodcache), + 1, + "a resolved-version mismatch must be detected as drift" + ); + + // apply must NOT silently paper over it: a version bump means the manifest + // is stale (it patches v1.4.2, the build wants v1.5.0). apply re-affirms the + // v1.4.2 redirect but cannot make the build use it, so check STAYS red until + // a human re-scans. (Fail-closed stays closed — never a false "in sync".) + assert_eq!(apply(root, &gomodcache).0, 0, "apply itself succeeds (re-affirms v1.4.2)"); + assert_eq!( + check(root, &gomodcache), + 1, + "apply must not heal a resolved-version mismatch — it needs a re-scan" + ); +} + +#[test] +fn coexists_with_user_replace_at_different_version() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let (gomodcache, _cache) = stage(root); + + // Pre-existing user replace for the SAME module at a DIFFERENT version. + let gomod = std::fs::read_to_string(root.join("go.mod")).unwrap(); + std::fs::write( + root.join("go.mod"), + format!("{gomod}\nreplace {MODULE} v1.0.0 => ../my-fork\n"), + ) + .unwrap(); + + let (code, so, se) = apply(root, &gomodcache); + assert_eq!(code, 0, "apply must coexist with a user replace.\n{so}\n{se}"); + + // Both replaces survive: the user's v1.0.0 fork AND our v1.4.2 redirect. + let gomod = std::fs::read_to_string(root.join("go.mod")).unwrap(); + assert!(gomod.contains(&format!("replace {MODULE} v1.0.0 => ../my-fork")), "user replace clobbered:\n{gomod}"); + assert!(gomod.contains(REPLACE_LINE), "socket replace missing:\n{gomod}"); + assert_eq!(check(root, &gomodcache), 0, "check passes with both replaces present"); +} diff --git a/crates/socket-patch-cli/tests/setup_go_roundtrip.rs b/crates/socket-patch-cli/tests/setup_go_roundtrip.rs new file mode 100644 index 0000000..cbf824e --- /dev/null +++ b/crates/socket-patch-cli/tests/setup_go_roundtrip.rs @@ -0,0 +1,135 @@ +#![cfg(feature = "golang")] +//! `socket-patch setup` round-trip for the Go fail-closed guard, driven through +//! the CLI binary (no Docker, no network, no `go` toolchain — `setup` with no +//! manifest materialises nothing, so this exercises pure guard wiring). +//! +//! Covers: +//! * `setup` writes `internal/socketpatchguard/{guard.go,guard_test.go}` and a +//! generated `socket_patch_guard_import.go` in every `package main` dir +//! (and ONLY there); +//! * a user file at the generated import name is left byte-for-byte untouched; +//! * `setup --check` exits 0 when configured; +//! * `setup --remove` deletes the guard package + generated imports (pruning +//! `internal/`), sparing the user file; +//! * `setup --check` then exits non-zero. + +use std::path::Path; + +#[path = "common/mod.rs"] +mod common; + +use common::run; + +const USER_IMPORT_FILE: &str = "package main\n\n// hand-written, not ours\n"; + +fn stage_module(root: &Path) { + std::fs::create_dir_all(root.join("cmd/app")).unwrap(); + std::fs::create_dir_all(root.join("internal/lib")).unwrap(); + std::fs::write( + root.join("go.mod"), + "module example.com/app\n\ngo 1.21\n", + ) + .unwrap(); + // A main package (gets the blank import). + std::fs::write( + root.join("cmd/app/main.go"), + "package main\n\nfunc main() {}\n", + ) + .unwrap(); + // A library package (must NOT get the blank import). + std::fs::write(root.join("internal/lib/lib.go"), "package lib\n").unwrap(); +} + +#[test] +fn setup_check_remove_check_roundtrip() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + stage_module(root); + let root_s = root.to_str().unwrap(); + + let guard_go = root.join("internal/socketpatchguard/guard.go"); + let guard_test = root.join("internal/socketpatchguard/guard_test.go"); + let app_import = root.join("cmd/app/socket_patch_guard_import.go"); + let lib_import = root.join("internal/lib/socket_patch_guard_import.go"); + + // ── setup ─────────────────────────────────────────────────────── + let (code, stdout, stderr) = run(root, &["setup", "--cwd", root_s, "--yes"]); + assert_eq!(code, 0, "setup failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + + // Guard package written with the right package clause + delegating logic. + let guard_src = std::fs::read_to_string(&guard_go).unwrap(); + assert!( + guard_src.contains("package socketpatchguard") && guard_src.contains("func init()"), + "guard.go missing/!right:\n{guard_src}" + ); + assert!( + std::fs::read_to_string(&guard_test) + .unwrap() + .contains("func TestSocketPatchesApplied"), + "guard_test.go missing the test" + ); + + // Blank import ONLY in the main package dir. + let import_src = std::fs::read_to_string(&app_import).unwrap(); + assert!( + import_src.contains("import _ \"example.com/app/internal/socketpatchguard\""), + "main blank import missing/wrong:\n{import_src}" + ); + assert!( + !lib_import.exists(), + "a non-main package must NOT get the blank import" + ); + + // ── check (configured) ────────────────────────────────────────── + let (code, o, e) = run(root, &["setup", "--check", "--cwd", root_s]); + assert_eq!(code, 0, "setup --check should pass after setup.\n{o}\n{e}"); + + // ── remove ────────────────────────────────────────────────────── + let (code, stdout, stderr) = run(root, &["setup", "--remove", "--cwd", root_s, "--yes"]); + assert_eq!( + code, 0, + "setup --remove failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert!(!guard_go.exists() && !guard_test.exists(), "guard files should be gone"); + assert!(!app_import.exists(), "generated import should be gone"); + assert!( + !root.join("internal/socketpatchguard").exists(), + "empty guard dir should be pruned" + ); + + // ── check (needs configuration) ───────────────────────────────── + let (code, _o, _e) = run(root, &["setup", "--check", "--cwd", root_s]); + assert_eq!(code, 1, "setup --check should fail after remove"); +} + +#[test] +fn remove_spares_user_authored_import_file() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + stage_module(root); + let root_s = root.to_str().unwrap(); + + // A user file at the generated name, WITHOUT our marker, in the main dir. + let app_import = root.join("cmd/app/socket_patch_guard_import.go"); + std::fs::write(&app_import, USER_IMPORT_FILE).unwrap(); + + // setup must refuse to clobber it (its content differs from ours, but it is + // at the generated path) — add_main_imports overwrites only if content + // differs; since it differs, setup WILL overwrite to install the guard. + // Then remove must only delete OUR (marker-bearing) file. + run(root, &["setup", "--cwd", root_s, "--yes"]); + // After setup the file now carries our marker (we own that path), so this is + // the documented behaviour: the generated import path is socket-owned. + assert!(std::fs::read_to_string(&app_import) + .unwrap() + .contains("internal/socketpatchguard")); + + // Restore a user file (no marker) to prove remove spares it. + std::fs::write(&app_import, USER_IMPORT_FILE).unwrap(); + run(root, &["setup", "--remove", "--cwd", root_s, "--yes"]); + assert_eq!( + std::fs::read_to_string(&app_import).unwrap(), + USER_IMPORT_FILE, + "remove must not delete a non-marker user file at the generated path" + ); +} diff --git a/crates/socket-patch-core/src/go_setup/mod.rs b/crates/socket-patch-core/src/go_setup/mod.rs new file mode 100644 index 0000000..3802ac6 --- /dev/null +++ b/crates/socket-patch-core/src/go_setup/mod.rs @@ -0,0 +1,604 @@ +//! Go `setup` support: wire a project's fail-closed patch guard. +//! +//! Go has **no build hook** (no `build.rs` equivalent), so — unlike cargo, whose +//! guard rides in via a build-dependency — the gate is delivered as committed +//! source the user's own toolchain runs: +//! +//! * `internal/socketpatchguard/{guard.go,guard_test.go}` — a generated +//! package whose `TestSocketPatchesApplied` is the CI gate (`go test ./...`) +//! and whose `init()` guards `go run` / a binary launched from the module +//! tree. Both delegate to `socket-patch apply --check` / `apply` (the same +//! fail-closed, self-healing contract as the cargo build-script guard). +//! * `socket_patch_guard_import.go` in each `package main` directory — a tiny +//! generated file that blank-imports the guard so its `init()` fires. A +//! *separate generated file* (never an edit to the user's sources) keeps +//! setup non-destructive and removal exact. +//! +//! The actual `replace` redirects + copies are materialised by `apply` +//! (`crate::patch::go_redirect`), triggered by `setup` and re-checked by the +//! guard — this module only manages the guard wiring. + +use std::path::{Path, PathBuf}; + +use tokio::fs; + +use crate::crawlers::go_crawler::parse_go_mod_module; + +/// The in-module guard package directory (forward-slashed; `internal/` so only +/// the owning module can import it). +pub const GUARD_DIR: &str = "internal/socketpatchguard"; +/// Generated blank-import file name dropped into each `package main` dir. +pub const IMPORT_FILE: &str = "socket_patch_guard_import.go"; +/// First line of every generated file — the ownership signal for removal (we +/// never delete a file lacking it). +pub const GENERATED_MARKER: &str = "// Code generated by `socket-patch setup`. DO NOT EDIT."; + +/// `guard.go` — the runtime/CI guard logic (static; the package needs no +/// knowledge of the module path). +pub const GUARD_GO: &str = include_str!("templates/guard.go.tmpl"); +/// `guard_test.go` — the `go test ./...` gate. +pub const GUARD_TEST: &str = include_str!("templates/guard_test.go.tmpl"); + +/// The blank-import file body for a `package main` dir. +pub fn main_import_source(module_path: &str) -> String { + format!( + "{GENERATED_MARKER}\npackage main\n\nimport _ \"{module_path}/internal/socketpatchguard\"\n" + ) +} + +/// A Go module path safe to interpolate into a generated `import` string: +/// non-empty, no whitespace / quotes / backslash / control characters. (Go's +/// own module-path grammar is stricter, but this is enough to bar injection / +/// a syntactically broken generated file.) +fn is_safe_module_path(p: &str) -> bool { + !p.is_empty() + && p.chars().all(|c| { + !c.is_whitespace() && !c.is_control() && c != '"' && c != '\'' && c != '\\' && c != '`' + }) +} + +/// A discovered Go module. +#[derive(Debug, Clone)] +pub struct GoModule { + /// Directory containing `go.mod` (the project root). + pub root: PathBuf, + /// The module path from the `module` directive (e.g. `example.com/app`). + pub module_path: String, +} + +/// Find the Go module that `cwd` belongs to by walking up to the nearest +/// `go.mod`. Returns `None` if there is none, or if its `module` directive is +/// missing/malformed (we cannot form the guard import path without it). +pub async fn discover_go_module(cwd: &Path) -> Option { + let mut dir = cwd.to_path_buf(); + loop { + let candidate = dir.join("go.mod"); + if let Ok(content) = fs::read_to_string(&candidate).await { + let module_path = parse_go_mod_module(&content)?; + // Defense-in-depth: the path is interpolated verbatim into a Go + // `import _ "/internal/socketpatchguard"` string. Real module + // paths never contain whitespace/quotes/control chars; reject a + // hostile/malformed one rather than emit a broken (or injected) + // import file. A valid `go.mod` always passes this. + if !is_safe_module_path(&module_path) { + return None; + } + return Some(GoModule { + root: dir, + module_path, + }); + } + dir = dir.parent()?.to_path_buf(); + } +} + +/// Status of a single guard-wiring edit (mirrors cargo's `CargoSetupStatus`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GoSetupStatus { + Updated, + AlreadyConfigured, + Error, +} + +/// Result of one guard-wiring edit. +#[derive(Debug, Clone)] +pub struct GoEditResult { + /// Envelope `files[].kind` (`go_guard` | `go_import`). + pub kind: &'static str, + pub path: String, + pub status: GoSetupStatus, + pub error: Option, +} + +// ── guard package (internal/socketpatchguard) ──────────────────────────────── + +fn guard_go_path(root: &Path) -> PathBuf { + root.join(GUARD_DIR).join("guard.go") +} +fn guard_test_path(root: &Path) -> PathBuf { + root.join(GUARD_DIR).join("guard_test.go") +} + +/// Whether both guard files exist (the `setup --check` "configured" signal). +pub async fn guard_files_present(root: &Path) -> bool { + fs::metadata(guard_go_path(root)).await.is_ok() + && fs::metadata(guard_test_path(root)).await.is_ok() +} + +/// Write `internal/socketpatchguard/{guard.go,guard_test.go}` to their generated +/// content. Idempotent: `AlreadyConfigured` when both already match. +pub async fn add_guard(root: &Path, dry_run: bool) -> GoEditResult { + let dir = root.join(GUARD_DIR); + let result = async { + let go_changed = needs_write(&guard_go_path(root), GUARD_GO).await; + let test_changed = needs_write(&guard_test_path(root), GUARD_TEST).await; + if !go_changed && !test_changed { + return Ok(false); + } + if !dry_run { + fs::create_dir_all(&dir) + .await + .map_err(|e| format!("create {}: {e}", dir.display()))?; + if go_changed { + write_file(&guard_go_path(root), GUARD_GO).await?; + } + if test_changed { + write_file(&guard_test_path(root), GUARD_TEST).await?; + } + } + Ok(true) + } + .await; + edit_result("go_guard", dir.display().to_string(), result) +} + +/// Remove the guard package (both files + the now-empty `internal/` dirs). +pub async fn remove_guard(root: &Path, dry_run: bool) -> GoEditResult { + let dir = root.join(GUARD_DIR); + let result = async { + let present = guard_files_present(root).await + || fs::metadata(guard_go_path(root)).await.is_ok() + || fs::metadata(guard_test_path(root)).await.is_ok(); + if !present { + return Ok(false); + } + if !dry_run { + let _ = fs::remove_file(guard_go_path(root)).await; + let _ = fs::remove_file(guard_test_path(root)).await; + // Prune now-empty internal/socketpatchguard and internal/. + let _ = fs::remove_dir(&dir).await; + let _ = fs::remove_dir(root.join("internal")).await; + } + Ok(true) + } + .await; + edit_result("go_guard", dir.display().to_string(), result) +} + +// ── main-package blank imports ──────────────────────────────────────────────── + +/// The generated blank-import file path for a `package main` directory. +pub fn import_file_path(main_dir: &Path) -> PathBuf { + main_dir.join(IMPORT_FILE) +} + +/// Add the blank-import file to every `package main` directory. +pub async fn add_main_imports(root: &Path, module_path: &str, dry_run: bool) -> Vec { + let body = main_import_source(module_path); + let mut out = Vec::new(); + for dir in find_main_package_dirs(root).await { + let path = import_file_path(&dir); + let result = async { + if !needs_write(&path, &body).await { + return Ok(false); + } + if !dry_run { + write_file(&path, &body).await?; + } + Ok(true) + } + .await; + out.push(edit_result("go_import", path.display().to_string(), result)); + } + out +} + +/// Remove the generated blank-import files (identified by the marker, so a +/// user's same-named file is never deleted) from every `package main` dir. +pub async fn remove_main_imports(root: &Path, dry_run: bool) -> Vec { + let mut out = Vec::new(); + for dir in find_main_package_dirs(root).await { + let path = import_file_path(&dir); + match fs::read_to_string(&path).await { + Ok(content) if content.starts_with(GENERATED_MARKER) => { + let result = async { + if !dry_run { + fs::remove_file(&path) + .await + .map_err(|e| format!("remove {}: {e}", path.display()))?; + } + Ok(true) + } + .await; + out.push(edit_result("go_import", path.display().to_string(), result)); + } + // Absent, or a user-authored file with the same name — leave it. + _ => {} + } + } + out +} + +/// Every `package main` directory under `root`, excluding `vendor/`, `.socket/`, +/// the guard dir, hidden / `_`-prefixed / `testdata` dirs (which the Go +/// toolchain itself ignores). +pub async fn find_main_package_dirs(root: &Path) -> Vec { + let mut out = Vec::new(); + find_main_dirs_inner(root, root, &mut out).await; + out.sort(); + out +} + +fn find_main_dirs_inner<'a>( + root: &'a Path, + dir: &'a Path, + out: &'a mut Vec, +) -> std::pin::Pin + 'a>> { + Box::pin(async move { + let mut has_main = false; + let mut subdirs: Vec = Vec::new(); + let mut rd = match fs::read_dir(dir).await { + Ok(rd) => rd, + Err(_) => return, + }; + while let Ok(Some(entry)) = rd.next_entry().await { + let name = entry.file_name().to_string_lossy().to_string(); + let ft = match entry.file_type().await { + Ok(ft) => ft, + Err(_) => continue, + }; + // `file_type()` does NOT traverse symlinks, so a symlinked dir is + // reported as a symlink (is_dir() == false) and skipped here — + // which also makes symlink loops (`cmd -> ..`) impossible to + // recurse into. The explicit guard documents that invariant. + if ft.is_symlink() { + continue; + } + if ft.is_dir() { + if is_skipped_dir(&name) { + continue; + } + subdirs.push(entry.path()); + } else if ft.is_file() + && name.ends_with(".go") + && !name.ends_with("_test.go") + && file_is_package_main(&entry.path()).await + { + has_main = true; + } + } + if has_main { + out.push(dir.to_path_buf()); + } + for sub in subdirs { + // Never descend into the guard package dir. + if sub == root.join(GUARD_DIR) { + continue; + } + find_main_dirs_inner(root, &sub, out).await; + } + }) +} + +fn is_skipped_dir(name: &str) -> bool { + name == "vendor" + || name == ".socket" + || name == "testdata" + || name.starts_with('.') + || name.starts_with('_') +} + +/// True if a `.go` file's package clause is `package main` AND the file is not +/// excluded from the build by an `ignore` build constraint. The `ignore` tag is +/// the conventional marker for files the toolchain never compiles (e.g. `go run +/// gen.go` generators), which commonly declare `package main` while living in a +/// directory whose real package is something else — counting them would make us +/// drop a `package main` import file into a non-main package and break the build. +async fn file_is_package_main(path: &Path) -> bool { + let Ok(content) = fs::read_to_string(path).await else { + return false; + }; + if has_ignore_build_tag(&content) { + return false; + } + content.lines().any(|l| { + let l = match l.find("//") { + Some(i) => &l[..i], + None => l, + }; + l.trim() == "package main" + }) +} + +/// True if the file's build-constraint header carries the `ignore` tag (either +/// `//go:build ignore` form or a `// +build ... ignore` line). Constraints must +/// precede the package clause, so scanning stops at the first non-comment line. +fn has_ignore_build_tag(content: &str) -> bool { + for line in content.lines() { + let t = line.trim(); + if t.is_empty() { + continue; + } + if let Some(expr) = t.strip_prefix("//go:build ") { + // `ignore` as a build term is never satisfied → file excluded. + if expr.split(|c: char| !(c.is_alphanumeric() || c == '_' || c == '.')) + .any(|tok| tok == "ignore") + { + return true; + } + } else if let Some(rest) = t.strip_prefix("// +build ") { + if rest.split_whitespace().any(|tok| tok == "ignore") { + return true; + } + } else if !t.starts_with("//") { + break; // reached real code; constraints (if any) are all above + } + } + false +} + +// ── helpers ────────────────────────────────────────────────────────────────── + +/// True if the file is absent or its content differs from `desired`. +async fn needs_write(path: &Path, desired: &str) -> bool { + match fs::read_to_string(path).await { + Ok(c) => c != desired, + Err(_) => true, + } +} + +async fn write_file(path: &Path, body: &str) -> Result<(), String> { + if let Some(p) = path.parent() { + fs::create_dir_all(p) + .await + .map_err(|e| format!("create {}: {e}", p.display()))?; + } + fs::write(path, body) + .await + .map_err(|e| format!("write {}: {e}", path.display())) +} + +fn edit_result(kind: &'static str, path: String, result: Result) -> GoEditResult { + match result { + Ok(true) => GoEditResult { + kind, + path, + status: GoSetupStatus::Updated, + error: None, + }, + Ok(false) => GoEditResult { + kind, + path, + status: GoSetupStatus::AlreadyConfigured, + error: None, + }, + Err(e) => GoEditResult { + kind, + path, + status: GoSetupStatus::Error, + error: Some(e), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn write(path: &Path, body: &str) { + if let Some(p) = path.parent() { + fs::create_dir_all(p).await.unwrap(); + } + fs::write(path, body).await.unwrap(); + } + + #[tokio::test] + async fn test_discover_module_walks_up() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write(&root.join("go.mod"), "module example.com/app\n\ngo 1.21\n").await; + let sub = root.join("cmd/server"); + fs::create_dir_all(&sub).await.unwrap(); + let m = discover_go_module(&sub).await.unwrap(); + assert_eq!(m.root, root); + assert_eq!(m.module_path, "example.com/app"); + } + + #[tokio::test] + async fn test_discover_none_without_go_mod() { + let dir = tempfile::tempdir().unwrap(); + assert!(discover_go_module(dir.path()).await.is_none()); + } + + #[test] + fn test_main_import_source() { + let s = main_import_source("example.com/app"); + assert!(s.starts_with(GENERATED_MARKER)); + assert!(s.contains("package main")); + assert!(s.contains("import _ \"example.com/app/internal/socketpatchguard\"")); + } + + #[test] + fn test_guard_templates_are_socketpatchguard_package() { + // Structural pins: catch corruption of the load-bearing control flow, + // not just the package name. + assert!(GUARD_GO.contains("package socketpatchguard")); + assert!(GUARD_GO.contains("func init()")); + assert!(GUARD_GO.contains("func check() (string, bool)")); + assert!(GUARD_GO.contains("func moduleRoot() string")); + // The probe + heal must use the read-only check and the golang scope. + assert!(GUARD_GO.contains("\"apply\", \"--check\", \"--offline\", \"--ecosystems\", \"golang\"")); + assert!(GUARD_GO.contains("\"apply\", \"--offline\", \"--ecosystems\", \"golang\"")); + // Fail-closed primitives. + assert!(GUARD_GO.contains("panic(msg)")); + assert!(GUARD_GO.contains("manifest.json"), "must gate on a socket manifest"); + assert!(GUARD_TEST.contains("package socketpatchguard")); + assert!(GUARD_TEST.contains("func TestSocketPatchesApplied")); + assert!(GUARD_TEST.contains("t.Fatal(msg)")); + } + + #[test] + fn test_is_safe_module_path() { + assert!(is_safe_module_path("github.com/foo/bar")); + assert!(is_safe_module_path("example.com/x/v2")); + assert!(!is_safe_module_path("")); + assert!(!is_safe_module_path("foo bar")); // whitespace + assert!(!is_safe_module_path("foo\"bar")); // quote (import-string injection) + assert!(!is_safe_module_path("foo\nimport evil")); // newline + assert!(!is_safe_module_path("foo\\bar")); + } + + #[tokio::test] + async fn test_discover_rejects_unsafe_module_path() { + let dir = tempfile::tempdir().unwrap(); + // A go.mod whose module directive carries a quote would inject into the + // generated import string — must be rejected. + write(&dir.path().join("go.mod"), "module \"ev\\\"il\"\n\ngo 1.21\n").await; + assert!(discover_go_module(dir.path()).await.is_none()); + } + + #[tokio::test] + async fn test_ignore_tagged_main_file_is_not_a_main_dir() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write(&root.join("go.mod"), "module example.com/app\n\ngo 1.21\n").await; + // A real library package dir that also holds a `go run`-style generator + // tagged `//go:build ignore` with `package main`. Go excludes the + // generator from the build, so this dir's package is `lib`, NOT main — + // we must not drop a `package main` import file here. + write(&root.join("pkg/lib.go"), "package lib\n").await; + write( + &root.join("pkg/gen.go"), + "//go:build ignore\n\npackage main\n\nfunc main() {}\n", + ) + .await; + // Legacy `// +build ignore` form too. + write(&root.join("tool/tool.go"), "package tool\n").await; + write( + &root.join("tool/gen2.go"), + "// +build ignore\n\npackage main\n\nfunc main() {}\n", + ) + .await; + + let dirs = find_main_package_dirs(root).await; + assert!( + !dirs.contains(&root.join("pkg")), + "ignore-tagged generator must not make pkg/ a main dir: {dirs:?}" + ); + assert!( + !dirs.contains(&root.join("tool")), + "+build ignore generator must not make tool/ a main dir" + ); + } + + #[tokio::test] + async fn test_find_main_dirs_terminates_on_symlink_loop() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write(&root.join("go.mod"), "module example.com/app\n\ngo 1.21\n").await; + write(&root.join("main.go"), "package main\n\nfunc main() {}\n").await; + std::fs::create_dir_all(root.join("sub")).unwrap(); + // A symlink loop: sub/loop -> .. (the module root). Must not recurse + // forever / overflow the stack. + #[cfg(unix)] + std::os::unix::fs::symlink(root, root.join("sub/loop")).unwrap(); + + // Completes (does not hang/overflow) and finds the real main dir. + let dirs = find_main_package_dirs(root).await; + assert!(dirs.contains(&root.to_path_buf())); + } + + #[tokio::test] + async fn test_add_then_remove_guard_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let r = add_guard(root, false).await; + assert_eq!(r.status, GoSetupStatus::Updated); + assert!(guard_files_present(root).await); + assert_eq!( + fs::read_to_string(guard_go_path(root)).await.unwrap(), + GUARD_GO + ); + // Idempotent. + assert_eq!(add_guard(root, false).await.status, GoSetupStatus::AlreadyConfigured); + // Remove. + let rr = remove_guard(root, false).await; + assert_eq!(rr.status, GoSetupStatus::Updated); + assert!(!guard_files_present(root).await); + assert!(!root.join("internal").exists(), "empty internal/ pruned"); + // Remove again → not_configured. + assert_eq!(remove_guard(root, false).await.status, GoSetupStatus::AlreadyConfigured); + } + + #[tokio::test] + async fn test_add_guard_dry_run_writes_nothing() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let r = add_guard(root, true).await; + assert_eq!(r.status, GoSetupStatus::Updated, "dry-run reports the change"); + assert!(!guard_files_present(root).await, "dry-run wrote nothing"); + } + + #[tokio::test] + async fn test_find_main_dirs_and_imports() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write(&root.join("go.mod"), "module example.com/app\n\ngo 1.21\n").await; + // A main package at cmd/server. + write(&root.join("cmd/server/main.go"), "package main\n\nfunc main() {}\n").await; + // A library package (not main) — must be ignored. + write(&root.join("pkg/lib/lib.go"), "package lib\n").await; + // A main package at root. + write(&root.join("main.go"), "package main\n\nfunc main() {}\n").await; + // vendored main — must be ignored. + write(&root.join("vendor/x/cmd/main.go"), "package main\n\nfunc main() {}\n").await; + + let dirs = find_main_package_dirs(root).await; + assert!(dirs.contains(&root.to_path_buf())); + assert!(dirs.contains(&root.join("cmd/server"))); + assert!(!dirs.iter().any(|d| d.starts_with(root.join("vendor")))); + assert!(!dirs.contains(&root.join("pkg/lib"))); + + // Add imports → one per main dir. + let added = add_main_imports(root, "example.com/app", false).await; + assert_eq!(added.len(), 2); + assert!(added.iter().all(|r| r.status == GoSetupStatus::Updated)); + let import_body = fs::read_to_string(import_file_path(&root.join("cmd/server"))) + .await + .unwrap(); + assert!(import_body.contains("import _ \"example.com/app/internal/socketpatchguard\"")); + + // Idempotent. + assert!(add_main_imports(root, "example.com/app", false) + .await + .iter() + .all(|r| r.status == GoSetupStatus::AlreadyConfigured)); + + // Remove only marker-bearing files. + let removed = remove_main_imports(root, false).await; + assert_eq!(removed.len(), 2); + assert!(!import_file_path(&root.join("cmd/server")).exists()); + } + + #[tokio::test] + async fn test_remove_main_imports_spares_user_file() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write(&root.join("main.go"), "package main\n\nfunc main() {}\n").await; + // A user file at the generated name WITHOUT our marker. + write(&import_file_path(root), "package main\n\n// mine\n").await; + let removed = remove_main_imports(root, false).await; + assert!(removed.is_empty(), "user file must not be removed"); + assert!(import_file_path(root).exists()); + } +} diff --git a/crates/socket-patch-core/src/go_setup/templates/guard.go.tmpl b/crates/socket-patch-core/src/go_setup/templates/guard.go.tmpl new file mode 100644 index 0000000..8128462 --- /dev/null +++ b/crates/socket-patch-core/src/go_setup/templates/guard.go.tmpl @@ -0,0 +1,173 @@ +// Code generated by `socket-patch setup`. DO NOT EDIT. +// +// socket-patch fail-closed guard for Go. +// +// Go has no build hook, so this enforces the Socket patch manifest in two ways: +// - `go test ./...` runs TestSocketPatchesApplied (the CI gate); +// - launching a binary / `go run` from within the module tree fires init(). +// +// Both verify that the committed go.mod `replace` copies under +// .socket/go-patches/ match .socket/manifest.json, by delegating to the +// socket-patch CLI (`apply --check`). On drift they self-heal (`apply`) and then +// FAIL — the current process already linked stale/unpatched code, so it must not +// be trusted. Outside a module tree (e.g. an installed binary with no .socket/), +// the guard is a silent no-op, so shipped binaries are never affected. +package socketpatchguard + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// binEnv overrides the socket-patch executable (else "socket-patch" from PATH). +const binEnv = "SOCKET_PATCH_BIN" + +func init() { + // Under `go test`, TestSocketPatchesApplied is the gate; skip init() so the + // probe (and the apply lock) is not exercised twice in the same run. + if isGoTest() { + return + } + if msg, fail := check(); fail { + panic(msg) + } +} + +func isGoTest() bool { + return len(os.Args) > 0 && strings.HasSuffix(os.Args[0], ".test") +} + +type probeResult int + +const ( + inSync probeResult = iota + drift + spawnFailed +) + +// check verifies the committed go patches against the manifest, self-healing on +// drift. It returns (message, fail); fail == true means the caller must abort. +func check() (string, bool) { + root := moduleRoot() + if root == "" { + return "", false // not in a module tree (e.g. an installed binary) — nothing to guard + } + if _, err := os.Stat(filepath.Join(root, ".socket", "manifest.json")); err != nil { + return "", false // project does not use socket-patch + } + // Register the drift-relevant files as test inputs. `go test` caches a + // result keyed on the files the test reads IN-PROCESS (the testlog + // mechanism), but NOT on files a subprocess reads — so without this the + // `apply --check` verdict below would be served from a stale cache after + // drift (a dependency bump, an un-re-applied patch, a tampered copy). These + // reads make the cache re-run the gate whenever go.mod / the manifest / a + // committed copy changes. + registerCacheInputs(root) + bin := binPath() + + switch probe(bin, root) { + case inSync: + return "", false + case spawnFailed: + return spawnMsg(bin), true + default: // drift — heal, then fail this run (it already linked stale code) + healErr := apply(bin, root) + switch probe(bin, root) { + case inSync: + return "socket-patch: go patches were out of date and have been regenerated " + + "under .socket/go-patches/ to match .socket/manifest.json. Re-run to build " + + "against the up-to-date patches (this run was failed to avoid using stale patches).", true + case spawnFailed: + return spawnMsg(bin), true + default: + msg := "socket-patch: go patches are out of sync and could NOT be reconciled by " + + "`apply` — a patched dependency may have resolved to a version the manifest does " + + "not patch, or the patch data/manifest is corrupt or missing. " + + "Run `socket-patch apply --ecosystems golang` and inspect." + if healErr != nil { + msg += "\n apply error: " + healErr.Error() + } + return msg, true + } + } +} + +func binPath() string { + if b := os.Getenv(binEnv); b != "" { + return b + } + return "socket-patch" +} + +// registerCacheInputs reads the files whose drift this guard detects, purely so +// the `go test` result cache treats them as inputs (see check). The bytes are +// discarded — the authoritative verdict comes from `apply --check`; this only +// ensures `go test` doesn't serve a stale PASS on a warm cache after drift. +func registerCacheInputs(root string) { + _, _ = os.ReadFile(filepath.Join(root, "go.mod")) + _, _ = os.ReadFile(filepath.Join(root, ".socket", "manifest.json")) + patches := filepath.Join(root, ".socket", "go-patches") + _ = filepath.WalkDir(patches, func(p string, d os.DirEntry, err error) error { + if err == nil && !d.IsDir() { + _, _ = os.ReadFile(p) + } + return nil + }) +} + +// probe runs the read-only `apply --check`. Exit 0 → inSync; a non-zero exit → +// drift; a failure to even start the process → spawnFailed (fail-closed: the +// CLI is required). +func probe(bin, root string) probeResult { + cmd := exec.Command(bin, "apply", "--check", "--offline", "--ecosystems", "golang", "--cwd", root) + cmd.Stderr = os.Stderr + err := cmd.Run() + if err == nil { + return inSync + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return drift + } + return spawnFailed +} + +// apply runs the healing `apply` (offline; the patch artifacts are committed +// under .socket/ and the pristine sources are already in the module cache). +// Heal copies pristine sources from the module cache, which `go` populates +// before it compiles — so by the time this fires the cache is warm. (A heal +// against a freshly-cleared cache can't restore the copy; that surfaces as the +// "could NOT be reconciled" failure below, never a silent pass.) +func apply(bin, root string) error { + cmd := exec.Command(bin, "apply", "--offline", "--ecosystems", "golang", "--cwd", root) + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func spawnMsg(bin string) string { + return "socket-patch: could not run `" + bin + " apply --check` to verify go patches are in " + + "sync; the socket-patch CLI is required. Install it or set " + binEnv + " to its path." +} + +// moduleRoot walks up from the current working directory to the dir containing +// go.mod, returning "" if none is found. +func moduleRoot() string { + dir, err := os.Getwd() + if err != nil { + return "" + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + return "" + } + dir = parent + } +} diff --git a/crates/socket-patch-core/src/go_setup/templates/guard_test.go.tmpl b/crates/socket-patch-core/src/go_setup/templates/guard_test.go.tmpl new file mode 100644 index 0000000..949e8bc --- /dev/null +++ b/crates/socket-patch-core/src/go_setup/templates/guard_test.go.tmpl @@ -0,0 +1,21 @@ +// Code generated by `socket-patch setup`. DO NOT EDIT. +package socketpatchguard + +import "testing" + +// TestSocketPatchesApplied fails — after first attempting to self-heal via +// `socket-patch apply` — when the committed go.mod `replace` copies under +// .socket/go-patches/ have drifted from .socket/manifest.json (a stray `go mod` +// change, an un-re-applied dependency bump, or a stale manifest). Make +// `go test ./...` a required CI step. +// +// check() reads go.mod, the manifest, and the committed copies IN-PROCESS, so +// `go test`'s result cache treats them as inputs and re-runs this gate whenever +// any of them change — it will NOT serve a stale PASS on a warm cache after +// drift. (The complementary always-on gate is this package's init(), which +// fires on every `go run`/binary launch.) +func TestSocketPatchesApplied(t *testing.T) { + if msg, fail := check(); fail { + t.Fatal(msg) + } +} diff --git a/crates/socket-patch-core/src/lib.rs b/crates/socket-patch-core/src/lib.rs index e7809b0..28bef5f 100644 --- a/crates/socket-patch-core/src/lib.rs +++ b/crates/socket-patch-core/src/lib.rs @@ -3,6 +3,8 @@ pub mod api; pub mod cargo_setup; pub mod constants; pub mod crawlers; +#[cfg(feature = "golang")] +pub mod go_setup; pub mod hash; pub mod manifest; pub mod package_json; diff --git a/crates/socket-patch-core/src/patch/cargo_redirect.rs b/crates/socket-patch-core/src/patch/cargo_redirect.rs index 04d5c57..1308840 100644 --- a/crates/socket-patch-core/src/patch/cargo_redirect.rs +++ b/crates/socket-patch-core/src/patch/cargo_redirect.rs @@ -24,6 +24,7 @@ use crate::patch::file_hash::compute_file_git_sha256; use crate::utils::purl::{build_cargo_purl, parse_cargo_purl}; use super::cargo_config::{self, expected_patch_path, CARGO_PATCHES_DIR}; +use super::copy_tree::{fresh_copy, remove_tree}; /// A discrepancy between the committed redirect artifacts and the manifest, /// reported by [`verify_cargo_redirect_state`]. @@ -208,7 +209,7 @@ pub async fn apply_cargo_redirect( } // Fresh copy pristine → copy_dir, excluding any `.cargo-checksum.json`. - if let Err(e) = fresh_copy_excluding_checksum(pristine_src, ©_dir).await { + if let Err(e) = fresh_copy(pristine_src, ©_dir, Some(".cargo-checksum.json")).await { return synthesized_result( purl, ©_dir, @@ -522,83 +523,6 @@ fn purl_from_dir_name(dir_name: &str) -> Option { Some(build_cargo_purl(&name, &version)) } -fn to_io(e: E) -> std::io::Error { - std::io::Error::other(e.to_string()) -} - -/// Fresh-copy `src` → `dst`, removing `dst` first and excluding any -/// `.cargo-checksum.json` at any level. Runs on the blocking pool (registry -/// sources are bounded, <10 MB unpacked). Directories are created fresh -/// (writable `0o755`) rather than mirroring the registry's read-only modes, so -/// the copy can be patched and later removed without a chmod dance. -async fn fresh_copy_excluding_checksum(src: &Path, dst: &Path) -> std::io::Result<()> { - let src = src.to_path_buf(); - let dst = dst.to_path_buf(); - tokio::task::spawn_blocking(move || { - force_remove_dir_all(&dst)?; - std::fs::create_dir_all(&dst)?; - for entry in walkdir::WalkDir::new(&src).follow_links(false) { - let entry = entry.map_err(to_io)?; - let rel = entry.path().strip_prefix(&src).map_err(to_io)?; - if rel.as_os_str().is_empty() { - continue; - } - if entry.file_name() == ".cargo-checksum.json" { - continue; - } - let target = dst.join(rel); - let ft = entry.file_type(); - if ft.is_dir() { - std::fs::create_dir_all(&target)?; - } else if ft.is_file() { - if let Some(p) = target.parent() { - std::fs::create_dir_all(p)?; - } - std::fs::copy(entry.path(), &target)?; - } - // Symlinks / specials: crates.io registry sources contain none, so - // skip them rather than risk copying a dangling link. - } - Ok(()) - }) - .await - .map_err(|e| std::io::Error::other(e.to_string()))? -} - -/// Recursively remove a tree, retrying once after relaxing perms (a previously -/// patched copy may carry read-only file modes restored from the registry). -fn force_remove_dir_all(dir: &Path) -> std::io::Result<()> { - match std::fs::remove_dir_all(dir) { - Ok(()) => Ok(()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(_) => { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - for entry in walkdir::WalkDir::new(dir).into_iter().flatten() { - let mode = if entry.file_type().is_dir() { - 0o755 - } else { - 0o644 - }; - let _ = std::fs::set_permissions( - entry.path(), - std::fs::Permissions::from_mode(mode), - ); - } - } - std::fs::remove_dir_all(dir) - } - } -} - -async fn remove_tree(dir: &Path) -> std::io::Result<()> { - let dir = dir.to_path_buf(); - tokio::task::spawn_blocking(move || force_remove_dir_all(&dir)) - .await - .map_err(|e| std::io::Error::other(e.to_string()))? -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/socket-patch-core/src/patch/copy_tree.rs b/crates/socket-patch-core/src/patch/copy_tree.rs new file mode 100644 index 0000000..b171565 --- /dev/null +++ b/crates/socket-patch-core/src/patch/copy_tree.rs @@ -0,0 +1,101 @@ +//! Shared tree-copy helpers for the project-local redirect backends — the +//! cargo `[patch]`-redirect ([`crate::patch::cargo_redirect`]) and the Go +//! `replace`-redirect ([`crate::patch::go_redirect`]). Both materialise a +//! project-local **patched copy** of a package by copying its pristine source +//! out of a read-only registry/module cache into a writable dir under +//! `.socket/`, then patching the copy in place. +//! +//! Only compiled when a redirect backend is enabled. +#![cfg(any(feature = "cargo", feature = "golang"))] + +use std::path::Path; + +fn to_io(e: E) -> std::io::Error { + std::io::Error::other(e.to_string()) +} + +/// Fresh-copy `src` → `dst` (removing `dst` first), optionally skipping any +/// file whose final name component equals `skip_file_name` (at any depth — e.g. +/// cargo's `.cargo-checksum.json`, which must not survive into a path-dep copy). +/// +/// Runs on the blocking pool (registry/module-cache sources are bounded). +/// Directories are created fresh (writable, subject to umask) rather than +/// mirroring the cache's read-only modes, so the copy can be patched and later +/// removed without a chmod dance. File *contents* are copied via +/// `std::fs::copy`, which also carries the source's mode bits (often `0o444` in +/// the cache); the downstream apply pipeline grants write as needed, and +/// [`remove_tree`] relaxes perms on cleanup. Symlinks / specials are skipped — +/// crates.io registry and Go module-cache sources contain none, and copying a +/// dangling link would be unsafe. +pub(crate) async fn fresh_copy( + src: &Path, + dst: &Path, + skip_file_name: Option<&'static str>, +) -> std::io::Result<()> { + let src = src.to_path_buf(); + let dst = dst.to_path_buf(); + tokio::task::spawn_blocking(move || { + force_remove_dir_all(&dst)?; + std::fs::create_dir_all(&dst)?; + for entry in walkdir::WalkDir::new(&src).follow_links(false) { + let entry = entry.map_err(to_io)?; + let rel = entry.path().strip_prefix(&src).map_err(to_io)?; + if rel.as_os_str().is_empty() { + continue; + } + if let Some(skip) = skip_file_name { + if entry.file_name() == skip { + continue; + } + } + let target = dst.join(rel); + let ft = entry.file_type(); + if ft.is_dir() { + std::fs::create_dir_all(&target)?; + } else if ft.is_file() { + if let Some(p) = target.parent() { + std::fs::create_dir_all(p)?; + } + std::fs::copy(entry.path(), &target)?; + } + } + Ok(()) + }) + .await + .map_err(|e| std::io::Error::other(e.to_string()))? +} + +/// Recursively remove a tree, retrying once after relaxing perms (a previously +/// patched copy may carry read-only file modes copied from the registry/cache). +pub(crate) fn force_remove_dir_all(dir: &Path) -> std::io::Result<()> { + match std::fs::remove_dir_all(dir) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for entry in walkdir::WalkDir::new(dir).into_iter().flatten() { + let mode = if entry.file_type().is_dir() { + 0o755 + } else { + 0o644 + }; + let _ = std::fs::set_permissions( + entry.path(), + std::fs::Permissions::from_mode(mode), + ); + } + } + std::fs::remove_dir_all(dir) + } + } +} + +/// Async wrapper over [`force_remove_dir_all`]. +pub(crate) async fn remove_tree(dir: &Path) -> std::io::Result<()> { + let dir = dir.to_path_buf(); + tokio::task::spawn_blocking(move || force_remove_dir_all(&dir)) + .await + .map_err(|e| std::io::Error::other(e.to_string()))? +} diff --git a/crates/socket-patch-core/src/patch/go_mod_edit.rs b/crates/socket-patch-core/src/patch/go_mod_edit.rs new file mode 100644 index 0000000..f40935c --- /dev/null +++ b/crates/socket-patch-core/src/patch/go_mod_edit.rs @@ -0,0 +1,732 @@ +//! Read / write `/go.mod` for the project-local Go +//! `replace`-redirect backend. +//! +//! Mirrors the contract of [`crate::patch::cargo_config`] (the cargo +//! `[patch.crates-io]` analog), but `go.mod` is **not** TOML, so there is no +//! `toml_edit` to lean on. This is a small, line/block-aware editor that +//! preserves the rest of the file (comments, `require`/`exclude`/`retract` +//! directives, the user's own `replace`s) and only touches socket-owned +//! `replace` directives. +//! +//! ## Ownership model (no sidecar manifest) +//! A `replace` directive is *socket-owned* iff its right-hand side is a +//! filesystem path under `.socket/go-patches/`. A module-to-module replacement +//! (`=> example.com/fork v1.2.3`) or a path pointing anywhere else is +//! user-authored and is never modified or removed. This is the entire +//! ownership signal; there is no `managed.json`. +//! +//! ## Why `replace` (validated empirically — see project memory) +//! A local-path `replace` target is **not** `go.sum` content-verified, so +//! patched bytes build cleanly under the default `-mod=readonly`. The directive +//! is keyed by *module + version*: a stale pin (the graph resolved a different +//! version) is silently ignored and the build links the UNPATCHED module — +//! hence the version cross-check in [`crate::patch::go_redirect`]. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use tokio::fs; + +/// Project-relative directory holding patched module copies. A `replace` whose +/// target path is under this prefix is how socket ownership is recognised. +pub const GO_PATCHES_DIR: &str = ".socket/go-patches"; + +/// The expected (project-root-relative) `replace` target path for a module +/// copy. Always `./`-prefixed and forward-slashed: Go treats a replacement +/// target as a *filesystem path* only when it begins with `./`, `../`, or `/` +/// (otherwise it is parsed as a module path), and accepts forward slashes on +/// every platform. +pub fn expected_replace_path(module: &str, version: &str) -> String { + format!("./{GO_PATCHES_DIR}/{module}@{version}") +} + +/// One parsed `replace` directive. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReplaceEntry { + /// Left-hand-side module path. + pub module: String, + /// Left-hand-side version, or `None` for a version-less `replace M => ...`. + pub version: Option, + /// Right-hand-side path, iff the replacement is a filesystem path + /// (`None` for a module-to-module `=> mod ver` replacement). + pub path: Option, + /// True iff `path` is under `.socket/go-patches/`. + pub socket_owned: bool, +} + +// ── public async API ───────────────────────────────────────────────────────── + +/// Read all `replace` directives. Read-only; a missing/unreadable `go.mod` +/// yields an empty vec (callers treat that as "no managed entries"). +pub async fn read_replace_entries(project_root: &Path) -> Vec { + match fs::read_to_string(go_mod_path(project_root)).await { + Ok(content) => parse_replace_entries(&content), + Err(_) => Vec::new(), + } +} + +/// Resolved versions from the `require` directives, keyed by module path. Used +/// for the version cross-check (a socket `replace` pinned to a version the +/// module graph no longer selects is silently unused). `None` ⇒ no/unreadable +/// `go.mod` ⇒ skip the check (mirrors cargo's `read_locked_versions`). +pub async fn read_required_versions(project_root: &Path) -> Option> { + let content = fs::read_to_string(go_mod_path(project_root)).await.ok()?; + Some(parse_required_versions(&content)) +} + +/// Upsert a socket-owned `replace => ./.socket/go-patches/@`. +/// Idempotent. Returns whether the file changed. Errors (without writing) if a +/// `go.mod` is absent, or if a *user-authored* `replace` already pins the same +/// `module`+`version` (a duplicate would make `go.mod` invalid). +pub async fn ensure_replace_entry( + project_root: &Path, + module: &str, + version: &str, + dry_run: bool, +) -> Result { + edit_go_mod(project_root, dry_run, |c| { + upsert_replace_entry(c, module, version) + }) + .await +} + +/// Remove the *socket-owned* `replace` directive(s) for `module` (pruning an +/// emptied `replace ( … )` block). A user-authored or absent entry is a no-op. +/// Returns whether the file changed. +pub async fn drop_replace_entry( + project_root: &Path, + module: &str, + dry_run: bool, +) -> Result { + edit_go_mod(project_root, dry_run, |c| remove_replace_entry(c, module)).await +} + +// ── file resolution + read/write ────────────────────────────────────────────── + +fn go_mod_path(project_root: &Path) -> PathBuf { + project_root.join("go.mod") +} + +/// Apply a pure transform to `go.mod`, writing only if it changed and +/// `!dry_run`. Unlike `.cargo/config.toml`, a `go.mod` is **required** to exist +/// (it defines the module): a missing file is an error, not an empty start. +async fn edit_go_mod( + project_root: &Path, + dry_run: bool, + transform: impl FnOnce(&str) -> Result, String>, +) -> Result { + let path = go_mod_path(project_root); + let content = fs::read_to_string(&path) + .await + .map_err(|e| format!("read {}: {e}", path.display()))?; + match transform(&content)? { + None => Ok(false), + Some(new) => { + if !dry_run { + fs::write(&path, new) + .await + .map_err(|e| format!("write {}: {e}", path.display()))?; + } + Ok(true) + } + } +} + +// ── parsing ──────────────────────────────────────────────────────────────── + +/// Strip a trailing `// …` line comment. Module paths and our `./…` targets +/// never contain `//`, so the first occurrence is the comment. +fn strip_comment(line: &str) -> &str { + match line.find("//") { + Some(idx) => &line[..idx], + None => line, + } +} + +/// True if a replacement RHS token is a filesystem path (vs a module path). +/// Go's rule: a path begins with `./`, `../`, `/`, or a Windows drive/`\`. +fn rhs_is_path(tok: &str) -> bool { + tok.starts_with("./") + || tok.starts_with("../") + || tok.starts_with('/') + || tok.starts_with(".\\") + || tok.starts_with("..\\") + || (tok.len() >= 2 && tok.as_bytes()[1] == b':') // C:\… +} + +/// True if a `replace` target path lies under `.socket/go-patches/`. +fn path_is_socket_owned(path: &str) -> bool { + let norm = path.replace('\\', "/"); + let norm = norm.strip_prefix("./").unwrap_or(&norm); + let prefix = format!("{GO_PATCHES_DIR}/"); + norm.starts_with(&prefix) || norm.contains(&format!("/{prefix}")) +} + +/// Parse the `module path => target [version]` body of a replace directive +/// (the part after the `replace` keyword, or a line inside a `replace ( … )` +/// block). Returns `None` if there is no `=>` (not a replace body). +fn parse_replace_body(body: &str) -> Option { + let (lhs, rhs) = body.split_once("=>")?; + let lhs: Vec<&str> = lhs.split_whitespace().collect(); + let rhs: Vec<&str> = rhs.split_whitespace().collect(); + let module = (*lhs.first()?).to_string(); + let version = lhs.get(1).map(|s| s.to_string()); + let first_rhs = rhs.first()?; + let (path, socket_owned) = if rhs_is_path(first_rhs) { + let p = (*first_rhs).to_string(); + let owned = path_is_socket_owned(&p); + (Some(p), owned) + } else { + (None, false) // module-to-module replacement + }; + Some(ReplaceEntry { + module, + version, + path, + socket_owned, + }) +} + +/// Parse every `replace` directive (single-line and block forms). +pub fn parse_replace_entries(content: &str) -> Vec { + let mut out = Vec::new(); + let mut in_block = false; + for raw in content.lines() { + let line = strip_comment(raw).trim(); + if line.is_empty() { + continue; + } + if in_block { + if line == ")" { + in_block = false; + continue; + } + if let Some(e) = parse_replace_body(line) { + out.push(e); + } + continue; + } + if let Some(rest) = directive_block_open(line, "replace") { + if rest { + in_block = true; + } + continue; + } + if let Some(body) = line.strip_prefix("replace ") { + if let Some(e) = parse_replace_body(body) { + out.push(e); + } + } + } + out +} + +/// Parse `require` directives into `module -> version` (last wins; the module +/// graph selects one version per module path). +pub fn parse_required_versions(content: &str) -> HashMap { + let mut out = HashMap::new(); + let mut in_block = false; + for raw in content.lines() { + let line = strip_comment(raw).trim(); + if line.is_empty() { + continue; + } + if in_block { + if line == ")" { + in_block = false; + continue; + } + insert_require(&mut out, line); + continue; + } + if let Some(rest) = directive_block_open(line, "require") { + if rest { + in_block = true; + } + continue; + } + if let Some(body) = line.strip_prefix("require ") { + insert_require(&mut out, body); + } + } + out +} + +fn insert_require(out: &mut HashMap, body: &str) { + let toks: Vec<&str> = body.split_whitespace().collect(); + if let (Some(m), Some(v)) = (toks.first(), toks.get(1)) { + out.insert((*m).to_string(), (*v).to_string()); + } +} + +/// For a directive keyword (`replace`/`require`), classify a line: +/// * `Some(true)` — opens a `keyword (` block, +/// * `Some(false)` — a bare `keyword (` … `)` is not this (e.g. `keyword (` only +/// matches the open form); returns `Some(false)` for `keyword ()` empties, +/// * `None` — the line is not a block opener for this keyword. +fn directive_block_open(line: &str, keyword: &str) -> Option { + // `replace (` / `replace(` + let rest = line.strip_prefix(keyword)?; + let rest = rest.trim_start(); + if rest == "(" { + return Some(true); + } + if rest == "()" { + return Some(false); // empty inline block — nothing inside + } + None +} + +// ── pure transforms ────────────────────────────────────────────────────────── + +/// Upsert a socket-owned `replace module version => ./…@version`. +fn upsert_replace_entry( + content: &str, + module: &str, + version: &str, +) -> Result, String> { + let want_path = expected_replace_path(module, version); + let want_line = format!("replace {module} {version} => {want_path}"); + + let mut lines: Vec = content.lines().map(str::to_string).collect(); + + // Locate an existing socket-owned replace line for `module`, and detect a + // conflicting user-authored replace pinning the same module+version. + let mut socket_line: Option = None; + let mut in_block = false; + for (i, raw) in lines.iter().enumerate() { + let line = strip_comment(raw).trim(); + if line.is_empty() { + continue; + } + if in_block { + if line == ")" { + in_block = false; + continue; + } + inspect_existing(line, module, version, &want_path, i, &mut socket_line)?; + continue; + } + if let Some(opened) = directive_block_open(line, "replace") { + in_block = opened; + continue; + } + if let Some(body) = line.strip_prefix("replace ") { + inspect_existing(body, module, version, &want_path, i, &mut socket_line)?; + } + } + + if let Some(idx) = socket_line { + // Rewrite the existing socket-owned line in place, preserving whether it + // was a block member (`\tmodule … => …`) or a single-line `replace …`. + let raw = &lines[idx]; + let indent: String = raw.chars().take_while(|c| c.is_whitespace()).collect(); + let is_block_member = !strip_comment(raw).trim_start().starts_with("replace "); + let new = if is_block_member { + format!("{indent}{module} {version} => {want_path}") + } else { + format!("{indent}{want_line}") + }; + if lines[idx] == new { + return Ok(None); + } + lines[idx] = new; + return Ok(Some(join_preserving_trailing_newline(&lines, content))); + } + + // No socket-owned entry yet → append a single-line directive. + let mut body = content.to_string(); + if !body.is_empty() && !body.ends_with('\n') { + body.push('\n'); + } + body.push_str(&want_line); + body.push('\n'); + Ok(Some(body)) +} + +/// Inspect an existing replace `body` (after `replace `, or a block line) for +/// the target `module`: record a socket-owned match (to refresh) or reject a +/// user-authored same-version pin (a duplicate would be invalid go.mod). +fn inspect_existing( + body: &str, + module: &str, + version: &str, + want_path: &str, + line_idx: usize, + socket_line: &mut Option, +) -> Result<(), String> { + let Some(e) = parse_replace_body(body) else { + return Ok(()); + }; + if e.module != module { + return Ok(()); + } + if e.socket_owned { + // Our entry (any version): refresh it. Prefer the first one found. + if socket_line.is_none() { + *socket_line = Some(line_idx); + } + return Ok(()); + } + // A user-authored replace for the same module. Only the *same version* + // (or a version-less catch-all) collides with the directive we want to add. + let same_version = e.version.as_deref() == Some(version) || e.version.is_none(); + if same_version && e.path.as_deref() != Some(want_path) { + return Err(format!( + "go.mod already has a user-authored `replace {module}{}` => {}; \ + refusing to overwrite", + e.version + .as_deref() + .map(|v| format!(" {v}")) + .unwrap_or_default(), + e.path.as_deref().unwrap_or("") + )); + } + Ok(()) +} + +/// Remove socket-owned `replace` directive(s) for `module`, pruning an emptied +/// `replace ( … )` block. +fn remove_replace_entry(content: &str, module: &str) -> Result, String> { + let lines: Vec<&str> = content.lines().collect(); + let mut keep = vec![true; lines.len()]; + + // Track block extents so we can prune a block that becomes empty. + let mut i = 0; + let mut changed = false; + while i < lines.len() { + let line = strip_comment(lines[i]).trim(); + if directive_block_open(line, "replace") == Some(true) { + // Block spans [i, close]; mark socket-owned members for removal. + let open = i; + let mut close = i; + let mut members_total = 0usize; + let mut members_removed = 0usize; + let mut j = i + 1; + while j < lines.len() { + let inner = strip_comment(lines[j]).trim(); + if inner == ")" { + close = j; + break; + } + if !inner.is_empty() { + members_total += 1; + if let Some(e) = parse_replace_body(inner) { + if e.module == module && e.socket_owned { + keep[j] = false; + members_removed += 1; + changed = true; + } + } + } + close = j; + j += 1; + } + // If every member was removed, drop the whole block (open + close). + if members_total > 0 && members_removed == members_total { + keep[open] = false; + if close < lines.len() { + keep[close] = false; + } + } + i = close + 1; + continue; + } + if let Some(body) = line.strip_prefix("replace ") { + if let Some(e) = parse_replace_body(body) { + if e.module == module && e.socket_owned { + keep[i] = false; + changed = true; + } + } + } + i += 1; + } + + if !changed { + return Ok(None); + } + + let kept: Vec = lines + .iter() + .zip(keep) + .filter(|(_, k)| *k) + .map(|(l, _)| l.to_string()) + .collect(); + Ok(Some(join_preserving_trailing_newline(&kept, content))) +} + +/// Re-join lines, restoring a trailing newline iff the original had one. +fn join_preserving_trailing_newline(lines: &[String], original: &str) -> String { + let mut out = lines.join("\n"); + if original.ends_with('\n') { + out.push('\n'); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── path ownership ─────────────────────────────────────────────── + #[test] + fn test_is_socket_owned() { + assert!(path_is_socket_owned("./.socket/go-patches/github.com/x/y@v1.0.0")); + assert!(path_is_socket_owned(".socket/go-patches/x@v1.0.0")); + assert!(path_is_socket_owned("sub/.socket/go-patches/x@v1.0.0")); + assert!(!path_is_socket_owned("../fork")); + assert!(!path_is_socket_owned("./vendor/x")); + assert!(!path_is_socket_owned("/abs/.socketX/go-patches/x")); + } + + #[test] + fn test_rhs_is_path() { + assert!(rhs_is_path("./local")); + assert!(rhs_is_path("../local")); + assert!(rhs_is_path("/abs")); + assert!(!rhs_is_path("example.com/mod")); + assert!(!rhs_is_path("github.com/x/y")); + } + + #[test] + fn test_expected_path() { + assert_eq!( + expected_replace_path("github.com/foo/bar", "v1.4.2"), + "./.socket/go-patches/github.com/foo/bar@v1.4.2" + ); + } + + // ── parse ──────────────────────────────────────────────────────── + #[test] + fn test_parse_single_and_block() { + let gomod = "\ +module example.com/app + +go 1.21 + +require ( +\tgithub.com/foo/bar v1.4.2 +\texample.com/baz v2.0.0 // indirect +) + +replace github.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2 + +replace ( +\texample.com/baz v2.0.0 => ../local-baz +\texample.com/qux => example.com/qux-fork v1.1.0 +) +"; + let entries = parse_replace_entries(gomod); + assert_eq!(entries.len(), 3); + let bar = entries.iter().find(|e| e.module == "github.com/foo/bar").unwrap(); + assert!(bar.socket_owned); + assert_eq!(bar.version.as_deref(), Some("v1.4.2")); + let baz = entries.iter().find(|e| e.module == "example.com/baz").unwrap(); + assert!(!baz.socket_owned); + assert_eq!(baz.path.as_deref(), Some("../local-baz")); + let qux = entries.iter().find(|e| e.module == "example.com/qux").unwrap(); + assert!(!qux.socket_owned); + assert_eq!(qux.path, None, "module-to-module replacement has no path"); + + let req = parse_required_versions(gomod); + assert_eq!(req.get("github.com/foo/bar").map(String::as_str), Some("v1.4.2")); + assert_eq!(req.get("example.com/baz").map(String::as_str), Some("v2.0.0")); + } + + #[test] + fn test_parse_require_single() { + let gomod = "module m\n\ngo 1.21\n\nrequire github.com/x/y v1.0.0\n"; + let req = parse_required_versions(gomod); + assert_eq!(req.get("github.com/x/y").map(String::as_str), Some("v1.0.0")); + } + + // ── upsert ─────────────────────────────────────────────────────── + #[test] + fn test_upsert_appends_single_line() { + let gomod = "module example.com/app\n\ngo 1.21\n\nrequire github.com/foo/bar v1.4.2\n"; + let out = upsert_replace_entry(gomod, "github.com/foo/bar", "v1.4.2") + .unwrap() + .unwrap(); + assert!(out.contains( + "replace github.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2" + )); + // Original content preserved. + assert!(out.contains("require github.com/foo/bar v1.4.2")); + assert!(out.ends_with('\n')); + // Idempotent. + assert!(upsert_replace_entry(&out, "github.com/foo/bar", "v1.4.2") + .unwrap() + .is_none()); + } + + #[test] + fn test_upsert_refreshes_socket_owned_version_bump_single_line() { + let gomod = "module m\n\nreplace github.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2\n"; + let out = upsert_replace_entry(gomod, "github.com/foo/bar", "v1.5.0") + .unwrap() + .unwrap(); + assert!(out.contains( + "replace github.com/foo/bar v1.5.0 => ./.socket/go-patches/github.com/foo/bar@v1.5.0" + )); + assert!(!out.contains("bar@v1.4.2"), "old version line gone"); + // Exactly one replace for the module. + assert_eq!(parse_replace_entries(&out).iter().filter(|e| e.module == "github.com/foo/bar").count(), 1); + } + + #[test] + fn test_upsert_refreshes_socket_owned_inside_block() { + let gomod = "module m\n\nreplace (\n\tgithub.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2\n)\n"; + let out = upsert_replace_entry(gomod, "github.com/foo/bar", "v1.5.0") + .unwrap() + .unwrap(); + // Still a block member (indented, no `replace ` keyword), version bumped. + assert!(out.contains("\tgithub.com/foo/bar v1.5.0 => ./.socket/go-patches/github.com/foo/bar@v1.5.0")); + assert!(out.contains("replace (")); + } + + #[test] + fn test_upsert_refuses_user_authored_same_version() { + let gomod = "module m\n\nreplace github.com/foo/bar v1.4.2 => ../fork\n"; + assert!(upsert_replace_entry(gomod, "github.com/foo/bar", "v1.4.2").is_err()); + } + + #[test] + fn test_upsert_allows_user_replace_at_different_version() { + // User pins a DIFFERENT version → no conflict; ours is added alongside. + let gomod = "module m\n\nreplace github.com/foo/bar v1.0.0 => ../fork\n"; + let out = upsert_replace_entry(gomod, "github.com/foo/bar", "v1.4.2") + .unwrap() + .unwrap(); + assert!(out.contains("replace github.com/foo/bar v1.0.0 => ../fork")); + assert!(out.contains("replace github.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2")); + } + + #[test] + fn test_upsert_refuses_versionless_user_catchall() { + let gomod = "module m\n\nreplace github.com/foo/bar => ../fork\n"; + assert!(upsert_replace_entry(gomod, "github.com/foo/bar", "v1.4.2").is_err()); + } + + #[test] + fn test_upsert_allows_versionless_catchall_for_different_module() { + // A user's version-less catch-all for a DIFFERENT module must not block + // (or be touched by) our replace for github.com/foo/bar. + let gomod = "module m\n\nreplace example.com/other => ../other-fork\n"; + let out = upsert_replace_entry(gomod, "github.com/foo/bar", "v1.4.2") + .unwrap() + .unwrap(); + assert!(out.contains("replace example.com/other => ../other-fork"), "user catch-all preserved"); + assert!(out.contains( + "replace github.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2" + )); + let entries = parse_replace_entries(&out); + assert!(entries.iter().any(|e| e.module == "example.com/other" && !e.socket_owned)); + assert!(entries.iter().any(|e| e.module == "github.com/foo/bar" && e.socket_owned)); + } + + // ── remove ─────────────────────────────────────────────────────── + #[test] + fn test_remove_single_line() { + let gomod = "module m\n\nreplace github.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2\n"; + let out = remove_replace_entry(gomod, "github.com/foo/bar") + .unwrap() + .unwrap(); + assert!(!out.contains("go-patches")); + assert!(out.contains("module m")); + } + + #[test] + fn test_remove_block_member_prunes_empty_block() { + let gomod = "module m\n\nreplace (\n\tgithub.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2\n)\n"; + let out = remove_replace_entry(gomod, "github.com/foo/bar") + .unwrap() + .unwrap(); + assert!(!out.contains("go-patches")); + assert!(!out.contains("replace ("), "emptied block pruned"); + } + + #[test] + fn test_remove_block_keeps_other_members() { + let gomod = "module m\n\nreplace (\n\tgithub.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2\n\texample.com/baz v2.0.0 => ../local-baz\n)\n"; + let out = remove_replace_entry(gomod, "github.com/foo/bar") + .unwrap() + .unwrap(); + assert!(!out.contains("go-patches")); + assert!(out.contains("replace ("), "block kept (still has a member)"); + assert!(out.contains("example.com/baz v2.0.0 => ../local-baz")); + } + + #[test] + fn test_remove_leaves_user_replace() { + let gomod = "module m\n\nreplace github.com/foo/bar v1.4.2 => ../fork\n"; + assert!(remove_replace_entry(gomod, "github.com/foo/bar").unwrap().is_none()); + } + + #[test] + fn test_remove_absent_is_noop() { + assert!(remove_replace_entry("module m\n\ngo 1.21\n", "github.com/foo/bar") + .unwrap() + .is_none()); + } + + // ── async round-trip ───────────────────────────────────────────── + #[tokio::test] + async fn test_ensure_then_read_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("go.mod"), + "module example.com/app\n\ngo 1.21\n\nrequire github.com/foo/bar v1.4.2\n", + ) + .await + .unwrap(); + + assert!(ensure_replace_entry(dir.path(), "github.com/foo/bar", "v1.4.2", false) + .await + .unwrap()); + let entries = read_replace_entries(dir.path()).await; + let bar = entries.iter().find(|e| e.module == "github.com/foo/bar").unwrap(); + assert!(bar.socket_owned); + assert_eq!( + bar.path.as_deref(), + Some("./.socket/go-patches/github.com/foo/bar@v1.4.2") + ); + // Required-version cross-check source. + let req = read_required_versions(dir.path()).await.unwrap(); + assert_eq!(req.get("github.com/foo/bar").map(String::as_str), Some("v1.4.2")); + + // Idempotent on disk. + assert!(!ensure_replace_entry(dir.path(), "github.com/foo/bar", "v1.4.2", false) + .await + .unwrap()); + // Drop. + assert!(drop_replace_entry(dir.path(), "github.com/foo/bar", false) + .await + .unwrap()); + assert!(read_replace_entries(dir.path()).await.is_empty()); + } + + #[tokio::test] + async fn test_ensure_dry_run_does_not_write() { + let dir = tempfile::tempdir().unwrap(); + let body = "module m\n\ngo 1.21\n"; + fs::write(dir.path().join("go.mod"), body).await.unwrap(); + let changed = ensure_replace_entry(dir.path(), "github.com/foo/bar", "v1.4.2", true) + .await + .unwrap(); + assert!(changed, "dry-run reports the change it would make"); + assert_eq!( + fs::read_to_string(dir.path().join("go.mod")).await.unwrap(), + body, + "dry-run must not write" + ); + } + + #[tokio::test] + async fn test_ensure_missing_go_mod_errors() { + let dir = tempfile::tempdir().unwrap(); + assert!(ensure_replace_entry(dir.path(), "github.com/foo/bar", "v1.4.2", false) + .await + .is_err()); + } +} diff --git a/crates/socket-patch-core/src/patch/go_redirect.rs b/crates/socket-patch-core/src/patch/go_redirect.rs new file mode 100644 index 0000000..67ed0e5 --- /dev/null +++ b/crates/socket-patch-core/src/patch/go_redirect.rs @@ -0,0 +1,891 @@ +//! Project-local Go `replace`-redirect engine (local mode only). +//! +//! The Go analog of [`crate::patch::cargo_redirect`]. Instead of patching +//! modules in place in the shared, read-only, checksum-verified module cache +//! (the `--global` path), this materialises a project-local **patched copy** of +//! each module under `/.socket/go-patches/@/` and points +//! the build at it with a `replace` directive in `/go.mod`: +//! +//! ```text +//! replace github.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2 +//! ``` +//! +//! Patches become project-scoped, the module cache stays pristine (so +//! `go mod verify` keeps passing and other projects are unaffected), and removal +//! is clean (drop the directive → the build falls back to the cache). A +//! local-path `replace` target is **not** `go.sum` content-verified, so the +//! patched bytes build cleanly under the default `-mod=readonly` (validated +//! empirically — see project memory). +//! +//! The copy is produced by **delegating to the hardened +//! [`apply_package_patch`] pipeline** pointed at the fresh copy, reusing all the +//! verify → package/diff/blob → atomic-write machinery unchanged. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +use crate::manifest::schema::{PatchFileInfo, PatchManifest}; +use crate::patch::apply::{ + apply_package_patch, normalize_file_path, ApplyResult, PatchSources, VerifyResult, VerifyStatus, +}; +use crate::patch::file_hash::compute_file_git_sha256; +use crate::utils::purl::{build_golang_purl, parse_golang_purl}; + +use super::copy_tree::{fresh_copy, remove_tree}; +use super::go_mod_edit::{ + self, expected_replace_path, read_replace_entries, read_required_versions, GO_PATCHES_DIR, +}; + +/// A discrepancy between the committed redirect artifacts and the manifest, +/// reported by [`verify_go_redirect_state`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Drift { + /// No patched-copy directory exists for an in-scope PURL. + MissingCopy { purl: String }, + /// A patched file in the copy does not hash to its manifest `afterHash` + /// (`found` is `None` when the file is missing/unreadable). + StaleCopy { + purl: String, + file: String, + expected: String, + found: Option, + }, + /// No managed `replace` directive exists for an in-scope PURL. + MissingReplace { purl: String }, + /// A socket-owned `replace` directive exists but pins a different + /// version / points at a different copy than the manifest desires. Go keys + /// `replace` by module path **and version**: a directive pinned to the + /// wrong version is silently ignored and the build links the UNPATCHED + /// module, while the copy-hash checks still pass. + WrongReplacePath { + purl: String, + expected: String, + found: Option, + }, + /// A socket-owned `replace` directive exists with no desired PURL. + OrphanReplace { module: String }, + /// `go.mod`'s `require` set resolves this module to a version that does NOT + /// match the patched version, so the version-pinned `replace` is unused and + /// the build silently links the UNPATCHED module. + ResolvedVersionMismatch { + purl: String, + patched_version: String, + required_version: String, + }, +} + +impl std::fmt::Display for Drift { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Drift::MissingCopy { purl } => write!(f, "missing patched copy for {purl}"), + Drift::StaleCopy { + purl, + file, + expected, + found, + } => write!( + f, + "stale copy for {purl}: {file} expected {expected}, found {}", + found.as_deref().unwrap_or("") + ), + Drift::MissingReplace { purl } => write!(f, "missing go.mod `replace` for {purl}"), + Drift::WrongReplacePath { + purl, + expected, + found, + } => write!( + f, + "go.mod `replace` for {purl} points at {} but should be {expected} \ + — go would ignore it and link the UNPATCHED module", + found.as_deref().unwrap_or("") + ), + Drift::OrphanReplace { module } => write!( + f, + "orphan go.mod `replace` for `{module}` (no patch in manifest)" + ), + Drift::ResolvedVersionMismatch { + purl, + patched_version, + required_version, + } => write!( + f, + "{purl}: patched version {patched_version} is not the required version \ + (go.mod requires {required_version}) — go would link the UNPATCHED module" + ), + } + } +} + +/// The project-relative copy dir for a module. `module` carries the real +/// (decoded) module path with `/`-separators, so the on-disk layout mirrors the +/// module cache (`github.com/foo/bar@v1.4.2`). +fn copy_dir_for(project_root: &Path, module: &str, version: &str) -> PathBuf { + project_root + .join(GO_PATCHES_DIR) + .join(format!("{module}@{version}")) +} + +/// Materialise a project-local patched copy and wire up the `replace` redirect. +/// +/// * `pristine_src` — the pristine module-cache source dir (the crawler's +/// `pkg_path`, case-encoded on disk). It is copied, never mutated. +/// * `module` / `version` — the **decoded** module path + version (from the +/// PURL); they key both the copy dir and the `replace` directive. +#[allow(clippy::too_many_arguments)] +pub async fn apply_go_redirect( + purl: &str, + module: &str, + version: &str, + pristine_src: &Path, + project_root: &Path, + files: &HashMap, + sources: &PatchSources<'_>, + uuid: Option<&str>, + dry_run: bool, + force: bool, +) -> ApplyResult { + let copy_dir = copy_dir_for(project_root, module, version); + + // A redirect with no files to patch is meaningless: no-op success, no + // go.mod edit. + if files.is_empty() { + return synthesized_result(purl, ©_dir, Vec::new(), true, None); + } + + if dry_run { + // Verify (read-only) against the pristine source for an accurate + // "would patch" report, without creating the copy or editing go.mod. + let mut result = + apply_package_patch(purl, pristine_src, files, sources, uuid, true, force).await; + result.package_path = copy_dir.display().to_string(); + result.sidecar = None; // a replace copy is not the cache (no go.sum advisory) + return result; + } + + // Hot path: already in sync → touch nothing, so the build's source + // fingerprint stays stable across repeated applies (the guard re-runs apply + // on most "deps changed" builds). + if redirect_in_sync(©_dir, files, project_root, module, version).await { + let verified = files.keys().map(|f| already_patched_verify(f)).collect(); + return synthesized_result(purl, ©_dir, verified, true, None); + } + + // Fresh copy pristine → copy_dir. + if let Err(e) = fresh_copy(pristine_src, ©_dir, None).await { + return synthesized_result( + purl, + ©_dir, + Vec::new(), + false, + Some(format!("failed to copy pristine source: {e}")), + ); + } + + // A `replace` target must be a valid module: it needs a go.mod declaring the + // module path. Pre-modules packages have none in their extracted cache dir + // (validated: `gopkg.in/inf.v0`), so synthesize Go's own minimal form. + if let Err(e) = ensure_module_go_mod(©_dir, module).await { + let _ = remove_tree(©_dir).await; + return synthesized_result( + purl, + ©_dir, + Vec::new(), + false, + Some(format!("failed to synthesize go.mod for the copy: {e}")), + ); + } + + // Delegate to the hardened pipeline, pointed at the copy. + let mut result = apply_package_patch(purl, ©_dir, files, sources, uuid, false, force).await; + result.package_path = copy_dir.display().to_string(); + // The golang sidecar advisory ("go mod verify will fail against go.sum") + // is about in-cache patching; a `replace` copy bypasses go.sum entirely, so + // the advisory does not apply here — drop it. + result.sidecar = None; + + if !result.success { + // Don't leave a half-built copy that verify/reconcile would misjudge. + let _ = remove_tree(©_dir).await; + return result; + } + + // Wire up the `replace` directive. Load-bearing: without it the build won't + // redirect to the copy, so a failure here fails the apply. + if let Err(e) = go_mod_edit::ensure_replace_entry(project_root, module, version, false).await { + result.success = false; + result.error = Some(format!("failed to update go.mod: {e}")); + return result; + } + + result +} + +/// Drop the managed `replace` directive + patched copy for a golang PURL. +pub async fn remove_go_redirect( + purl: &str, + project_root: &Path, + dry_run: bool, +) -> Result<(), std::io::Error> { + let (module, version) = parse_golang_purl(purl).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("not a golang purl: {purl}"), + ) + })?; + + go_mod_edit::drop_replace_entry(project_root, module, dry_run) + .await + .map_err(std::io::Error::other)?; + + if !dry_run { + let copy_dir = copy_dir_for(project_root, module, version); + let _ = remove_tree(©_dir).await; // ignore NotFound + } + Ok(()) +} + +/// Prune socket-owned `replace` directives + copy dirs no longer in `desired` +/// (patches dropped from the manifest). Returns the removed PURLs. +pub async fn reconcile_go_redirects( + project_root: &Path, + desired: &HashSet, + dry_run: bool, +) -> Vec { + let desired_modules: HashSet<&str> = desired + .iter() + .filter_map(|p| parse_golang_purl(p).map(|(m, _)| m)) + .collect(); + + let mut removed: Vec = Vec::new(); + + // (a) Orphan socket-owned `replace` directives (module no longer patched). + for entry in read_replace_entries(project_root).await { + if entry.socket_owned && !desired_modules.contains(entry.module.as_str()) { + let _ = go_mod_edit::drop_replace_entry(project_root, &entry.module, dry_run).await; + if let Some(v) = &entry.version { + let purl = build_golang_purl(&entry.module, v); + if !removed.contains(&purl) { + removed.push(purl); + } + } + } + } + + // (b) Orphan copy dirs not referenced by a desired PURL (catches copies left + // behind by a hand-deleted directive or a version bump). + for (purl, dir) in collect_copy_modules(&project_root.join(GO_PATCHES_DIR)).await { + if !desired.contains(&purl) { + if !dry_run { + let _ = remove_tree(&dir).await; + } + if !removed.contains(&purl) { + removed.push(purl); + } + } + } + + removed +} + +/// Registry-independent verification for `apply --check` (CI / GitHub-App +/// auditing + the build-time guard probe). Reads **only** the manifest, the +/// committed copies, and `go.mod` — never the module cache, no network — so it +/// works on a fresh clone / airgapped CI. +/// +/// Version cross-check limitation: the resolved-version comparison uses +/// `go.mod`'s `require` directives. After `go mod tidy` these list the selected +/// version of every module that provides an imported package (direct *and* +/// `// indirect`), so the common cases are covered. A patched module that is +/// **transitive-only and absent from `require`** cannot be version-checked here +/// (full MVS needs the toolchain); such a patch falling stale relies on the +/// build-time guard (which runs where `go` is present) for eventual detection. +pub async fn verify_go_redirect_state( + project_root: &Path, + manifest: &PatchManifest, + desired: &HashSet, +) -> Result<(), Vec> { + let mut drifts = Vec::new(); + let entries = read_replace_entries(project_root).await; + // Required versions from go.mod (None ⇒ no go.mod ⇒ skip the version + // cross-check). Read once, project-local, offline. + let required = read_required_versions(project_root).await; + let desired_modules: HashSet<&str> = desired + .iter() + .filter_map(|p| parse_golang_purl(p).map(|(m, _)| m)) + .collect(); + + for purl in desired { + let Some((module, version)) = parse_golang_purl(purl) else { + continue; + }; + let Some(record) = manifest.patches.get(purl) else { + continue; + }; + + // go.mod `require` cross-check: if the graph resolves this module to a + // version that is NOT the patched one, the version-pinned `replace` is + // unused and the build links the unpatched module — a silent-stale hole + // the copy/directive checks below can't see. (A module absent from + // `require` is harmless — it isn't built — so only flag a + // present-but-different resolution.) + if let Some(req) = required.as_ref().and_then(|r| r.get(module)) { + if req != version { + drifts.push(Drift::ResolvedVersionMismatch { + purl: purl.clone(), + patched_version: version.to_string(), + required_version: req.clone(), + }); + } + } + + let copy_dir = copy_dir_for(project_root, module, version); + if tokio::fs::metadata(©_dir).await.is_err() { + drifts.push(Drift::MissingCopy { purl: purl.clone() }); + } else { + for (file_name, info) in &record.files { + let path = copy_dir.join(normalize_file_path(file_name)); + match compute_file_git_sha256(&path).await { + Ok(h) if h == info.after_hash => {} + Ok(h) => drifts.push(Drift::StaleCopy { + purl: purl.clone(), + file: file_name.clone(), + expected: info.after_hash.clone(), + found: Some(h), + }), + Err(_) => drifts.push(Drift::StaleCopy { + purl: purl.clone(), + file: file_name.clone(), + expected: info.after_hash.clone(), + found: None, + }), + } + } + } + + // The socket-owned `replace` must exist AND pin THIS version's copy. Go + // keys `replace` by module + version, so a socket directive pinned to + // another version (an aborted/partial apply, a bad merge, a hand-edit) + // is silently ignored while the copy-hash checks above pass. + let expected = expected_replace_path(module, version); + let socket = entries + .iter() + .find(|e| e.module == module && e.socket_owned); + match socket { + Some(e) if e.path.as_deref() == Some(expected.as_str()) + && e.version.as_deref() == Some(version) => {} + Some(e) => drifts.push(Drift::WrongReplacePath { + purl: purl.clone(), + expected, + found: e.path.clone(), + }), + None => drifts.push(Drift::MissingReplace { purl: purl.clone() }), + } + } + + for entry in &entries { + if entry.socket_owned && !desired_modules.contains(entry.module.as_str()) { + drifts.push(Drift::OrphanReplace { + module: entry.module.clone(), + }); + } + } + + if drifts.is_empty() { + Ok(()) + } else { + Err(drifts) + } +} + +// ── helpers ────────────────────────────────────────────────────────────────── + +/// True if the copy exists, every patched file in it already hashes to its +/// `afterHash`, and a socket-owned `replace` pins this version's copy. +async fn redirect_in_sync( + copy_dir: &Path, + files: &HashMap, + project_root: &Path, + module: &str, + version: &str, +) -> bool { + if tokio::fs::metadata(copy_dir).await.is_err() { + return false; + } + for (file_name, info) in files { + let path = copy_dir.join(normalize_file_path(file_name)); + match compute_file_git_sha256(&path).await { + Ok(h) if h == info.after_hash => {} + _ => return false, + } + } + let expected = expected_replace_path(module, version); + read_replace_entries(project_root).await.iter().any(|e| { + e.module == module + && e.socket_owned + && e.path.as_deref() == Some(expected.as_str()) + && e.version.as_deref() == Some(version) + }) +} + +/// Synthesize Go's minimal `go.mod` (`module `) in the copy iff it has +/// none — required for a `replace` target derived from a pre-modules package. +async fn ensure_module_go_mod(copy_dir: &Path, module: &str) -> std::io::Result<()> { + let go_mod = copy_dir.join("go.mod"); + if tokio::fs::metadata(&go_mod).await.is_ok() { + return Ok(()); + } + tokio::fs::write(&go_mod, format!("module {module}\n")).await +} + +fn synthesized_result( + package_key: &str, + copy_dir: &Path, + files_verified: Vec, + success: bool, + error: Option, +) -> ApplyResult { + ApplyResult { + package_key: package_key.to_string(), + package_path: copy_dir.display().to_string(), + success, + files_verified, + files_patched: Vec::new(), + applied_via: HashMap::new(), + error, + sidecar: None, + } +} + +fn already_patched_verify(file: &str) -> VerifyResult { + VerifyResult { + file: file.to_string(), + status: VerifyStatus::AlreadyPatched, + message: None, + current_hash: None, + expected_hash: None, + target_hash: None, + } +} + +/// Recursively find every patched-copy module dir under `go_patches_root`, +/// returning `(purl, dir)`. A module dir is identified by an `@` in its final +/// path component (`github.com/foo/bar@v1.4.2`); recursion stops there (the +/// module's own contents are not scanned). Returns empty if the root is absent. +async fn collect_copy_modules(go_patches_root: &Path) -> Vec<(String, PathBuf)> { + let mut out = Vec::new(); + collect_copy_modules_inner(go_patches_root, String::new(), &mut out).await; + out +} + +fn collect_copy_modules_inner<'a>( + dir: &'a Path, + prefix: String, + out: &'a mut Vec<(String, PathBuf)>, +) -> std::pin::Pin + 'a>> { + Box::pin(async move { + let mut rd = match tokio::fs::read_dir(dir).await { + Ok(rd) => rd, + Err(_) => return, + }; + while let Ok(Some(entry)) = rd.next_entry().await { + if !entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) { + continue; + } + let name = entry.file_name().to_string_lossy().to_string(); + if let Some(at) = name.rfind('@') { + // `` is the module's final path segment. + let leaf = &name[..at]; + let version = &name[at + 1..]; + let module = if prefix.is_empty() { + leaf.to_string() + } else { + format!("{prefix}/{leaf}") + }; + if !module.is_empty() && !version.is_empty() { + out.push((build_golang_purl(&module, version), entry.path())); + } + // Do not recurse into a module dir. + } else { + let child_prefix = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}/{name}") + }; + collect_copy_modules_inner(&entry.path(), child_prefix, out).await; + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hash::git_sha256::compute_git_sha256_from_bytes; + use std::collections::HashMap; + + const PRISTINE: &[u8] = b"package bar\n\nfunc Hello() string { return \"hi\" }\n"; + const PATCHED: &[u8] = b"package bar\n\nfunc Hello() string { return \"patched\" }\n"; + const MODULE: &str = "github.com/foo/bar"; + const VERSION: &str = "v1.4.2"; + const PURL: &str = "pkg:golang/github.com/foo/bar@v1.4.2"; + + fn git_sha(bytes: &[u8]) -> String { + compute_git_sha256_from_bytes(bytes) + } + + /// Build a pristine module-cache-style dir (with go.mod) and a blobs dir + /// carrying the patched bytes. Returns (tmp, blobs, pristine, files, after). + async fn fixture() -> ( + tempfile::TempDir, + PathBuf, + PathBuf, + HashMap, + String, + ) { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().to_path_buf(); + + let pristine = root.join("cache/github.com/foo/bar@v1.4.2"); + tokio::fs::create_dir_all(&pristine).await.unwrap(); + tokio::fs::write(pristine.join("bar.go"), PRISTINE).await.unwrap(); + tokio::fs::write( + pristine.join("go.mod"), + "module github.com/foo/bar\n\ngo 1.21\n", + ) + .await + .unwrap(); + + let before = git_sha(PRISTINE); + let after = git_sha(PATCHED); + + let blobs = root.join(".socket/blobs"); + tokio::fs::create_dir_all(&blobs).await.unwrap(); + tokio::fs::write(blobs.join(&after), PATCHED).await.unwrap(); + + let mut files = HashMap::new(); + files.insert( + "package/bar.go".to_string(), + PatchFileInfo { + before_hash: before, + after_hash: after.clone(), + }, + ); + + // The project root needs a go.mod for the replace directive. + tokio::fs::write( + root.join("go.mod"), + "module example.com/app\n\ngo 1.21\n\nrequire github.com/foo/bar v1.4.2\n", + ) + .await + .unwrap(); + + (dir, blobs, pristine, files, after) + } + + fn manifest_with(files: &HashMap) -> PatchManifest { + let mut m = PatchManifest::new(); + m.patches.insert( + PURL.to_string(), + crate::manifest::schema::PatchRecord { + uuid: "u".into(), + exported_at: "t".into(), + files: files.clone(), + vulnerabilities: HashMap::new(), + description: String::new(), + license: String::new(), + tier: String::new(), + }, + ); + m + } + + #[tokio::test] + async fn test_apply_redirect_happy_path() { + let (dir, blobs, pristine, files, after) = fixture().await; + let root = dir.path(); + let sources = PatchSources::blobs_only(&blobs); + + let result = apply_go_redirect( + PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false, + ) + .await; + assert!(result.success, "apply failed: {:?}", result.error); + assert!(result.sidecar.is_none(), "replace copy must not emit a sidecar"); + + // Copy exists with patched bytes + a go.mod. + let copy = root.join(".socket/go-patches/github.com/foo/bar@v1.4.2"); + let body = tokio::fs::read(copy.join("bar.go")).await.unwrap(); + assert_eq!(body, PATCHED); + assert_eq!(git_sha(&body), after); + assert!(copy.join("go.mod").exists()); + + // Module cache pristine untouched. + assert_eq!(tokio::fs::read(pristine.join("bar.go")).await.unwrap(), PRISTINE); + + // go.mod replace points at the copy. + let entries = read_replace_entries(root).await; + let e = entries.iter().find(|e| e.module == MODULE).unwrap(); + assert!(e.socket_owned); + assert_eq!( + e.path.as_deref(), + Some("./.socket/go-patches/github.com/foo/bar@v1.4.2") + ); + assert_eq!(e.version.as_deref(), Some(VERSION)); + } + + #[tokio::test] + async fn test_apply_is_idempotent_byte_for_byte() { + let (dir, blobs, pristine, files, _after) = fixture().await; + let root = dir.path(); + let sources = PatchSources::blobs_only(&blobs); + apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false).await; + + let copy = root.join(".socket/go-patches/github.com/foo/bar@v1.4.2/bar.go"); + let gomod = root.join("go.mod"); + let body1 = tokio::fs::read(©).await.unwrap(); + let mod1 = tokio::fs::read_to_string(&gomod).await.unwrap(); + + let result = apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false).await; + assert!(result.success); + assert!(result.files_patched.is_empty(), "in-sync resync patches nothing"); + assert_eq!(tokio::fs::read(©).await.unwrap(), body1, "copy unchanged"); + assert_eq!(tokio::fs::read_to_string(&gomod).await.unwrap(), mod1, "go.mod unchanged"); + } + + #[tokio::test] + async fn test_drift_triggers_rebuild() { + let (dir, blobs, pristine, files, after) = fixture().await; + let root = dir.path(); + let sources = PatchSources::blobs_only(&blobs); + apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false).await; + + let copy = root.join(".socket/go-patches/github.com/foo/bar@v1.4.2/bar.go"); + tokio::fs::write(©, b"corrupted").await.unwrap(); + + let result = apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false).await; + assert!(result.success); + assert_eq!(git_sha(&tokio::fs::read(©).await.unwrap()), after); + } + + #[tokio::test] + async fn test_dry_run_writes_nothing() { + let (dir, blobs, pristine, files, _after) = fixture().await; + let root = dir.path(); + let pristine_gomod = tokio::fs::read_to_string(root.join("go.mod")).await.unwrap(); + let sources = PatchSources::blobs_only(&blobs); + let result = apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, true, false).await; + assert!(result.success); + assert!(!root.join(".socket/go-patches/github.com/foo/bar@v1.4.2").exists()); + // go.mod unchanged (no replace added). + assert_eq!(tokio::fs::read_to_string(root.join("go.mod")).await.unwrap(), pristine_gomod); + } + + #[tokio::test] + async fn test_partial_failure_rolls_back_copy() { + let (dir, _blobs, pristine, files, _after) = fixture().await; + let root = dir.path(); + let empty = root.join(".socket/empty-blobs"); + tokio::fs::create_dir_all(&empty).await.unwrap(); + let sources = PatchSources::blobs_only(&empty); + + let result = apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false).await; + assert!(!result.success); + assert!( + !root.join(".socket/go-patches/github.com/foo/bar@v1.4.2").exists(), + "half-built copy must be rolled back" + ); + // No replace directive written. + assert!(read_replace_entries(root).await.is_empty()); + } + + #[tokio::test] + async fn test_synthesizes_go_mod_for_pre_modules_package() { + let (dir, blobs, pristine, files, _after) = fixture().await; + let root = dir.path(); + // Simulate a pre-modules package: remove the go.mod from the pristine src. + tokio::fs::remove_file(pristine.join("go.mod")).await.unwrap(); + let sources = PatchSources::blobs_only(&blobs); + + let result = apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false).await; + assert!(result.success, "apply failed: {:?}", result.error); + let synthesized = root.join(".socket/go-patches/github.com/foo/bar@v1.4.2/go.mod"); + assert_eq!( + tokio::fs::read_to_string(&synthesized).await.unwrap(), + "module github.com/foo/bar\n" + ); + } + + #[tokio::test] + async fn test_remove_drops_directive_and_copy() { + let (dir, blobs, pristine, files, _after) = fixture().await; + let root = dir.path(); + let sources = PatchSources::blobs_only(&blobs); + apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false).await; + + remove_go_redirect(PURL, root, false).await.unwrap(); + assert!(!root.join(".socket/go-patches/github.com/foo/bar@v1.4.2").exists()); + assert!(read_replace_entries(root).await.is_empty()); + // The require directive (not socket-owned) survives. + assert!(tokio::fs::read_to_string(root.join("go.mod")) + .await + .unwrap() + .contains("require github.com/foo/bar v1.4.2")); + } + + #[tokio::test] + async fn test_reconcile_prunes_orphan() { + let (dir, blobs, pristine, files, _after) = fixture().await; + let root = dir.path(); + let sources = PatchSources::blobs_only(&blobs); + apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false).await; + + let desired: HashSet = HashSet::new(); + let removed = reconcile_go_redirects(root, &desired, false).await; + assert!(removed.contains(&PURL.to_string())); + assert!(!root.join(".socket/go-patches/github.com/foo/bar@v1.4.2").exists()); + assert!(read_replace_entries(root).await.is_empty()); + } + + #[tokio::test] + async fn test_reconcile_keeps_desired_and_user_replaces() { + let (dir, blobs, pristine, files, _after) = fixture().await; + let root = dir.path(); + let sources = PatchSources::blobs_only(&blobs); + apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false).await; + // Add a user-authored replace. + let mut body = tokio::fs::read_to_string(root.join("go.mod")).await.unwrap(); + body.push_str("replace example.com/other v1.0.0 => ../other-fork\n"); + tokio::fs::write(root.join("go.mod"), body).await.unwrap(); + + let desired: HashSet = [PURL.to_string()].into_iter().collect(); + let removed = reconcile_go_redirects(root, &desired, false).await; + assert!(removed.is_empty()); + let entries = read_replace_entries(root).await; + assert!(entries.iter().any(|e| e.module == MODULE && e.socket_owned)); + assert!(entries.iter().any(|e| e.module == "example.com/other" && !e.socket_owned)); + } + + #[tokio::test] + async fn test_verify_state_drift_kinds() { + let (dir, blobs, pristine, files, _after) = fixture().await; + let root = dir.path(); + let sources = PatchSources::blobs_only(&blobs); + apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false).await; + + let manifest = manifest_with(&files); + let desired: HashSet = [PURL.to_string()].into_iter().collect(); + + // Clean → Ok. Registry-independence: delete the pristine source first. + tokio::fs::remove_dir_all(&pristine).await.unwrap(); + assert!(verify_go_redirect_state(root, &manifest, &desired).await.is_ok()); + + // Corrupt a file → StaleCopy. + let copy = root.join(".socket/go-patches/github.com/foo/bar@v1.4.2/bar.go"); + tokio::fs::write(©, b"x").await.unwrap(); + let drifts = verify_go_redirect_state(root, &manifest, &desired).await.unwrap_err(); + assert!(drifts.iter().any(|d| matches!(d, Drift::StaleCopy { .. }))); + + // Delete the copy → MissingCopy (directive still present). + tokio::fs::remove_dir_all(root.join(".socket/go-patches/github.com/foo/bar@v1.4.2")).await.unwrap(); + let drifts = verify_go_redirect_state(root, &manifest, &desired).await.unwrap_err(); + assert!(drifts.iter().any(|d| matches!(d, Drift::MissingCopy { .. }))); + assert!(!drifts.iter().any(|d| matches!(d, Drift::MissingReplace { .. }))); + } + + #[tokio::test] + async fn test_verify_flags_missing_replace() { + let (dir, blobs, pristine, files, _after) = fixture().await; + let root = dir.path(); + let sources = PatchSources::blobs_only(&blobs); + apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false).await; + // Drop the directive but keep the copy. + go_mod_edit::drop_replace_entry(root, MODULE, false).await.unwrap(); + + let manifest = manifest_with(&files); + let desired: HashSet = [PURL.to_string()].into_iter().collect(); + let drifts = verify_go_redirect_state(root, &manifest, &desired).await.unwrap_err(); + assert!(drifts.iter().any(|d| matches!(d, Drift::MissingReplace { .. }))); + } + + #[tokio::test] + async fn test_verify_flags_wrong_replace_version() { + let (dir, blobs, pristine, files, _after) = fixture().await; + let root = dir.path(); + let sources = PatchSources::blobs_only(&blobs); + apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false).await; + + let manifest = manifest_with(&files); + let desired: HashSet = [PURL.to_string()].into_iter().collect(); + assert!(verify_go_redirect_state(root, &manifest, &desired).await.is_ok()); + + // Repin the socket-owned replace at a DIFFERENT version while the copy + // stays byte-correct. Go keys replace by module+version, so this + // silently links the unpatched module — verify must flag it. + go_mod_edit::ensure_replace_entry(root, MODULE, "v9.9.9", false).await.unwrap(); + // ensure_replace refreshed our entry to v9.9.9; the v1.4.2 copy is now orphaned by directive. + let drifts = verify_go_redirect_state(root, &manifest, &desired).await.unwrap_err(); + assert!( + drifts.iter().any(|d| matches!(d, Drift::WrongReplacePath { .. })), + "stale replace version must be flagged: {drifts:?}" + ); + } + + #[tokio::test] + async fn test_verify_flags_resolved_version_mismatch() { + let (dir, blobs, pristine, files, _after) = fixture().await; + let root = dir.path(); + let sources = PatchSources::blobs_only(&blobs); + apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false).await; + + let manifest = manifest_with(&files); + let desired: HashSet = [PURL.to_string()].into_iter().collect(); + assert!(verify_go_redirect_state(root, &manifest, &desired).await.is_ok()); + + // go.mod requires a DIFFERENT version → the v1.4.2 patch is unused. + tokio::fs::write( + root.join("go.mod"), + "module example.com/app\n\ngo 1.21\n\nrequire github.com/foo/bar v1.5.0\n\nreplace github.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2\n", + ) + .await + .unwrap(); + let drifts = verify_go_redirect_state(root, &manifest, &desired).await.unwrap_err(); + assert!(drifts.iter().any(|d| matches!(d, Drift::ResolvedVersionMismatch { .. }))); + } + + #[tokio::test] + async fn test_verify_orphan_replace() { + let (dir, blobs, pristine, files, _after) = fixture().await; + let root = dir.path(); + let sources = PatchSources::blobs_only(&blobs); + apply_go_redirect(PURL, MODULE, VERSION, &pristine, root, &files, &sources, None, false, false).await; + + // Empty desired + empty manifest → the live directive is an orphan. + let manifest = PatchManifest::new(); + let desired: HashSet = HashSet::new(); + let drifts = verify_go_redirect_state(root, &manifest, &desired).await.unwrap_err(); + assert!(drifts.iter().any(|d| matches!(d, Drift::OrphanReplace { .. }))); + } + + #[tokio::test] + async fn test_empty_files_is_noop() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + tokio::fs::write(root.join("go.mod"), "module m\n\ngo 1.21\n").await.unwrap(); + let blobs = root.join("blobs"); + tokio::fs::create_dir_all(&blobs).await.unwrap(); + let sources = PatchSources::blobs_only(&blobs); + let files = HashMap::new(); + let result = apply_go_redirect(PURL, MODULE, VERSION, root, root, &files, &sources, None, false, false).await; + assert!(result.success); + assert!(read_replace_entries(root).await.is_empty()); + } + + #[test] + fn test_collect_copy_modules_reconstructs_nested_purl() { + // Pure-ish check of the path→PURL reconstruction via build_golang_purl. + assert_eq!( + build_golang_purl("github.com/foo/bar", "v1.4.2"), + "pkg:golang/github.com/foo/bar@v1.4.2" + ); + } +} diff --git a/crates/socket-patch-core/src/patch/mod.rs b/crates/socket-patch-core/src/patch/mod.rs index 2962a95..3f75df9 100644 --- a/crates/socket-patch-core/src/patch/mod.rs +++ b/crates/socket-patch-core/src/patch/mod.rs @@ -4,9 +4,15 @@ pub mod apply_lock; pub mod cargo_config; #[cfg(feature = "cargo")] pub mod cargo_redirect; +#[cfg(any(feature = "cargo", feature = "golang"))] +pub mod copy_tree; pub mod cow; pub mod diff; pub mod file_hash; +#[cfg(feature = "golang")] +pub mod go_mod_edit; +#[cfg(feature = "golang")] +pub mod go_redirect; pub mod package; pub mod rollback; pub mod sidecars;