From a2a686ae93d6d988f9e4ab03518816e4932093ab Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 23 Oct 2026 15:14:14 -0400 Subject: [PATCH 1/2] Fix Go patch audits, rollback, and e2e tests Verify the native vendor tree selected by Go before attesting patches. Account for workspaces, GOFLAGS, selected packages, embedded assets, and dependency-only fixes. Forward consumer synchronization warnings through nested agent get operations. Preserve upstream checksum lines with local patch artifacts and restore them on rollback after tidy. Refuse checksum conflicts without removing an active replacement or its copy, including orphan reconciliation. Replace empty production Go canaries with pgx/v5 execution proofs for hosted, vendored, and agent delivery, fresh caches, cold agent failure and recovery, and rollback. Add real Go regressions for native drift and the lifecycle failures reproduced by the smoke repository. Assisted-by: Codex:GPT-6 --- Cargo.lock | 1 + crates/socket-patch-cli/src/commands/apply.rs | 34 +- .../socket-patch-cli/src/commands/vendor.rs | 7 + crates/socket-patch-cli/src/commands/vex.rs | 1 + .../tests/e2e_golang_build.rs | 395 ++++++++++++++- .../tests/e2e_golang_hosted_build.rs | 40 ++ .../tests/e2e_hosted_production.rs | 60 +-- .../tests/e2e_vendor_golang_build.rs | 90 +++- .../tests/e2e_vendored_production.rs | 63 +-- .../tests/golang_production/mod.rs | 188 +++++++ crates/socket-patch-core/Cargo.toml | 1 + .../src/crawlers/go_crawler.rs | 2 +- .../src/patch/redirect/golang_local.rs | 60 ++- .../src/vendor/go_consumer_sync.rs | 116 +++++ .../src/vendor/go_mod_edit.rs | 20 +- .../src/vendor/go_native_vendor.rs | 478 ++++++++++++++++++ .../src/vendor/go_sum_backup.rs | 214 ++++++++ crates/socket-patch-core/src/vendor/mod.rs | 2 + crates/socket-patch-core/src/vex/verify.rs | 16 + docs/ecosystems.md | 12 + docs/testing/hosted-production-e2e.md | 2 +- docs/testing/vendored-production-e2e.md | 3 +- 22 files changed, 1667 insertions(+), 138 deletions(-) create mode 100644 crates/socket-patch-cli/tests/golang_production/mod.rs create mode 100644 crates/socket-patch-core/src/vendor/go_native_vendor.rs create mode 100644 crates/socket-patch-core/src/vendor/go_sum_backup.rs diff --git a/Cargo.lock b/Cargo.lock index 35faf6ba6..03fbdf3c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1951,6 +1951,7 @@ dependencies = [ "flate2", "fs2", "futures-util", + "glob", "hex", "ignore", "libc", diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 1f915eb2f..5fe2c5a2c 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -256,16 +256,19 @@ async fn try_local_go_apply( /// After the apply loop: prune local-go redirects whose patches were dropped /// from the manifest. No-op unless local go is in scope. -async fn reconcile_local_go(common: &GlobalArgs, target_manifest_purls: &HashSet) { +async fn reconcile_local_go( + common: &GlobalArgs, + target_manifest_purls: &HashSet, +) -> Result<(), String> { if !eco_in_local_scope(common, Ecosystem::Golang) { - return; + return Ok(()); } 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; + 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" @@ -284,6 +287,7 @@ async fn reconcile_local_go(common: &GlobalArgs, target_manifest_purls: &HashSet println!(" {purl}"); } } + Ok(()) } /// Read-only verification that the manifest's patches are in place, for CI @@ -383,6 +387,20 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { drifts.push((id, "go_redirect_drift".to_string(), d.to_string())); } } + for (purl, record) in &manifest.patches { + if in_scope.contains(purl) { + if let Err(detail) = + socket_patch_core::vendor::go_consumer_sync::verify_native_vendor( + &args.common.cwd, + purl, + record, + ) + .await + { + drifts.push((purl.clone(), "go_vendor_content_mismatch".into(), detail)); + } + } + } // The redirects can be intact while the project no longer // builds: a committed vendor/modules.txt or the go.mod // requirements out of step with them (#343, #618). @@ -1358,11 +1376,6 @@ pub(crate) async fn run_locked( .map(|(code, detail)| RunWarning { code, detail }) .collect(); run_warnings.extend(user_patch_warnings.iter().cloned()); - let nested_skip_warnings = if args.nested.is_some() { - user_patch_warnings - } else { - Vec::new() - }; run_warnings.extend(results.iter().filter_map(|r| { let note = r.error.as_deref().filter(|_| r.success)?; note.contains(socket_patch_core::patch::apply::OWNERSHIP_NOT_RESTORED_MARKER) @@ -1632,8 +1645,7 @@ pub(crate) async fn run_locked( // The mismatch overwrites, for a nested caller's envelope (the // JSON events above are the standalone apply's copy). let mut warnings = mismatch_overwrite_warnings(&results, args.common.dry_run); - warnings.extend(cache_warnings); - warnings.extend(nested_skip_warnings); + warnings.extend(run_warnings.iter().cloned()); let (applied, skipped) = collect_apply_outcomes( &results, &unmatched, @@ -2151,7 +2163,7 @@ async fn apply_patches_inner( .chain(user_patched) .cloned() .collect(); - reconcile_local_go(&args.common, &recorded).await; + reconcile_local_go(&args.common, &recorded).await?; if partitioned.is_empty() { // Nothing in scope: the manifest lists no patches (or every patch was diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 6dedfaf7e..b61967a43 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -1256,6 +1256,13 @@ async fn run_check(args: &VendorArgs) -> i32 { // project no longer builds: vendor/modules.txt or the go.mod // requirements out of step with the `replace` (#343, #618). if failure.is_none() && entry.ecosystem == "golang" { + if let Some(record) = record { + failure = socket_patch_core::vendor::go_consumer_sync::verify_native_vendor( + root, key, record, + ) + .await + .err(); + } if let Some((module, _)) = socket_patch_core::utils::purl::parse_golang_purl(key) { let issues: Vec = go_sync_issues .get_or_init(|| async { diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index f3c399bff..518d8425b 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -1898,6 +1898,7 @@ fn omission_phrase(reason: &str) -> &'static str { "file_not_found" => "a patched file is missing", "no_files" => "the patch record lists no files", "vendor_hash_mismatch" => "the vendored artifact does not match the patch", + "go_vendor_content_mismatch" => "the native Go vendor tree does not contain the patch; run go mod vendor (or go work vendor for a workspace)", "vendor_artifact_missing" => "the vendored artifact is missing", "vendor_artifact_unreadable" => "the vendored artifact cannot be read", "vendor_path_unsafe" => "the vendored artifact path is unsafe", diff --git a/crates/socket-patch-cli/tests/e2e_golang_build.rs b/crates/socket-patch-cli/tests/e2e_golang_build.rs index ed0c08425..fce1196c7 100644 --- a/crates/socket-patch-cli/tests/e2e_golang_build.rs +++ b/crates/socket-patch-cli/tests/e2e_golang_build.rs @@ -32,7 +32,7 @@ mod golang_e2e_matrix; #[path = "vex_e2e_common/mod.rs"] mod vex_e2e_common; -use common::{binary, cache_env, git_sha256}; +use common::{cache_env, git_sha256}; use vex_e2e_common::{ assert_absent, assert_attested, run_vex, strip_manifest, Marker, VexRun, VexVia, }; @@ -84,10 +84,15 @@ fn go(dir: &Path, args: &[&str], env: &[(&str, &str)]) -> std::process::Output { /// 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) { + let godir = golang_e2e_matrix::go_directive(); // 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("go.mod"), + format!("module {UMOD}\n\ngo {godir}\n"), + ) + .unwrap(); std::fs::write(stage.join("lib.go"), PRISTINE_LIB).unwrap(); // File-proxy layout: proxy//@v/.{info,mod,zip}. @@ -100,7 +105,7 @@ fn stage(tmp: &Path) -> (std::path::PathBuf, std::path::PathBuf, String) { .unwrap(); std::fs::write( pxv.join(format!("{UVER}.mod")), - format!("module {UMOD}\n\ngo 1.21\n"), + format!("module {UMOD}\n\ngo {godir}\n"), ) .unwrap(); let zip_out = pxv.join(format!("{UVER}.zip")); @@ -125,7 +130,7 @@ fn stage(tmp: &Path) -> (std::path::PathBuf, std::path::PathBuf, String) { 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"), + format!("module example.com/consumer\n\ngo {godir}\n\nrequire {UMOD} {UVER}\n"), ) .unwrap(); std::fs::write( @@ -467,6 +472,44 @@ fn committed_vendor_dir_needs_go_mod_vendor_after_apply_and_rollback() { ); assert_eq!(code, 0, "in sync after go mod vendor:\n{so}\n{se}"); + std::fs::write( + consumer.join("vendor").join(UMOD).join("lib.go"), + PRISTINE_LIB, + ) + .unwrap(); + let native = go(&consumer, &["run", "."], &goenv); + assert!(String::from_utf8_lossy(&native.stdout).contains("OUT: PRISTINE")); + let (code, so, se) = run_socket( + &consumer, + &[ + "apply", + "--check", + "--offline", + "--json", + "--ecosystems", + "golang", + ], + &modcache, + ); + assert_eq!(code, 1, "native vendor drift must fail audit: {so} {se}"); + assert!(so.contains("go_vendor_content_mismatch"), "{so}"); + let (code, so, se) = run_socket( + &consumer, + &[ + "vex", + "--offline", + "--json", + "--product", + "pkg:golang/example.com/consumer@v1.0.0", + "--output", + "vex.json", + ], + &modcache, + ); + assert_eq!(code, 1, "native vendor drift must not attest: {so} {se}"); + assert!(so.contains("go_vendor_content_mismatch"), "{so}"); + assert!(go(&consumer, &["mod", "vendor"], &goenv).status.success()); + // Rollback drops the replace; modules.txt still records it. let (code, so, se) = run_socket( &consumer, @@ -506,6 +549,15 @@ fn committed_vendor_dir_needs_go_mod_vendor_after_apply_and_rollback() { /// the drift until it is run, and after it the patched bytes build. #[test] fn patched_go_mod_requirement_bump_needs_go_mod_tidy() { + requirement_bump(false); +} + +#[test] +fn dependency_only_patch_attests_after_native_vendoring() { + requirement_bump(true); +} + +fn requirement_bump(dependency_only: bool) { if !golang_e2e_matrix::toolchain_ready("e2e_golang_build(requirements)") { return; } @@ -536,7 +588,12 @@ fn patched_go_mod_requirement_bump_needs_go_mod_tidy() { ) }; let (before_mod, after_mod) = (up_mod("v1.0.0"), up_mod("v1.1.0")); - let (before_lib, after_lib) = (up_lib("PRISTINE"), up_lib("PATCHED")); + let marker = if dependency_only { + "PRISTINE" + } else { + "PATCHED" + }; + let (before_lib, after_lib) = (up_lib("PRISTINE"), up_lib(marker)); publish( t, UMOD, @@ -584,7 +641,7 @@ fn patched_go_mod_requirement_bump_needs_go_mod_tidy() { for body in [&after_lib, &after_mod] { std::fs::write(socket.join("blobs").join(git_sha256(body.as_bytes())), body).unwrap(); } - let manifest = serde_json::json!({"patches": {UPURL: { + let mut manifest = serde_json::json!({"patches": {UPURL: { "uuid": UUID, "exportedAt": "t", "files": { "lib.go": {"beforeHash": git_sha256(before_lib.as_bytes()), @@ -595,6 +652,12 @@ fn patched_go_mod_requirement_bump_needs_go_mod_tidy() { "vulnerabilities": {GHSA: {"cves": [CVE], "summary": "s", "severity": "high", "description": "d"}}, "description": "", "license": "", "tier": ""}}}); + if dependency_only { + manifest["patches"][UPURL]["files"] + .as_object_mut() + .unwrap() + .remove("lib.go"); + } std::fs::write(socket.join("manifest.json"), manifest.to_string()).unwrap(); let cs = consumer.to_str().unwrap(); @@ -643,7 +706,7 @@ fn patched_go_mod_requirement_bump_needs_go_mod_tidy() { ); let run = go(&consumer, &["run", "."], &goenv); assert!( - String::from_utf8_lossy(&run.stdout).contains("OUT: FIXED-PATCHED"), + String::from_utf8_lossy(&run.stdout).contains(&format!("OUT: FIXED-{marker}")), "{}{}", String::from_utf8_lossy(&run.stdout), String::from_utf8_lossy(&run.stderr) @@ -654,6 +717,47 @@ fn patched_go_mod_requirement_bump_needs_go_mod_tidy() { &modcache, ); assert_eq!(code, 0, "in sync after go mod tidy:\n{so}\n{se}"); + assert!(go(&consumer, &["mod", "vendor"], &goenv).status.success()); + assert!(go_out(&consumer, &goenv).contains(&format!("OUT: FIXED-{marker}"))); + let (code, so, se) = check(&consumer, &modcache); + assert_eq!(code, 0, "native vendor requirements in sync: {so} {se}"); + let (code, so, se) = run_socket( + &consumer, + &[ + "vex", + "--offline", + "--json", + "--product", + "pkg:golang/example.com/consumer@v1.0.0", + "--output", + "vex.json", + ], + &modcache, + ); + assert_eq!(code, 0, "dependency patch must attest: {so} {se}"); + assert!(std::fs::read_to_string(consumer.join("vex.json")) + .unwrap() + .contains(GHSA)); + let modules = consumer.join("vendor/modules.txt"); + let text = std::fs::read_to_string(&modules) + .unwrap() + .replace("# example.com/dep v1.1.0", "# example.com/dep v1.0.0"); + std::fs::write(modules, text).unwrap(); + let (code, so, se) = run_socket( + &consumer, + &[ + "vex", + "--offline", + "--json", + "--product", + "pkg:golang/example.com/consumer@v1.0.0", + "--output", + "vex.json", + ], + &modcache, + ); + assert_eq!(code, 1, "old dependency must not attest: {so} {se}"); + assert!(so.contains("go_vendor_content_mismatch"), "{so}"); chmod_writable(tmp.path()); } @@ -672,10 +776,11 @@ fn workspace_env<'a>(modcache: &'a str, proxy_url: &'a str) -> Vec<(&'a str, &'a /// A module at `dir` that requires the upstream and prints its greeting, /// with the go.sum `go mod tidy` writes (outside any workspace). fn consumer_module(dir: &Path, module: &str, modcache: &str, proxy_url: &str) { + let godir = golang_e2e_matrix::go_directive(); std::fs::create_dir_all(dir).unwrap(); std::fs::write( dir.join("go.mod"), - format!("module {module}\n\ngo 1.21\n\nrequire {UMOD} {UVER}\n"), + format!("module {module}\n\ngo {godir}\n\nrequire {UMOD} {UVER}\n"), ) .unwrap(); std::fs::write( @@ -982,3 +1087,277 @@ fn go_work_member_replaces_must_not_conflict() { chmod_writable(tmp.path()); } + +#[test] +fn go_rollback_after_tidy_restores_upstream_sums_offline() { + if !golang_e2e_matrix::toolchain_ready("rollback after tidy") { + return; + } + let tmp = tempfile::tempdir().unwrap(); + let (consumer, modcache, proxy) = stage(tmp.path()); + let original_sums = std::fs::read_to_string(consumer.join("go.sum")).unwrap(); + write_patch(&consumer); + let (code, so, se) = run_socket( + &consumer, + &["apply", "--offline", "--ecosystems", "golang"], + &modcache, + ); + assert_eq!(code, 0, "{so} {se}"); + let mut env = go_env(modcache.to_str().unwrap(), &proxy); + assert!(go(&consumer, &["mod", "tidy"], &env).status.success()); + assert!(!std::fs::read_to_string(consumer.join("go.sum")) + .unwrap() + .contains(UMOD)); + let (code, so, se) = run_socket( + &consumer, + &["rollback", "--offline", "--ecosystems", "golang"], + &modcache, + ); + assert_eq!(code, 0, "{so} {se}"); + assert_eq!( + std::fs::read_to_string(consumer.join("go.sum")).unwrap(), + original_sums + ); + env.push(("GOFLAGS", "-mod=readonly")); + let built = go(&consumer, &["run", "."], &env); + assert!( + built.status.success(), + "{}", + String::from_utf8_lossy(&built.stderr) + ); + assert!(String::from_utf8_lossy(&built.stdout).contains("OUT: PRISTINE")); + chmod_writable(tmp.path()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn agent_get_preserves_native_vendor_warning() { + use base64::Engine as _; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + if !golang_e2e_matrix::toolchain_ready("agent get warning") { + return; + } + let tmp = tempfile::tempdir().unwrap(); + let (consumer, cache, proxy) = stage(tmp.path()); + let env = go_env(cache.to_str().unwrap(), &proxy); + assert!(go(&consumer, &["mod", "vendor"], &env).status.success()); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/test/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": UUID, "purl": UPURL, "publishedAt": "2026-10-23T00:00:00Z", + "files": {"lib.go": {"beforeHash": git_sha256(PRISTINE_LIB.as_bytes()), + "afterHash": git_sha256(PATCHED_LIB.as_bytes()), + "blobContent": base64::engine::general_purpose::STANDARD.encode(PATCHED_LIB)}}, + "vulnerabilities": {}, "description": "fixture", "license": "MIT", "tier": "free" + }))) + .mount(&server) + .await; + let (code, so, se) = common::run_with_env( + &consumer, + &[ + "get", + UUID, + "--mode", + "agent", + "--json", + "--yes", + "--ecosystems", + "golang", + "--api-url", + &server.uri(), + "--org", + "test", + "--api-token", + "fake", + ], + &[("GOMODCACHE", cache.to_str().unwrap())], + ); + assert_eq!(code, 0, "{so} {se}"); + assert!( + warnings_of(&so).contains(&"go_vendor_modules_txt_out_of_sync".into()), + "{so}" + ); + chmod_writable(tmp.path()); +} + +#[test] +fn native_vendor_projection_and_workspace_selection() { + if !golang_e2e_matrix::toolchain_ready("native vendor projection") { + return; + } + let tmp = tempfile::tempdir().unwrap(); + let (consumer, modcache, proxy) = stage(tmp.path()); + write_patch(&consumer); + let mut go_mod = std::fs::read_to_string(consumer.join("go.mod")).unwrap(); + go_mod.push_str(&format!("\nreplace {UMOD} v0.9.0 => ./unused\n")); + std::fs::write(consumer.join("go.mod"), go_mod).unwrap(); + let manifest_path = consumer.join(".socket/manifest.json"); + let mut manifest: serde_json::Value = + serde_json::from_slice(&std::fs::read(&manifest_path).unwrap()).unwrap(); + for (name, body) in [ + ("data/fix.txt", "fixed asset\n"), + ("data/other.txt", "other asset\n"), + ("embeds.go", "package upstream\nimport \"embed\"\n//go:embed \"data/*.txt\"\nvar assets embed.FS\nfunc AssetCount() int { entries, _ := assets.ReadDir(\"data\"); return len(entries) }\n"), + ("unused/unused.go", "package unused\nconst Fixed = true\n"), + ("testdata/case.txt", "patched test data\n"), + ( + "generate.go", + "//go:build ignore && (linux || windows)\n\npackage main\nfunc main() {}\n", + ), + ("extra.go", "package upstream\nconst Fixed = true\n"), + ] { + let hash = git_sha256(body.as_bytes()); + std::fs::write(consumer.join(".socket/blobs").join(&hash), body).unwrap(); + manifest["patches"][UPURL]["files"][name] = + serde_json::json!({"beforeHash": "", "afterHash": hash}); + } + std::fs::write(&manifest_path, manifest.to_string()).unwrap(); + let (code, so, se) = apply_json(&consumer, &modcache); + assert_eq!(code, 0, "{so} {se}"); + std::fs::create_dir_all(consumer.join("embed-probe")).unwrap(); + std::fs::write(consumer.join("embed-probe/main.go"), format!("package main\nimport (\"fmt\"; \"{UMOD}\")\nfunc main() {{ fmt.Println(upstream.AssetCount()) }}\n")).unwrap(); + let env = workspace_env(modcache.to_str().unwrap(), &proxy); + assert!(go(&consumer, &["mod", "vendor"], &env).status.success()); + let native = consumer.join("vendor").join(UMOD); + for missing in ["unused/unused.go", "testdata/case.txt", "generate.go"] { + assert!(!native.join(missing).exists(), "Go should omit {missing}"); + } + let audit = |wanted| { + let (code, so, se) = check(&consumer, &modcache); + assert_eq!(code, wanted, "audit: {so} {se}"); + let (code, so, se) = run_socket( + &consumer, + &[ + "vex", + "--offline", + "--json", + "--product", + "pkg:golang/example.com/consumer@v1.0.0", + "--output", + "vex.json", + ], + &modcache, + ); + assert_eq!(code, wanted, "vex: {so} {se}"); + if wanted == 0 { + assert!(std::fs::read_to_string(consumer.join("vex.json")) + .unwrap() + .contains(GHSA)); + } else { + assert!(so.contains("go_vendor_content_mismatch"), "{so}"); + } + }; + assert!(go_out(&consumer, &env).contains("OUT: PATCHED")); + audit(0); + let count = go(&consumer, &["run", "./embed-probe"], &env); + assert!(count.status.success()); + assert_eq!(String::from_utf8_lossy(&count.stdout).trim(), "2"); + std::fs::remove_file(native.join("data/fix.txt")).unwrap(); + let count = go(&consumer, &["run", "./embed-probe"], &env); + assert!(count.status.success()); + assert_eq!(String::from_utf8_lossy(&count.stdout).trim(), "1"); + audit(1); + assert!(go(&consumer, &["mod", "vendor"], &env).status.success()); + // A missing source in a selected package is drift, not an omitted package. + std::fs::remove_file(native.join("extra.go")).unwrap(); + audit(1); + assert!(go(&consumer, &["mod", "vendor"], &env).status.success()); + std::fs::write(native.join("lib.go"), PRISTINE_LIB).unwrap(); + for flags in [ + "--mod=mod", + "--mod=readonly", + "'--mod=readonly'", + "\"-mod=mod\"", + ] { + let mut bypass = env.clone(); + bypass.push(("GOFLAGS", flags)); + assert!( + go_out(&consumer, &bypass).contains("OUT: PATCHED"), + "{flags}" + ); + let (code, so, se) = common::run_with_env( + &consumer, + &["apply", "--check", "--offline", "--json"], + &[ + ("GOMODCACHE", modcache.to_str().unwrap()), + ("GOFLAGS", flags), + ], + ); + assert_eq!(code, 0, "{flags}: {so} {se}"); + } + assert!(go(&consumer, &["mod", "vendor"], &env).status.success()); + if golang_e2e_matrix::go_minor() >= 22 { + // Go 1.22+ selects workspace vendoring even with a go 1.21 directive. + // It first ignores the pre-existing module-mode vendor tree. + std::fs::write(native.join("lib.go"), PRISTINE_LIB).unwrap(); + std::fs::write(consumer.join("go.work"), "go 1.21\nuse .\n").unwrap(); + assert!(go_out(&consumer, &env).contains("OUT: PATCHED")); + audit(0); + let out = go(&consumer, &["work", "vendor"], &env); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(std::fs::read_to_string(consumer.join("go.work")) + .unwrap() + .starts_with("go 1.21\n")); + audit(0); + std::fs::write(native.join("lib.go"), PRISTINE_LIB).unwrap(); + assert!(go_out(&consumer, &env).contains("OUT: PRISTINE")); + audit(1); + // Conversely module mode ignores a workspace vendor tree. + std::fs::remove_file(consumer.join("go.work")).unwrap(); + assert!(go_out(&consumer, &env).contains("OUT: PATCHED")); + audit(0); + } + chmod_writable(tmp.path()); +} + +#[test] +fn checksum_conflict_preserves_orphan_redirect_and_reports_failure() { + if !golang_e2e_matrix::toolchain_ready("checksum-conflict reconciliation") { + return; + } + let tmp = tempfile::tempdir().unwrap(); + let (consumer, modcache, proxy) = stage(tmp.path()); + write_patch(&consumer); + let (code, so, se) = apply_json(&consumer, &modcache); + assert_eq!(code, 0, "{so} {se}"); + let saved = std::fs::read_to_string(consumer.join("go.sum")).unwrap(); + let conflict = saved + .lines() + .map(|line| { + let mut words: Vec<_> = line.split_whitespace().collect(); + words[2] = "h1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + words.join(" ") + }) + .collect::>() + .join("\n") + + "\n"; + std::fs::write(consumer.join("go.sum"), &conflict).unwrap(); + std::fs::write(consumer.join(".socket/manifest.json"), "{\"patches\":{}}").unwrap(); + let go_mod = std::fs::read(consumer.join("go.mod")).unwrap(); + let (code, so, se) = apply_json(&consumer, &modcache); + assert_eq!(code, 1, "{so} {se}"); + assert!(so.contains("conflicts"), "{so}"); + assert_eq!(std::fs::read(consumer.join("go.mod")).unwrap(), go_mod); + assert_eq!( + std::fs::read_to_string(consumer.join("go.sum")).unwrap(), + conflict + ); + let copy = consumer + .join(".socket/go-patches") + .join(format!("{UMOD}@{UVER}")); + assert!(copy.join("lib.go").is_file()); + let env = workspace_env(modcache.to_str().unwrap(), &proxy); + assert!(go_out(&consumer, &env).contains("OUT: PATCHED")); + // Resolving the conflict permits reconciliation and removes the backup. + std::fs::write(consumer.join("go.sum"), saved).unwrap(); + let (code, so, se) = apply_json(&consumer, &modcache); + assert_eq!(code, 0, "{so} {se}"); + assert!(!consumer.join(".socket/go-patches").exists()); + assert!(go_out(&consumer, &env).contains("OUT: PRISTINE")); + chmod_writable(tmp.path()); +} diff --git a/crates/socket-patch-cli/tests/e2e_golang_hosted_build.rs b/crates/socket-patch-cli/tests/e2e_golang_hosted_build.rs index 4d4273eaa..9221f0559 100644 --- a/crates/socket-patch-cli/tests/e2e_golang_hosted_build.rs +++ b/crates/socket-patch-cli/tests/e2e_golang_hosted_build.rs @@ -747,6 +747,46 @@ async fn golang_get_uuid_hosted_day2_machine_builds() { String::from_utf8_lossy(&patched.stdout) ); + let native = go(&fx.consumer, &m2, &["mod", "vendor"], &as_pairs(&env)); + assert!( + native.status.success(), + "{}", + String::from_utf8_lossy(&native.stderr) + ); + let consumed = fx.consumer.join("vendor").join(UMOD).join("lib.go"); + std::fs::write(&consumed, PRISTINE_LIB).unwrap(); + let native_run = go( + &fx.consumer, + &m2, + &["run", "-mod=vendor", "."], + &as_pairs(&env), + ); + assert!(String::from_utf8_lossy(&native_run.stdout).contains("OUT: PRISTINE")); + let (code, so, se) = common::run_with_env( + &fx.consumer, + &[ + "vex", + "--json", + "--product", + "pkg:golang/example.com/consumer@v1.0.0", + "--output", + "vex.json", + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake", + ], + &[ + ("GOFLAGS", "-mod=vendor"), + ("GOMODCACHE", m2.modcache.to_str().unwrap()), + ], + ); + assert_eq!(code, 1, "native vendor drift must not attest: {so} {se}"); + assert!(so.contains("go_vendor_content_mismatch"), "{so}"); + std::fs::remove_dir_all(fx.consumer.join("vendor")).unwrap(); + // ── manifest-less VEX over the committed state (no ledger) ─────────── // The API stand-in owns its own runtime, so the tail runs off this // test's async runtime. diff --git a/crates/socket-patch-cli/tests/e2e_hosted_production.rs b/crates/socket-patch-cli/tests/e2e_hosted_production.rs index f1232680e..a9221c637 100644 --- a/crates/socket-patch-cli/tests/e2e_hosted_production.rs +++ b/crates/socket-patch-cli/tests/e2e_hosted_production.rs @@ -50,10 +50,9 @@ //! a full sparse-registry install proof until 2026-09-01: production's free //! cargo tier emptied on 2026-08-28, so the leg was demoted to the canary //! (`docs/testing/hosted-production-e2e.md` says how to re-promote it). -//! * **golang** — hosted mode is supported for free-tier references carrying -//! a `goproxy` override (`docs/ecosystems.md#go-directory-replaces-and-gosum`), but production -//! publishes no golang hosted modules yet. Covered as a shape guard that -//! holds in both worlds. +//! * **golang** — pgx/v5 v5.7.0 must deliver the function-call decoder fix +//! through a hosted `/v5` module with both go.sum pins, including a fresh +//! checkout. Missing hosted metadata is a failure. //! * **deno** — hosted mode is not supported. Covered as a negative assertion. //! //! # Prerequisites @@ -2447,55 +2446,16 @@ fn statements_for_opt(doc: Option<&serde_json::Value>, purl: &str) -> usize { // Documented negative cases // =========================================================================== -/// Go hosted mode: supported for free-tier references that carry a `goproxy` -/// override (`docs/ecosystems.md#go-directory-replaces-and-gosum`); refused with -/// `redirect_golang_unsupported` otherwise (`golang-hosted-no-go.md`, the -/// paid-tier analysis). -/// -/// Production publishes no golang hosted modules today, so this guards BOTH -/// worlds without edits: while prod ships no `goproxy` overrides, nothing may -/// be redirected (an empty-project scan must not invent a redirect); the day -/// prod starts shipping them, any redirect this scan performs must be the -/// documented shape — a socket-namespace replace in go.mod plus go.sum lines. +#[path = "golang_production/mod.rs"] +mod golang_production; + #[test] -#[ignore = "live production API. Run with --ignored."] -fn golang_hosted_redirects_only_via_goproxy_override() { - const LEG: &str = "golang_hosted_redirects_only_via_goproxy_override"; +#[ignore = "live production API + real Go >= 1.21. Run with --ignored."] +fn golang_hosted_pgx_delivery() { if !has_command("go") { - soft_skip!(LEG, "`go` not on PATH"); - } - let tmp = tempfile::tempdir().expect("tempdir"); - let proj = tmp.path().join("proj"); - std::fs::create_dir_all(&proj).expect("mkdir proj"); - std::fs::write( - proj.join("go.mod"), - "module example.com/hosted-e2e\n\ngo 1.21\n", - ) - .expect("write go.mod"); - - let env_json = scan_hosted(&proj, &["--ecosystems", "golang"]); - let redirected = hosted_pin_count(&env_json); - if redirected == 0 { - println!( - "{LEG}: production publishes no golang hosted modules (or none \ - matched an empty project); nothing redirected, as documented." - ); - } else { - // The moment prod ships goproxy overrides, every golang redirect must - // be the committable fork-replace shape. - let go_mod = std::fs::read_to_string(proj.join("go.mod")).expect("read go.mod"); - assert!( - go_mod.contains("patch.socket.dev/gopatch/"), - "{LEG}: {redirected} golang redirect(s) reported but go.mod has no \ - socket-namespace replace:\n{go_mod}\n{env_json:#}" - ); - assert!( - proj.join("go.sum").is_file(), - "{LEG}: golang redirect without a go.sum pin bricks -mod=readonly \ - builds:\n{env_json:#}" - ); - println!("{LEG}: production now serves golang hosted modules; shape verified."); + soft_skip!("golang_hosted", "go not on PATH"); } + golang_production::delivery("hosted"); } /// Deno hosted mode is not supported. Same shape as the golang guard. diff --git a/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs index 852cfc0ef..b89222a78 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs @@ -118,9 +118,14 @@ fn b64(bytes: &[u8]) -> String { /// Build the upstream module into a file proxy and `go mod download` it into /// a private GOMODCACHE. Returns `(consumer, modcache, proxy_url)`. fn stage(tmp: &Path) -> (PathBuf, PathBuf, String) { + let godir = golang_e2e_matrix::go_directive(); 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("go.mod"), + format!("module {UMOD}\n\ngo {godir}\n"), + ) + .unwrap(); std::fs::write(stage.join("lib.go"), PRISTINE_LIB).unwrap(); let pxv = tmp.join("proxy").join(UMOD).join("@v"); @@ -132,7 +137,7 @@ fn stage(tmp: &Path) -> (PathBuf, PathBuf, String) { .unwrap(); std::fs::write( pxv.join(format!("{UVER}.mod")), - format!("module {UMOD}\n\ngo 1.21\n"), + format!("module {UMOD}\n\ngo {godir}\n"), ) .unwrap(); let zip_out = pxv.join(format!("{UVER}.zip")); @@ -156,7 +161,7 @@ fn stage(tmp: &Path) -> (PathBuf, PathBuf, String) { 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"), + format!("module example.com/consumer\n\ngo {godir}\n\nrequire {UMOD} {UVER}\n"), ) .unwrap(); std::fs::write( @@ -1035,3 +1040,82 @@ fn go_vendor_reports_applied_event() { chmod_writable(tmp.path()); } + +#[test] +fn native_go_vendor_drift_and_tidy_rollback() { + if !golang_e2e_matrix::toolchain_ready("native vendor drift") { + return; + } + let tmp = tempfile::tempdir().unwrap(); + let (consumer, modcache, proxy) = stage(tmp.path()); + let original_sums = std::fs::read_to_string(consumer.join("go.sum")).unwrap(); + write_patch(&consumer); + let (code, so, se) = run_socket(&consumer, &["vendor", "--offline", "--json"], &modcache); + assert_eq!(code, 0, "{so} {se}"); + let mut env = go_env(modcache.to_str().unwrap(), &proxy); + assert!(go(&consumer, &["mod", "tidy"], &env).status.success()); + assert!(go(&consumer, &["mod", "vendor"], &env).status.success()); + env.push(("GOFLAGS", "")); + let run = go(&consumer, &["run", "."], &env); + assert!( + run.status.success(), + "{}", + String::from_utf8_lossy(&run.stderr) + ); + assert!(String::from_utf8_lossy(&run.stdout).contains("OUT: PATCHED")); + for cmd in ["vendor", "apply"] { + let (code, so, se) = run_socket( + &consumer, + &[cmd, "--check", "--offline", "--json"], + &modcache, + ); + assert_eq!(code, 0, "{cmd}: {so} {se}"); + } + std::fs::write( + consumer.join("vendor").join(UMOD).join("lib.go"), + PRISTINE_LIB, + ) + .unwrap(); + for cmd in ["vendor", "apply"] { + let (code, so, se) = run_socket( + &consumer, + &[cmd, "--check", "--offline", "--json"], + &modcache, + ); + assert_eq!(code, 1, "{cmd} must detect native vendor drift: {so} {se}"); + } + let (code, so, se) = run_socket( + &consumer, + &[ + "vex", + "--offline", + "--json", + "--product", + "pkg:golang/example.com/consumer@v1.0.0", + "--output", + "vex.json", + ], + &modcache, + ); + assert_eq!(code, 1, "native vendor drift must not attest: {so} {se}"); + assert!(so.contains("go_vendor_content_mismatch"), "{so}"); + let (code, so, se) = run_socket( + &consumer, + &["rollback", "--offline", "--ecosystems", "golang"], + &modcache, + ); + assert_eq!(code, 0, "{so} {se}"); + assert_eq!( + std::fs::read_to_string(consumer.join("go.sum")).unwrap(), + original_sums + ); + assert!(go(&consumer, &["mod", "vendor"], &env).status.success()); + let run = go(&consumer, &["run", "."], &env); + assert!( + run.status.success(), + "{}", + String::from_utf8_lossy(&run.stderr) + ); + assert!(String::from_utf8_lossy(&run.stdout).contains("OUT: PRISTINE")); + chmod_writable(tmp.path()); +} diff --git a/crates/socket-patch-cli/tests/e2e_vendored_production.rs b/crates/socket-patch-cli/tests/e2e_vendored_production.rs index 724c5fbd0..1164907e1 100644 --- a/crates/socket-patch-cli/tests/e2e_vendored_production.rs +++ b/crates/socket-patch-cli/tests/e2e_vendored_production.rs @@ -60,10 +60,7 @@ //! loop including the fresh-dir `bundle install` delivery proof. The server //! must provide a valid `gem-stub-gemspec` with summary and authors; invalid //! stubs fail closed without constructing a local replacement. -//! * **golang** — vendored mode *works* (directory `replace`), but production -//! publishes no free golang patches, so there is nothing to vendor. -//! [`golang_vendored_finds_no_free_patches`] asserts exactly that (zero -//! applied) and would light up the moment a golang patch is published. +//! * **golang** — pgx/v5 v5.7.0 exercises directory replacements and cold-cache delivery. //! * **cargo / maven / nuget / composer** — vendored mode is implemented, but //! production publishes **zero** free-tier patches for them. //! [`canary_unpublished_vendored_ecosystems`] probes production every run and @@ -2504,50 +2501,16 @@ fn gem_manifestless_vex( // Documented gaps — golang (nothing to vendor), deno (unsupported), canary // =========================================================================== -/// Golang vendored mode WORKS (directory `replace`), but production publishes -/// no free golang patches, so there is nothing to vendor. This asserts exactly -/// that — a clean run that vendors zero packages — and would light up the -/// moment a free golang patch is published. +#[path = "golang_production/mod.rs"] +mod golang_production; + #[test] -#[ignore = "live production API. Run with --ignored."] -fn golang_vendored_finds_no_free_patches() { - const LEG: &str = "golang_vendored_finds_no_free_patches"; +#[ignore = "live production API + real Go >= 1.21. Run with --ignored."] +fn golang_vendored_pgx_delivery() { if !has_command("go") { - soft_skip!(LEG, "`go` not on PATH"); + soft_skip!("golang_vendored", "go not on PATH"); } - let tmp = tempfile::tempdir().expect("tempdir"); - let proj = tmp.path().join("proj"); - std::fs::create_dir_all(&proj).expect("mkdir proj"); - std::fs::write( - proj.join("go.mod"), - "module example.com/vendored-e2e\n\ngo 1.21\n", - ) - .expect("write go.mod"); - - let env_json = scan_vendored(&proj, &["--ecosystems", "golang"]); - let applied = env_json["summary"]["applied"].as_u64().unwrap_or(0); - assert_eq!( - applied, 0, - "{LEG}: golang vendored something, but production publishes no free golang patches. \ - Either production changed (extend this suite with a real golang delivery proof) or \ - this is a bug.\nenvelope:\n{env_json:#}" - ); - let patches = env_json["events"] - .as_array() - .map(|a| a.iter().filter(|e| e["action"] == "downloaded").count()) - .unwrap_or(0); - if patches > 0 { - println!( - "{LEG}: production now publishes free golang patches — extend this suite with a \ - real `go mod` vendored delivery proof." - ); - } else { - println!("{LEG}: no free golang patches published; vendored mode correctly found nothing."); - } - assert!( - !proj.join(".socket/vendor").exists(), - "{LEG}: nothing was vendored, so no .socket/vendor tree should exist:\n{env_json:#}" - ); + golang_production::delivery("vendored"); } /// Deno vendored mode is not supported. Same shape as the golang guard. @@ -2709,3 +2672,13 @@ async fn vlt_pinned_matrix_production_vendored_install_proof() { ); leg.ran(); } + +#[test] +#[ignore = "live production API + real Go >= 1.21. Run with --ignored."] +fn golang_agent_pgx_delivery_and_cold_cache() { + if !has_command("go") { + soft_skip!("golang_agent", "go not on PATH"); + } + golang_production::delivery("agent"); + golang_production::cold_agent(); +} diff --git a/crates/socket-patch-cli/tests/golang_production/mod.rs b/crates/socket-patch-cli/tests/golang_production/mod.rs new file mode 100644 index 000000000..a3bfd699d --- /dev/null +++ b/crates/socket-patch-cli/tests/golang_production/mod.rs @@ -0,0 +1,188 @@ +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +#[path = "../common/cache_env.rs"] +mod cache_env; +#[path = "../common/hermetic.rs"] +mod hermetic; +#[path = "../common/oracle.rs"] +mod oracle; + +const UUID: &str = "d8941a4c-4bcd-4756-b0aa-239ec9c81b0f"; +const MODULE: &str = "github.com/jackc/pgx/v5"; +const PURL: &str = "pkg:golang/github.com/jackc/pgx/v5@v5.7.0"; +const MAIN: &str = r#"package main +import ( + "fmt" + "github.com/jackc/pgx/v5/pgproto3" +) +func probe() (result string) { + defer func() { if recover() != nil { result = "PANIC" } }() + var msg pgproto3.FunctionCall + if msg.Decode([]byte{0,0,0,1,0,0,0,1,255,255,255,254}) != nil { return "REJECTED" } + return "ACCEPTED" +} +func main() { fmt.Println(probe()) } +"#; + +fn env(cmd: &mut Command, cache: &Path) { + cache_env::isolate(cmd); + cmd.env("GOMODCACHE", cache.join("modcache")) + .env("GOPATH", cache.join("gopath")) + .env("GOCACHE", cache.join("gocache")) + .env("GOTOOLCHAIN", "local") + .env("GOENV", "off") + .env("GOWORK", "off") + .env("GOFLAGS", "-mod=readonly") + .env("GOPRIVATE", "") + .env("GONOSUMDB", "") + .env("GONOPROXY", "") + .env("GOPROXY", "https://proxy.golang.org,direct") + .env("GOSUMDB", "sum.golang.org"); +} + +fn go(project: &Path, cache: &Path, args: &[&str]) -> Output { + let mut cmd = Command::new("go"); + env(&mut cmd, cache); + cmd.args(args).current_dir(project).output().expect("go") +} + +fn success(out: &Output) { + assert!( + out.status.success(), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); +} + +fn invoke(project: &Path, cache: &Path, args: &[&str]) -> Output { + let mut cmd = hermetic::command(&oracle::binary()); + env(&mut cmd, cache); + let out = cmd + .current_dir(project) + .args(args) + .args([ + "--json", + "--yes", + "--ecosystems", + "golang", + "--proxy-url", + "https://patches-api.socket.dev", + ]) + .output() + .expect("socket-patch"); + out +} + +fn cli(project: &Path, cache: &Path, args: &[&str]) -> serde_json::Value { + let out = invoke(project, cache, args); + success(&out); + serde_json::from_slice(&out.stdout).expect("JSON envelope") +} + +fn project(root: &Path, name: &str) -> PathBuf { + let dir = root.join(name); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("go.mod"), + format!("module example.com/go-patch-smoke\n\ngo 1.21\n\nrequire {MODULE} v5.7.0\n"), + ) + .unwrap(); + std::fs::write(dir.join("main.go"), MAIN).unwrap(); + dir +} + +fn copy_tree(src: &Path, dst: &Path) { + std::fs::create_dir_all(dst).unwrap(); + for entry in std::fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let to = dst.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy_tree(&entry.path(), &to); + } else { + std::fs::copy(entry.path(), to).unwrap(); + } + } +} + +pub fn delivery(mode: &str) { + for entrypoint in ["get", "scan"] { + let tmp = tempfile::tempdir().unwrap(); + let consumer = project(tmp.path(), "consumer"); + let cache = tmp.path().join("initial"); + success(&go( + &consumer, + &cache, + &["mod", "download", &format!("{MODULE}@v5.7.0")], + )); + let before = go(&consumer, &cache, &["run", "."]); + success(&before); + assert_eq!(String::from_utf8_lossy(&before.stdout).trim(), "PANIC"); + let args = if entrypoint == "get" { + vec!["get", UUID, "--mode", mode] + } else { + vec!["scan", "--mode", mode] + }; + let result = cli(&consumer, &cache, &args); + assert!( + result["events"].as_array().unwrap().iter().any(|event| { + event["purl"] == PURL + && (event["action"] == "applied" || event["action"] == "redirected") + }), + "{mode}/{entrypoint} did not deliver {UUID}: {result:#}" + ); + let after = go(&consumer, &cache, &["run", "."]); + success(&after); + assert_eq!(String::from_utf8_lossy(&after.stdout).trim(), "REJECTED"); + let fresh = tmp.path().join("fresh"); + copy_tree(&consumer, &fresh); + let run = go(&fresh, &tmp.path().join("empty-cache"), &["run", "."]); + success(&run); + assert_eq!(String::from_utf8_lossy(&run.stdout).trim(), "REJECTED"); + if mode != "hosted" { + success(&go(&consumer, &cache, &["mod", "tidy"])); + cli(&consumer, &cache, &["rollback", "--offline"]); + let restored = go(&consumer, &cache, &["run", "."]); + success(&restored); + assert_eq!(String::from_utf8_lossy(&restored.stdout).trim(), "PANIC"); + } + } +} + +pub fn cold_agent() { + let tmp = tempfile::tempdir().unwrap(); + let consumer = project(tmp.path(), "consumer"); + let cache = tmp.path().join("empty-cache"); + let out = invoke(&consumer, &cache, &["get", UUID, "--mode", "agent"]); + assert!( + !out.status.success(), + "a missing module must fail explicitly" + ); + let result: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!( + result["summary"]["applied"], 0, + "a cold cache cannot be reported applied: {result}" + ); + assert!( + result["events"] + .as_array() + .unwrap() + .iter() + .any(|e| e["purl"] == PURL + && e["action"] == "failed" + && e["errorCode"] == "package_not_installed"), + "{result}" + ); + success(&go( + &consumer, + &cache, + &["mod", "download", &format!("{MODULE}@v5.7.0")], + )); + cli(&consumer, &cache, &["apply", "--offline"]); + let after = go(&consumer, &cache, &["run", "."]); + success(&after); + assert_eq!(String::from_utf8_lossy(&after.stdout).trim(), "REJECTED"); +} diff --git a/crates/socket-patch-core/Cargo.toml b/crates/socket-patch-core/Cargo.toml index 5f061cfc6..cd7d02b8c 100644 --- a/crates/socket-patch-core/Cargo.toml +++ b/crates/socket-patch-core/Cargo.toml @@ -37,6 +37,7 @@ thiserror = { workspace = true } walkdir = { workspace = true } uuid = { workspace = true } regex = { workspace = true } +glob = { workspace = true } aho-corasick = { workspace = true } toml_edit = { workspace = true } once_cell = { workspace = true } diff --git a/crates/socket-patch-core/src/crawlers/go_crawler.rs b/crates/socket-patch-core/src/crawlers/go_crawler.rs index 4451b88e1..79576d57c 100644 --- a/crates/socket-patch-core/src/crawlers/go_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/go_crawler.rs @@ -178,7 +178,7 @@ impl GoCrawler { /// Each setting resolves as go resolves it: the environment, then the /// `go env -w` file ([`crate::utils::go_env`]), so a cache configured /// with `go env -w GOMODCACHE=…` is the one crawled (#344). - fn get_gomodcache() -> Option { + pub(crate) fn get_gomodcache() -> Option { if let Some(cache) = crate::utils::go_env::go_env("GOMODCACHE") { return Some(PathBuf::from(cache)); } diff --git a/crates/socket-patch-core/src/patch/redirect/golang_local.rs b/crates/socket-patch-core/src/patch/redirect/golang_local.rs index ec2f342ee..a05189c79 100644 --- a/crates/socket-patch-core/src/patch/redirect/golang_local.rs +++ b/crates/socket-patch-core/src/patch/redirect/golang_local.rs @@ -355,11 +355,17 @@ pub async fn remove_go_redirect( )); } + let entries: Vec<_> = read_replace_entries(project_root) + .await + .into_iter() + .filter(|entry| entry.module == module && entry.owner == Some(owner)) + .collect(); go_mod_edit::drop_replace_entry(project_root, module, owner, dry_run) .await .map_err(std::io::Error::other)?; if !dry_run { + crate::vendor::go_sum_backup::forget(project_root, &entries).await; let copy_dir = copy_dir_for(project_root, base_rel, module, version); // Ignore NotFound. Prunes the emptied `//` levels and // `go-patches/` itself when this was its last module, stopping at @@ -378,7 +384,7 @@ pub async fn reconcile_go_redirects( project_root: &Path, desired: &HashSet, dry_run: bool, -) -> Vec { +) -> Result, String> { let desired_modules: HashSet = desired .iter() .filter_map(|p| parse_golang_purl(p).map(|(m, _)| m.into_owned())) @@ -391,13 +397,17 @@ pub async fn reconcile_go_redirects( if entry.owner == Some(ReplaceOwner::GoPatches) && !desired_modules.contains(entry.module.as_str()) { - let _ = go_mod_edit::drop_replace_entry( + go_mod_edit::drop_replace_entry( project_root, &entry.module, ReplaceOwner::GoPatches, dry_run, ) - .await; + .await?; + if !dry_run { + crate::vendor::go_sum_backup::forget(project_root, std::slice::from_ref(&entry)) + .await; + } if let Some(v) = &entry.version { let purl = build_golang_purl(&entry.module, v); if !removed.contains(&purl) { @@ -437,13 +447,23 @@ pub async fn reconcile_go_redirects( && e.module == module && e.path.as_deref() == Some(target.as_str()) }) { - let _ = go_mod_edit::drop_replace_entry( + go_mod_edit::drop_replace_entry( project_root, module, ReplaceOwner::GoPatches, dry_run, ) - .await; + .await?; + if !dry_run { + let matching: Vec<_> = entries + .iter() + .filter(|e| { + e.module == module && e.owner == Some(ReplaceOwner::GoPatches) + }) + .cloned() + .collect(); + crate::vendor::go_sum_backup::forget(project_root, &matching).await; + } } } if !dry_run { @@ -455,7 +475,7 @@ pub async fn reconcile_go_redirects( } } - removed + Ok(removed) } /// Registry-independent verification for `apply --check` (CI / GitHub-App @@ -1377,7 +1397,7 @@ mod tests { .await; let desired: HashSet = HashSet::new(); - let removed = reconcile_go_redirects(root, &desired, false).await; + let removed = reconcile_go_redirects(root, &desired, false).await.unwrap(); assert!(removed.contains(&PURL.to_string())); assert!(!root .join(".socket/go-patches/github.com/foo/bar@v1.4.2") @@ -1455,7 +1475,7 @@ mod tests { 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; + let removed = reconcile_go_redirects(root, &desired, false).await.unwrap(); assert!(removed.is_empty()); let entries = read_replace_entries(root).await; assert!(entries @@ -1498,7 +1518,7 @@ mod tests { let desired: HashSet = ["pkg:golang/github.com/foo/bar@v1.5.0".to_string()] .into_iter() .collect(); - let removed = reconcile_go_redirects(root, &desired, false).await; + let removed = reconcile_go_redirects(root, &desired, false).await.unwrap(); assert!(removed.contains(&PURL.to_string())); assert!( !root @@ -1553,7 +1573,7 @@ mod tests { let desired: HashSet = ["pkg:golang/github.com/foo/bar@v1.5.0".to_string()] .into_iter() .collect(); - let removed = reconcile_go_redirects(root, &desired, false).await; + let removed = reconcile_go_redirects(root, &desired, false).await.unwrap(); assert!(removed.contains(&PURL.to_string())); assert!( !root @@ -2052,7 +2072,7 @@ mod tests { assert!(result.success, "apply failed: {:?}", result.error); let desired: HashSet = [qualified.to_string()].into_iter().collect(); - let removed = reconcile_go_redirects(root, &desired, false).await; + let removed = reconcile_go_redirects(root, &desired, false).await.unwrap(); assert!( removed.is_empty(), "a desired (qualified) redirect must not be pruned: {removed:?}" @@ -2113,7 +2133,7 @@ mod tests { // Reconcile with the same encoded manifest key — PurlKey // decodes both sides, so they match. let desired: HashSet = [encoded_purl.to_string()].into_iter().collect(); - let removed = reconcile_go_redirects(root, &desired, false).await; + let removed = reconcile_go_redirects(root, &desired, false).await.unwrap(); assert!( removed.is_empty(), "a desired encoded version must not be pruned: {removed:?}" @@ -2449,7 +2469,9 @@ mod tests { .await .unwrap(); - let removed = reconcile_go_redirects(root, &HashSet::new(), true).await; + let removed = reconcile_go_redirects(root, &HashSet::new(), true) + .await + .unwrap(); assert!( removed.contains(&PURL.to_string()), "dry-run must still report the orphan: {removed:?}" @@ -2471,7 +2493,9 @@ mod tests { ); // Parity: the real run removes exactly what the dry run reported. - let removed_wet = reconcile_go_redirects(root, &HashSet::new(), false).await; + let removed_wet = reconcile_go_redirects(root, &HashSet::new(), false) + .await + .unwrap(); assert_eq!( removed_wet, removed, "dry-run report must match the real run" @@ -2502,7 +2526,9 @@ mod tests { assert_eq!(entries[0].owner, Some(ReplaceOwner::GoPatches)); assert_eq!(entries[0].version, None); - let removed = reconcile_go_redirects(root, &HashSet::new(), false).await; + let removed = reconcile_go_redirects(root, &HashSet::new(), false) + .await + .unwrap(); assert!( read_replace_entries(root).await.is_empty(), "the version-less orphan directive must be dropped" @@ -2546,7 +2572,9 @@ mod tests { .await .unwrap(); - let removed = reconcile_go_redirects(root, &HashSet::new(), false).await; + let removed = reconcile_go_redirects(root, &HashSet::new(), false) + .await + .unwrap(); assert_eq!( removed, vec!["pkg:golang/foo@v1.0.0".to_string()], diff --git a/crates/socket-patch-core/src/vendor/go_consumer_sync.rs b/crates/socket-patch-core/src/vendor/go_consumer_sync.rs index b51638281..b3d6e0680 100644 --- a/crates/socket-patch-core/src/vendor/go_consumer_sync.rs +++ b/crates/socket-patch-core/src/vendor/go_consumer_sync.rs @@ -280,6 +280,107 @@ fn modules_txt_location(project_root: &Path) -> (PathBuf, &'static str) { ) } +// GOFLAGS uses Go's quoted.Split: quotes surround a whole argument and do +// not unescape its contents. Both -flag and --flag are accepted. +fn go_mod_flag(mut flags: &str) -> Option<&str> { + let space = |c: char| matches!(c, ' ' | '\t' | '\n' | '\r'); + let mut mode = None; + loop { + flags = flags.trim_start_matches(space); + if flags.is_empty() { + return mode; + } + let (word, rest) = + if let Some(quote) = flags.chars().next().filter(|c| *c == '\'' || *c == '"') { + let (word, rest) = flags[1..].split_once(quote)?; + (word, rest) + } else { + let end = flags.find(space).unwrap_or(flags.len()); + (&flags[..end], &flags[end..]) + }; + if let Some(value) = word + .strip_prefix("-mod=") + .or_else(|| word.strip_prefix("--mod=")) + { + mode = Some(value); + } + flags = rest; + } +} + +/// The native vendor tree selected by Go, including GOFLAGS and go.work. +/// An absent modules.txt does not disable vendor mode; it makes it invalid. +pub async fn native_vendor_dir(project_root: &Path) -> Option { + let flags = crate::utils::go_env::go_env("GOFLAGS").unwrap_or_default(); + let mode = go_mod_flag(&flags); + if mode.is_some_and(|mode| mode != "vendor") { + return None; + } + let (modules_txt, _) = modules_txt_location(project_root); + let dir = modules_txt.parent()?.to_path_buf(); + if mode == Some("vendor") { + return Some(dir); + } + if !dir.is_dir() { + return None; + } + let work = workspace_file(project_root); + // Go only automatically selects a tree generated in the same mode. A + // module vendor tree at a workspace root is otherwise ignored. + let modules = read_regular_to_string(&modules_txt) + .await + .unwrap_or_default(); + let workspace_tree = modules.lines().next().is_some_and(|line| { + line.strip_prefix("## ") + .is_some_and(|annotations| annotations.split(';').any(|a| a.trim() == "workspace")) + }); + if workspace_tree != work.is_some() { + return None; + } + let config = work.clone().unwrap_or_else(|| project_root.join("go.mod")); + let text = read_regular_to_string(&config).await.ok()?; + let normalized = go_mod_edit::normalize_for_read(&text); + let version = normalized.lines().find_map(|line| { + let mut words = line.split_whitespace(); + (words.next()? == "go").then(|| words.next()).flatten() + })?; + let mut parts = version.split('.'); + let major = parts.next()?.parse::().ok()?; + let minor = parts.next()?.parse::().ok()?; + (major > 1 || major == 1 && minor >= 14).then_some(dir) +} + +/// Native vendoring copies runtime files and omits dependency tests and +/// manifests. Its bytes must agree with the patch even when the Socket copy +/// is intact: Go builds this tree without reading the replacement directory. +pub async fn verify_native_vendor( + project_root: &Path, + purl: &str, + record: &crate::manifest::schema::PatchRecord, +) -> Result<(), String> { + let Some((module, version)) = crate::utils::purl::parse_golang_purl(purl) else { + return Ok(()); + }; + let Some(dir) = native_vendor_dir(project_root).await else { + return Ok(()); + }; + if !crate::patch::redirect::golang_local::are_safe_redirect_coords(&module, &version) { + return Err("invalid Go module coordinates".into()); + } + let replaces = go_mod_edit::read_replace_entries(project_root).await; + if let Some(issue) = vendor_modules_txt_issues(project_root, &replaces) + .await + .into_iter() + .find(|issue| issue.module() == module) + { + return Err(issue.to_string()); + } + let (_, command) = modules_txt_location(project_root); + super::go_native_vendor::verify(project_root, &dir, &module, &version, record, &replaces) + .await + .map_err(|detail| format!("{detail}; run `{command}` and commit vendor/")) +} + /// Whether the go command builds `project_root` in workspace mode (a /// `go.work` from `GOWORK` or at or above the project), so its build list /// spans every member's requirements, not only the project go.mod's. @@ -694,6 +795,21 @@ fn go_semver_cmp(a: &str, b: &str) -> std::cmp::Ordering { mod tests { use super::*; + #[test] + fn goflags_uses_whole_argument_quotes_and_last_mod_flag() { + assert_eq!( + go_mod_flag("-mod=vendor '--mod=readonly'"), + Some("readonly") + ); + assert_eq!(go_mod_flag("\"--mod=mod\" -tags=linux"), Some("mod")); + assert_eq!( + go_mod_flag("'-ldflags=-X main.flag=-mod=vendor' --mod=mod"), + Some("mod") + ); + assert_eq!(go_mod_flag("'-ldflags=-X main.flag=-mod=vendor'"), None); + assert_eq!(go_mod_flag("'unterminated"), None); + } + const COPY: &str = ".socket/go-patches/example.com/upstream@v1.0.0"; fn write(root: &Path, rel: &str, text: &str) { diff --git a/crates/socket-patch-core/src/vendor/go_mod_edit.rs b/crates/socket-patch-core/src/vendor/go_mod_edit.rs index d0de79958..6ff5238e9 100644 --- a/crates/socket-patch-core/src/vendor/go_mod_edit.rs +++ b/crates/socket-patch-core/src/vendor/go_mod_edit.rs @@ -188,6 +188,13 @@ pub async fn ensure_replace_entry( { return Err(refusal); } + if !dry_run { + let content = read_regular_to_string(&go_mod_path(project_root)) + .await + .map_err(|e| e.to_string())?; + upsert_replace_entry(&content, module, version, base_rel)?; + super::go_sum_backup::save(project_root, base_rel, module, version).await?; + } edit_go_mod(project_root, dry_run, |c| { upsert_replace_entry(c, module, version, base_rel) }) @@ -205,10 +212,19 @@ pub async fn drop_replace_entry( owner: ReplaceOwner, dry_run: bool, ) -> Result { - edit_go_mod(project_root, dry_run, |c| { + let entries: Vec<_> = read_replace_entries(project_root) + .await + .into_iter() + .filter(|entry| entry.module == module && entry.owner == Some(owner)) + .collect(); + if !dry_run && !entries.is_empty() { + super::go_sum_backup::restore(project_root, &entries).await?; + } + let changed = edit_go_mod(project_root, dry_run, |c| { remove_replace_entry(c, module, owner) }) - .await + .await?; + Ok(changed) } // ── file resolution + read/write ────────────────────────────────────────────── diff --git a/crates/socket-patch-core/src/vendor/go_native_vendor.rs b/crates/socket-patch-core/src/vendor/go_native_vendor.rs new file mode 100644 index 000000000..b8031186a --- /dev/null +++ b/crates/socket-patch-core/src/vendor/go_native_vendor.rs @@ -0,0 +1,478 @@ +//! Verify the projection of a patch into Go's selected native vendor tree. +//! modules.txt lists copied packages, not every package in the module. Files +//! outside those packages (except embeds/metadata) and dependency tests are +//! legitimately absent. Present files are always checked; a missing file in a +//! selected package needs proof that Go omits it. + +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use crate::crawlers::go_crawler::{encode_module_path, GoCrawler}; +use crate::hash::git_sha256::compute_git_sha256_from_bytes; +use crate::manifest::schema::PatchRecord; +use crate::patch::apply::{is_safe_relative_subpath, normalize_file_path}; +use crate::patch::file_hash::compute_file_git_sha256; +use crate::utils::fs::read_regular_to_string; + +use super::go_mod_edit::{self, ReplaceEntry}; + +/// Read expected text from an authenticated blob or replacement copy. Never +/// trust a build constraint or dependency version from unverified content. +async fn expected_text( + root: &Path, + replaces: &[ReplaceEntry], + module: &str, + version: &str, + name: &str, + hash: &str, +) -> Option { + if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let mut paths = vec![root.join(".socket/blobs").join(hash)]; + for entry in replaces.iter().filter(|e| { + e.module == module && (e.version.is_none() || e.version.as_deref() == Some(version)) + }) { + if let Some(path) = &entry.path { + paths.push(root.join(path).join(name)); + } else if let (Some(cache), Some(target), Some(ver)) = ( + GoCrawler::get_gomodcache(), + &entry.rhs_module, + &entry.rhs_version, + ) { + if crate::patch::redirect::golang_local::are_safe_redirect_coords(target, ver) { + paths.push( + cache + .join(format!( + "{}@{}", + encode_module_path(target), + encode_module_path(ver) + )) + .join(name), + ); + } + } + } + for path in paths { + if let Ok(text) = read_regular_to_string(&path).await { + if compute_git_sha256_from_bytes(text.as_bytes()) == hash { + return Some(text); + } + } + } + None +} + +pub(super) async fn verify( + root: &Path, + dir: &Path, + module: &str, + version: &str, + record: &PatchRecord, + replaces: &[ReplaceEntry], +) -> Result<(), String> { + let text = read_regular_to_string(&dir.join("modules.txt")) + .await + .map_err(|e| format!("cannot read {}/modules.txt: {e}", dir.display()))?; + let packages: HashSet<_> = text + .lines() + .filter(|s| !s.starts_with('#') && !s.is_empty()) + .collect(); + // Trailing replacement-only headers describe unused versions too. Only + // a section with metadata or packages records a selected requirement. + let mut versions = HashMap::new(); + let mut current = None; + for line in text.lines() { + if let Some(header) = line.strip_prefix("# ") { + let mut fields = header.split_whitespace(); + current = fields + .next() + .zip(fields.next()) + .filter(|(_, v)| v.starts_with('v')); + } else if !line.is_empty() { + if let Some((module, version)) = current { + versions.insert(module, version); + } + } + } + if versions.get(module).copied() != Some(version) { + return Err(format!( + "vendor/modules.txt does not select {module} {version}" + )); + } + for (name, info) in &record.files { + let name = normalize_file_path(name); + if !is_safe_relative_subpath(name) { + return Err("invalid Go patch file path".into()); + } + if name == "go.mod" { + let patched = expected_text(root, replaces, module, version, name, &info.after_hash) + .await + .ok_or_else(|| format!("cannot verify the patched {module}/go.mod requirements"))?; + for (dep, required) in + go_mod_edit::parse_required_versions(&go_mod_edit::normalize_for_read(&patched)) + { + // A dependency pruned out of the consumer's graph has no + // vendored packages. A selected dependency must be new enough. + if versions.get(dep.as_str()).is_some_and(|selected| { + super::go_sum_edit::go_semver_cmp(selected, &required).is_lt() + }) { + return Err(format!( + "vendor/modules.txt selects {dep} below patched requirement {required}" + )); + } + } + continue; + } + if name == "go.sum" || name.ends_with("_test.go") { + continue; + } + let path = dir.join(module).join(name); + match compute_file_git_sha256(&path).await { + Ok(hash) if hash == info.after_hash => continue, + Ok(_) => { + return Err(format!( + "{} does not match the patched file", + path.display() + )) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("cannot verify {}: {e}", path.display())), + } + // Empty after hashes denote deletions, not empty files. + if info.after_hash.is_empty() { + continue; + } + let parent = Path::new(name).parent().unwrap_or(Path::new("")); + let package = if parent.as_os_str().is_empty() { + module.to_string() + } else { + Path::new(module) + .join(parent) + .to_string_lossy() + .replace('\\', "/") + }; + if !packages.contains(package.as_str()) { + if !is_embedded(dir, module, name, &packages).await? { + continue; + } + return Err(format!( + "{} is missing a patched embedded asset", + path.display() + )); + } + if name.ends_with(".go") { + if let Some(text) = + expected_text(root, replaces, module, version, name, &info.after_hash).await + { + if omitted_by_build_constraint(&text) { + continue; + } + } + } + return Err(format!( + "{} is missing a patched runtime file", + path.display() + )); + } + Ok(()) +} + +// Embedded files can live below a selected package without being packages +// themselves. Match both explicit files and recursively embedded directories. +async fn is_embedded( + dir: &Path, + module: &str, + name: &str, + packages: &HashSet<&str>, +) -> Result { + for package in packages { + let relative = if *package == module { + name + } else if let Some(subdir) = package.strip_prefix(&format!("{module}/")) { + let Some(relative) = name.strip_prefix(&format!("{subdir}/")) else { + continue; + }; + relative + } else { + continue; + }; + let mut files = tokio::fs::read_dir(dir.join(package)) + .await + .map_err(|e| e.to_string())?; + while let Some(file) = files.next_entry().await.map_err(|e| e.to_string())? { + let filename = file.file_name(); + let filename = filename.to_string_lossy(); + if !filename.ends_with(".go") || filename.ends_with("_test.go") { + continue; + } + let source = read_regular_to_string(&file.path()) + .await + .map_err(|e| e.to_string())?; + if omitted_by_build_constraint(&source) { + continue; + } + for line in source.lines() { + let Some(patterns) = line + .trim() + .strip_prefix("//go:embed") + .filter(|tail| tail.starts_with(char::is_whitespace)) + else { + continue; + }; + let patterns = embed_patterns(patterns).ok_or_else(|| { + format!("cannot parse embed patterns in {}", file.path().display()) + })?; + for pattern in patterns { + let (all, pattern) = pattern + .strip_prefix("all:") + .map_or((false, pattern.as_str()), |p| (true, p)); + let pattern = glob::Pattern::new(pattern).map_err(|e| e.to_string())?; + let parts: Vec<_> = relative.split('/').collect(); + for end in 1..=parts.len() { + if pattern.matches_with( + &parts[..end].join("/"), + glob::MatchOptions { + case_sensitive: true, + require_literal_separator: true, + require_literal_leading_dot: false, + }, + ) && (all || parts[end..].iter().all(|p| !p.starts_with(['.', '_']))) + { + return Ok(true); + } + } + } + } + } + } + Ok(false) +} + +fn embed_patterns(mut text: &str) -> Option> { + let mut patterns = Vec::new(); + while !text.trim_start().is_empty() { + text = text.trim_start(); + if let Some(rest) = text.strip_prefix('`') { + let (pattern, rest) = rest.split_once('`')?; + patterns.push(pattern.to_string()); + text = rest; + } else if let Some(rest) = text.strip_prefix('"') { + let mut chars = rest.char_indices(); + let mut pattern = String::new(); + let mut end = None; + while let Some((i, c)) = chars.next() { + match c { + '"' => { + end = Some(i + 1); + break; + } + '\\' => { + let (_, escaped) = chars.next()?; + pattern.push(match escaped { + 'a' => '\u{7}', + 'b' => '\u{8}', + 'f' => '\u{c}', + 'n' => '\n', + 'r' => '\r', + 't' => '\t', + 'v' => '\u{b}', + '\\' | '"' => escaped, + 'x' | 'u' | 'U' | '0'..='7' => { + let (radix, count, mut value) = match escaped { + 'x' => (16, 2, 0), + 'u' => (16, 4, 0), + 'U' => (16, 8, 0), + c => (8, 2, c.to_digit(8)?), + }; + for _ in 0..count { + value = value + .checked_mul(radix)? + .checked_add(chars.next()?.1.to_digit(radix)?)?; + } + char::from_u32(value)? + } + _ => return None, + }); + } + c => pattern.push(c), + } + } + text = &rest[end?..]; + patterns.push(pattern); + } else { + let end = text.find(char::is_whitespace).unwrap_or(text.len()); + patterns.push(text[..end].to_string()); + text = &text[end..]; + } + } + Some(patterns) +} + +// Go vendors files for every platform/tag except constraints that require +// `ignore`. Each ordinary tag can be true OR false in this mode. The pair is +// Go's AnyTags evaluation for prefer=true and prefer=false respectively. +fn constraint(expr: &str) -> Option { + fn atom(input: &mut &str, depth: usize) -> Option<(bool, bool)> { + if depth > 100 { + return None; + } + *input = input.trim_start(); + if let Some(rest) = input.strip_prefix('!') { + *input = rest; + let (a, b) = atom(input, depth + 1)?; + return Some((!b, !a)); + } + if let Some(rest) = input.strip_prefix('(') { + *input = rest; + let value = or(input, depth + 1)?; + *input = input.trim_start().strip_prefix(')')?; + return Some(value); + } + let end = input + .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '.')) + .unwrap_or(input.len()); + if end == 0 { + return None; + } + let tag = &input[..end]; + *input = &input[end..]; + Some((tag != "ignore", false)) + } + fn and(input: &mut &str, depth: usize) -> Option<(bool, bool)> { + let mut value = atom(input, depth)?; + while let Some(rest) = input.trim_start().strip_prefix("&&") { + *input = rest; + let rhs = atom(input, depth)?; + value = (value.0 && rhs.0, value.1 && rhs.1); + } + Some(value) + } + fn or(input: &mut &str, depth: usize) -> Option<(bool, bool)> { + let mut value = and(input, depth)?; + while let Some(rest) = input.trim_start().strip_prefix("||") { + *input = rest; + let rhs = and(input, depth)?; + value = (value.0 || rhs.0, value.1 || rhs.1); + } + Some(value) + } + let mut input = expr; + let value = or(&mut input, 0)?; + input.trim().is_empty().then_some(value.0) +} + +fn omitted_by_build_constraint(text: &str) -> bool { + let mut modern = None; + let mut legacy = Vec::new(); + let mut legacy_end = 0; + let mut block = false; + let mut legacy_allowed = true; + for line in text.lines() { + let mut line = line.trim(); + if line.is_empty() && legacy_allowed { + legacy_end = legacy.len(); + } + loop { + if block { + let Some((_, rest)) = line.split_once("*/") else { + break; + }; + line = rest.trim(); + block = false; + } else if let Some(rest) = line.strip_prefix("/*") { + block = true; + legacy_allowed = false; + line = rest; + } else { + break; + } + } + if block { + continue; + } + if let Some(expr) = line.strip_prefix("//go:build ") { + if modern.replace(expr).is_some() { + return false; + } + } else if legacy_allowed { + if let Some(expr) = line.strip_prefix("// +build ") { + legacy.push(expr); + } + } + if !line.is_empty() && !line.starts_with("//") { + break; + } + } + if let Some(expr) = modern { + return constraint(expr) == Some(false); + } + legacy[..legacy_end].iter().any(|line| { + // Legacy constraints: whitespace OR, commas AND. + !line + .split_whitespace() + .any(|term| term.split(',').all(|tag| tag != "ignore")) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn embed_patterns_decode_go_literals() { + assert_eq!( + embed_patterns(r#"data/* "space dir/*.txt" `all:assets` "data/\x66ix.txt""#), + Some(vec![ + "data/*".into(), + "space dir/*.txt".into(), + "all:assets".into(), + "data/fix.txt".into() + ]) + ); + assert_eq!(embed_patterns("\"unterminated"), None); + } + + #[test] + fn vendor_build_constraints_match_any_platform_except_ignore() { + for expr in [ + "ignore", + "linux && ignore", + "(windows || linux) && ignore", + "!!ignore", + ] { + assert!( + omitted_by_build_constraint(&format!("//go:build {expr}\n\npackage main\n")), + "{expr}" + ); + } + for expr in [ + "linux", + "!linux", + "linux && !linux", + "ignore || windows", + "!ignore", + "!(ignore && linux)", + ] { + assert!( + !omitted_by_build_constraint(&format!("//go:build {expr}\n\npackage main\n")), + "{expr}" + ); + } + assert!(omitted_by_build_constraint( + "// +build ignore,linux\n\npackage main\n" + )); + assert!(!omitted_by_build_constraint( + "// +build ignore linux\n\npackage main\n" + )); + assert!(!omitted_by_build_constraint( + "// +build ignore\npackage main\n" + )); + assert!(!omitted_by_build_constraint( + "/* //go:build ignore */\npackage main\n" + )); + assert!(!omitted_by_build_constraint( + "package main\n//go:build ignore\n" + )); + } +} diff --git a/crates/socket-patch-core/src/vendor/go_sum_backup.rs b/crates/socket-patch-core/src/vendor/go_sum_backup.rs new file mode 100644 index 000000000..88c22bbc2 --- /dev/null +++ b/crates/socket-patch-core/src/vendor/go_sum_backup.rs @@ -0,0 +1,214 @@ +use std::path::{Path, PathBuf}; + +use super::go_mod_edit::{ReplaceEntry, ReplaceOwner}; +use super::go_sum_edit::reinsert_lines; +use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; + +const NAME: &str = ".upstream-go.sum"; + +async fn read(path: &Path) -> Result { + match read_regular_to_string(path).await { + Ok(text) => Ok(text), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), + Err(e) => Err(format!("read {}: {e}", path.display())), + } +} + +fn selected(text: &str, module: &str, version: &str) -> String { + text.lines() + .filter(|line| { + let mut fields = line.split_whitespace(); + fields.next() == Some(module) + && fields + .next() + .is_some_and(|v| v == version || v.strip_suffix("/go.mod") == Some(version)) + }) + .collect::>() + .join("\n") +} + +pub(super) async fn save( + root: &Path, + base: &str, + module: &str, + version: &str, +) -> Result<(), String> { + let mut sums = selected(&read(&root.join("go.sum")).await?, module, version); + for entry in super::go_mod_edit::read_replace_entries(root).await { + if entry.module == module && entry.version.as_deref() == Some(version) { + if let Some(path) = backup(root, &entry) { + let saved = selected(&read(&path).await?, module, version); + if let Some(merged) = reinsert_lines(&sums, &saved) { + sums = merged; + } + } + } + } + if sums.is_empty() { + return Ok(()); + } + let path = root.join(base).join(NAME); + let old = read(&path).await?; + if let Some(new) = reinsert_lines(&old, &sums) { + tokio::fs::create_dir_all(root.join(base)) + .await + .map_err(|e| e.to_string())?; + atomic_write_bytes_preserving_mode(&path, new.as_bytes()) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +fn backup(root: &Path, entry: &ReplaceEntry) -> Option { + if !matches!( + entry.owner, + Some(ReplaceOwner::GoPatches | ReplaceOwner::Vendor) + ) { + return None; + } + let path = entry + .path + .as_deref()? + .strip_prefix("./") + .unwrap_or(entry.path.as_deref()?); + if !crate::patch::apply::is_safe_relative_subpath(path) { + return None; + } + let suffix = format!("/{}@{}", entry.module, entry.version.as_deref()?); + Some(root.join(path.strip_suffix(&suffix)?).join(NAME)) +} + +/// Restore only this module's saved checksums, preserving unrelated sums and +/// refusing a conflicting live pin. The backup travels with the local copy. +pub(super) async fn restore(root: &Path, entries: &[ReplaceEntry]) -> Result<(), String> { + let path = root.join("go.sum"); + let mut sums = read(&path).await?; + let original = sums.clone(); + for entry in entries { + let (Some(backup), Some(version)) = (backup(root, entry), entry.version.as_deref()) else { + continue; + }; + let saved = selected(&read(&backup).await?, &entry.module, version); + for line in saved.lines() { + let fields: Vec<_> = line.split_whitespace().collect(); + if fields.len() != 3 || !fields[2].starts_with("h1:") { + return Err(format!("invalid saved checksum in {}", backup.display())); + } + if sums.lines().any(|live| { + let live: Vec<_> = live.split_whitespace().collect(); + live.len() == 3 && live[..2] == fields[..2] && live[2] != fields[2] + }) { + return Err(format!( + "go.sum conflicts with the saved checksum for {} {version}", + entry.module + )); + } + } + if let Some(new) = reinsert_lines(&sums, &saved) { + sums = new; + } + } + if sums != original { + atomic_write_bytes_preserving_mode(&path, sums.as_bytes()) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +pub(crate) async fn forget(root: &Path, entries: &[ReplaceEntry]) { + for entry in entries { + let (Some(path), Some(version)) = (backup(root, entry), entry.version.as_deref()) else { + continue; + }; + let Ok(old) = read(&path).await else { + continue; + }; + let ours = selected(&old, &entry.module, version); + let Some(new) = super::go_sum_edit::remove_lines(&old, &ours) else { + continue; + }; + if new.is_empty() { + let _ = tokio::fs::remove_file(&path).await; + } else { + let _ = atomic_write_bytes_preserving_mode(&path, new.as_bytes()).await; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vendor::go_mod_edit::{drop_replace_entry, ensure_replace_entry, GO_PATCHES_DIR}; + + const MODULE: &str = "example.com/upstream"; + const VERSION: &str = "v1.0.0"; + const SUMS: &str = "example.com/upstream v1.0.0 h1:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=\nexample.com/upstream v1.0.0/go.mod h1:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb=\n"; + + #[tokio::test] + async fn preserves_sums_across_tidy_and_mode_takeover() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + tokio::fs::write( + root.join("go.mod"), + "module consumer\ngo 1.21\nrequire example.com/upstream v1.0.0\n", + ) + .await + .unwrap(); + tokio::fs::write(root.join("go.sum"), SUMS).await.unwrap(); + ensure_replace_entry(root, MODULE, VERSION, GO_PATCHES_DIR, true) + .await + .unwrap(); + assert!(!root.join(GO_PATCHES_DIR).exists()); + ensure_replace_entry(root, MODULE, VERSION, GO_PATCHES_DIR, false) + .await + .unwrap(); + tokio::fs::write(root.join("go.sum"), "example.com/other v1.0.0 h1:other\n") + .await + .unwrap(); + let vendor = ".socket/vendor/golang/11111111-2222-4333-8444-555555555555"; + ensure_replace_entry(root, MODULE, VERSION, vendor, false) + .await + .unwrap(); + tokio::fs::remove_dir_all(root.join(GO_PATCHES_DIR)) + .await + .unwrap(); + drop_replace_entry(root, MODULE, ReplaceOwner::Vendor, false) + .await + .unwrap(); + assert_eq!( + read(&root.join("go.sum")).await.unwrap(), + format!("example.com/other v1.0.0 h1:other\n{SUMS}") + ); + // Retained while the artifact is retained (hosted dry-run takeovers + // temporarily remove and restore wiring without deleting artifacts). + assert!(root.join(vendor).join(NAME).exists()); + } + + #[tokio::test] + async fn conflicting_checksum_refuses_rollback_without_dropping_replace() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + tokio::fs::write(root.join("go.mod"), "module consumer\ngo 1.21\n") + .await + .unwrap(); + tokio::fs::write(root.join("go.sum"), SUMS).await.unwrap(); + ensure_replace_entry(root, MODULE, VERSION, GO_PATCHES_DIR, false) + .await + .unwrap(); + let before = read(&root.join("go.mod")).await.unwrap(); + let conflict = SUMS.replace("h1:aaaa", "h1:zzzz"); + tokio::fs::write(root.join("go.sum"), &conflict) + .await + .unwrap(); + assert!( + drop_replace_entry(root, MODULE, ReplaceOwner::GoPatches, false) + .await + .unwrap_err() + .contains("conflicts") + ); + assert_eq!(read(&root.join("go.mod")).await.unwrap(), before); + assert_eq!(read(&root.join("go.sum")).await.unwrap(), conflict); + } +} diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index b2ab62ebd..3ba8f2e12 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -66,6 +66,8 @@ pub mod gem; pub mod go_build_list; pub mod go_consumer_sync; pub mod go_mod_edit; +mod go_native_vendor; +pub(crate) mod go_sum_backup; pub mod go_sum_edit; pub mod golang; pub mod jvm; diff --git a/crates/socket-patch-core/src/vex/verify.rs b/crates/socket-patch-core/src/vex/verify.rs index 45b485ba6..56d6f3aab 100644 --- a/crates/socket-patch-core/src/vex/verify.rs +++ b/crates/socket-patch-core/src/vex/verify.rs @@ -186,6 +186,22 @@ pub async fn applied_patches_with_copies( let mut out = VerifyOutcome::default(); for (purl, record) in &manifest.patches { + if let Some(ctx) = vendor { + if crate::vendor::go_consumer_sync::verify_native_vendor( + &ctx.project_root, + purl, + record, + ) + .await + .is_err() + { + out.failed.push(FailedPatch { + purl: purl.clone(), + reason: "go_vendor_content_mismatch".to_string(), + }); + continue; + } + } let vendor_entry = vendor.and_then(|ctx| lookup_entry(&ctx.entries, purl).map(|e| (ctx, e))); let result = if let Some((ctx, entry)) = vendor_entry { diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 60a2a8fce..824b25970 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -1391,6 +1391,18 @@ commit it, and review it like any other vendored code. The wiring survives `go mod tidy`, and `apply --check` gives CI a read-only audit that the committed redirects still match the manifest. +Local modes keep the replaced module's original checksum lines in +`.upstream-go.sum` beside the patched module tree. Commit that file with `.socket/`: +rollback restores those lines offline even after `go mod tidy` removes them from +`go.sum`. A conflicting checksum fails rollback without removing the replacement. +Projects patched by older releases without this backup may still need +`go mod download @` after rollback. + +When native Go vendoring is active, VEX and the audit commands also verify +`vendor/`, including the workspace vendor tree. A pristine or modified runtime +file there fails verification even when the `.socket` copy remains intact. +Regenerate it with `go mod vendor` or `go work vendor`. + Hosted mode uses a fork-style module replacement and two committed checksums: ```text diff --git a/docs/testing/hosted-production-e2e.md b/docs/testing/hosted-production-e2e.md index 1af30895b..5fd2b9e80 100644 --- a/docs/testing/hosted-production-e2e.md +++ b/docs/testing/hosted-production-e2e.md @@ -26,7 +26,7 @@ publication guarantee. | Native production installs | npm/shrinkwrap, pnpm, Yarn Classic, Yarn Berry with node-modules, Bun, pip requirements, uv, and Bundler | | Conditional production proof | vlt: validates the served encoding before attempting installation | | Catalog canaries | Cargo, Maven, NuGet, and Composer: detect when a free fixture becomes available; do not prove an install | -| Go | Validates the required hosted reference shape; see [Go support](../ecosystems.md#go-directory-replaces-and-gosum) | +| Go | pgx/v5 v5.7.0: get and scan, decoder regression, fresh-cache readonly build; requires the `/v5` hosted artifact | | Unsupported mode | Deno hosted refusal | Poetry, PDM, and Pipenv production installers are covered by their diff --git a/docs/testing/vendored-production-e2e.md b/docs/testing/vendored-production-e2e.md index b96770b8d..40770f655 100644 --- a/docs/testing/vendored-production-e2e.md +++ b/docs/testing/vendored-production-e2e.md @@ -28,7 +28,8 @@ when the catalog changes, updating both suites. | --- | --- | | Native production installs | npm, pnpm, Yarn Classic, Yarn Berry with node-modules, Bun text locks, vlt, pip requirements, uv, and Bundler | | Catalog canaries | Cargo, Maven, NuGet, and Composer; require a published free fixture before adding full install proofs | -| Go / Deno | Catalog/unsupported-mode assertions, not production vendor install proofs | +| Go | pgx/v5 v5.7.0: vendored and agent get/scan, decoder regression, fresh-cache build, rollback after tidy, cold-cache agent refusal | +| Deno | Unsupported-mode assertion | The vlt case installs a committed directory artifact with `vlt ci`. The CLI decodes the service download before vendoring it, so the hosted vlt From 5c40667023faf9b098fe855d37f094a9aaf3e879 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 23 Oct 2026 15:37:15 -0400 Subject: [PATCH 2/2] Keep Go patches usable after reinstall Reuse verified project-local agent copies after clearing the Go module cache. Refuse corrupted copies without an upstream rebuild source. Exercise get, scan, dependency maintenance, repeated native vendoring, and workspace synchronization with the patched pgx decoder. Assisted-by: Codex:gpt-6-astra --- crates/socket-patch-cli/src/commands/apply.rs | 43 ++++++++++++++- .../tests/e2e_golang_build.rs | 52 +++++++++++++++++++ .../tests/e2e_golang_workspace_build.rs | 49 +++++++++++++++++ .../tests/golang_production/mod.rs | 40 +++++++++++++- .../src/patch/redirect/golang_local.rs | 51 ++++++++++++++++++ docs/ecosystems.md | 7 +++ 6 files changed, 238 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 5fe2c5a2c..6d5c88692 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -2274,7 +2274,46 @@ async fn apply_patches_inner( // the root store is the same copy the root walk found (#633). distinct_npm_copies(&mut all_packages).await; + let mut reused_go = false; + for (purl, record) in &manifest.patches { + if is_local_go(purl, &args.common) && !is_vendored(purl) && !all_packages.contains_key(purl) + { + if let Some(result) = + socket_patch_core::patch::redirect::golang_local::reuse_go_redirect( + purl, + &args.common.cwd, + &record.files, + ) + .await + { + if !result.success && args.prints_errors() { + eprintln!( + "{}", + format_patch_failure( + purl, + result.error.as_deref().unwrap_or("invalid Go redirect") + ) + ); + } + matched_manifest_purls.insert(purl.clone()); + results.push(result); + reused_go = true; + } + } + } + if all_packages.is_empty() { + if reused_go && !args.common.dry_run { + run_warnings.extend( + socket_patch_core::vendor::go_consumer_sync::audit_warnings( + &args.common.cwd, + &go_pristine_mods, + ) + .await + .into_iter() + .map(|(code, detail)| RunWarning { code, detail }), + ); + } // Vendored purls are already accounted for (synthesized Skipped/ // vendored results above); only the remainder is genuinely // unmatched. An all-vendored manifest with an absent installed @@ -2300,7 +2339,7 @@ async fn apply_patches_inner( } print_lockfile_only_note(args, &unmatched, &lockfile_only); return Ok(ApplyOutcome { - success: unresolved.is_empty(), + success: unresolved.is_empty() && results.iter().all(|r| r.success), results, unmatched, lockfile_only, @@ -2318,7 +2357,7 @@ async fn apply_patches_inner( args.common.strict, args.common.allow_patch_conflicts, ); - let mut has_errors = false; + let mut has_errors = results.iter().any(|r| !r.success); // Group release-variant PURLs by base. PyPI (`?artifact_id=`), // RubyGems (`?platform=`), and Maven (`?classifier=&ext=`) carry diff --git a/crates/socket-patch-cli/tests/e2e_golang_build.rs b/crates/socket-patch-cli/tests/e2e_golang_build.rs index fce1196c7..08edc3fec 100644 --- a/crates/socket-patch-cli/tests/e2e_golang_build.rs +++ b/crates/socket-patch-cli/tests/e2e_golang_build.rs @@ -1270,6 +1270,11 @@ fn native_vendor_projection_and_workspace_selection() { "'--mod=readonly'", "\"-mod=mod\"", ] { + // Go 1.18 splits GOFLAGS on whitespace; quoted arguments were added + // later. The unquoted double-hyphen forms work throughout the matrix. + if golang_e2e_matrix::go_minor() < 21 && flags.starts_with(['\"', '\'']) { + continue; + } let mut bypass = env.clone(); bypass.push(("GOFLAGS", flags)); assert!( @@ -1361,3 +1366,50 @@ fn checksum_conflict_preserves_orphan_redirect_and_reports_failure() { assert!(go_out(&consumer, &env).contains("OUT: PRISTINE")); chmod_writable(tmp.path()); } + +#[test] +fn agent_reapply_after_cache_clear_validates_the_saved_copy() { + if !golang_e2e_matrix::toolchain_ready("agent reuse after reinstall") { + return; + } + let tmp = tempfile::tempdir().unwrap(); + let (consumer, modcache, proxy) = stage(tmp.path()); + write_patch(&consumer); + let (code, so, se) = apply_json(&consumer, &modcache); + assert_eq!(code, 0, "{so} {se}"); + let env = workspace_env(modcache.to_str().unwrap(), &proxy); + assert!(go(&consumer, &["clean", "-modcache"], &env) + .status + .success()); + assert!(go_out(&consumer, &env).contains("OUT: PATCHED")); + let go_mod = std::fs::read(consumer.join("go.mod")).unwrap(); + let (code, so, se) = apply_json(&consumer, &modcache); + assert_eq!( + code, 0, + "valid committed redirect needs no module cache: {so} {se}" + ); + let (code, so, se) = run_socket( + &consumer, + &["apply", "--offline", "--dry-run", "--json"], + &modcache, + ); + assert_eq!( + code, 0, + "preview must also recognize the installed patch: {so} {se}" + ); + assert_eq!(std::fs::read(consumer.join("go.mod")).unwrap(), go_mod); + let lib = consumer + .join(".socket/go-patches") + .join(format!("{UMOD}@{UVER}/lib.go")); + assert_eq!(std::fs::read_to_string(&lib).unwrap(), PATCHED_LIB); + std::fs::write(&lib, PRISTINE_LIB).unwrap(); + let (code, so, se) = apply_json(&consumer, &modcache); + assert_eq!( + code, 1, + "a corrupt saved copy must fail without an upstream source: {so} {se}" + ); + assert!(so.contains("out of sync"), "{so}"); + assert_eq!(std::fs::read_to_string(&lib).unwrap(), PRISTINE_LIB); + assert_eq!(std::fs::read(consumer.join("go.mod")).unwrap(), go_mod); + chmod_writable(tmp.path()); +} diff --git a/crates/socket-patch-cli/tests/e2e_golang_workspace_build.rs b/crates/socket-patch-cli/tests/e2e_golang_workspace_build.rs index 73122218a..d7fc59a6b 100644 --- a/crates/socket-patch-cli/tests/e2e_golang_workspace_build.rs +++ b/crates/socket-patch-cli/tests/e2e_golang_workspace_build.rs @@ -117,6 +117,42 @@ fn run_members(dir: &Path, machine: &Machine, env: &[(&str, &str)], want: &str, } } +/// Reinstall both members, then synchronize their requirements. Regenerating +/// native workspace vendoring must also retain the patched runtime bytes. +fn reinstall_workspace(root: &Path, machine: &Machine, env: &[(&str, &str)]) { + run_members(root, machine, env, "PATCHED", "before cache removal"); + for args in [ + vec!["clean", "-modcache", "-cache"], + vec!["mod", "download"], + vec!["work", "sync"], + ] { + let out = go(root, machine, &args, env); + assert!( + out.status.success(), + "{args:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + } + run_members( + root, + machine, + env, + "PATCHED", + "after reinstall and go work sync", + ); + if golang_e2e_matrix::go_minor() >= 22 { + let out = go(root, machine, &["work", "vendor"], env); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + run_members(root, machine, env, "PATCHED", "after go work vendor"); + // Later VEX tails deliberately inspect the module replacement itself. + std::fs::remove_dir_all(root.join("vendor")).unwrap(); + } +} + /// Go extracts modules read-only; restore write bits so the tempdir drops. struct ChmodGuard(PathBuf); impl Drop for ChmodGuard { @@ -464,6 +500,13 @@ fn go_work_vendored_members_build_patched_and_attest_without_manifest() { "vendored, root go.mod replace", ); + reinstall_workspace( + root, + &Machine::new(tmp.path(), "vendored-reinstall"), + // Older Go work sync loads each member's original module metadata. + &day2(&ws.proxy), + ); + let base = Tail { tmp: tmp.path(), ws: &ws, @@ -617,6 +660,12 @@ fn go_work_hosted_members_build_patched_and_attest_without_manifest() { "hosted, root go.mod replace", ); + reinstall_workspace( + root, + &Machine::new(tmp.path(), "hosted-reinstall"), + &day2(&ws.proxy), + ); + let base = Tail { tmp: tmp.path(), ws: &ws, diff --git a/crates/socket-patch-cli/tests/golang_production/mod.rs b/crates/socket-patch-cli/tests/golang_production/mod.rs index a3bfd699d..b00fce047 100644 --- a/crates/socket-patch-cli/tests/golang_production/mod.rs +++ b/crates/socket-patch-cli/tests/golang_production/mod.rs @@ -124,11 +124,18 @@ pub fn delivery(mode: &str) { let args = if entrypoint == "get" { vec!["get", UUID, "--mode", mode] } else { - vec!["scan", "--mode", mode] + vec!["scan", "--package", PURL, "--mode", mode] }; let result = cli(&consumer, &cache, &args); + let events = result["events"].as_array().unwrap(); assert!( - result["events"].as_array().unwrap().iter().any(|event| { + events + .iter() + .any(|event| event["purl"] == PURL && event["uuid"] == UUID), + "{mode}/{entrypoint} did not select {UUID}: {result:#}" + ); + assert!( + events.iter().any(|event| { event["purl"] == PURL && (event["action"] == "applied" || event["action"] == "redirected") }), @@ -142,6 +149,35 @@ pub fn delivery(mode: &str) { let run = go(&fresh, &tmp.path().join("empty-cache"), &["run", "."]); success(&run); assert_eq!(String::from_utf8_lossy(&run.stdout).trim(), "REJECTED"); + // Ordinary dependency maintenance must preserve the replacement even + // after all installed modules and compiled objects are removed. + for args in [ + vec!["clean", "-modcache", "-cache"], + vec!["mod", "download"], + vec!["mod", "tidy"], + vec!["mod", "verify"], + vec!["test", "-count=1", "./..."], + ] { + success(&go(&consumer, &cache, &args)); + } + success(&go( + &consumer, + &cache, + &["get", &format!("{MODULE}@v5.7.0")], + )); + cli(&consumer, &cache, &args); + let reinstalled = go(&consumer, &cache, &["run", "."]); + success(&reinstalled); + assert_eq!( + String::from_utf8_lossy(&reinstalled.stdout).trim(), + "REJECTED" + ); + for _ in 0..2 { + success(&go(&consumer, &cache, &["mod", "vendor"])); + let native = go(&consumer, &cache, &["run", "-mod=vendor", "."]); + success(&native); + assert_eq!(String::from_utf8_lossy(&native.stdout).trim(), "REJECTED"); + } if mode != "hosted" { success(&go(&consumer, &cache, &["mod", "tidy"])); cli(&consumer, &cache, &["rollback", "--offline"]); diff --git a/crates/socket-patch-core/src/patch/redirect/golang_local.rs b/crates/socket-patch-core/src/patch/redirect/golang_local.rs index a05189c79..5396173f6 100644 --- a/crates/socket-patch-core/src/patch/redirect/golang_local.rs +++ b/crates/socket-patch-core/src/patch/redirect/golang_local.rs @@ -158,6 +158,57 @@ pub fn are_safe_redirect_coords(module: &str, version: &str) -> bool { path_safety::is_safe_multi_segment(module) && path_safety::is_safe_single_segment(version) } +/// Reuse an installed project-local redirect after Go's module cache was +/// cleared. Never treat this copy as a pristine rebuild source: drift needs +/// the original module, and copying the tree onto itself would delete it. +pub async fn reuse_go_redirect( + purl: &str, + project_root: &Path, + files: &HashMap, +) -> Option { + let (module, version) = parse_golang_purl(purl)?; + if !are_safe_redirect_coords(&module, &version) || files.is_empty() { + return None; + } + let copy = copy_dir_for(project_root, GO_PATCHES_DIR, &module, &version); + if !copy.is_dir() { + return None; + } + let refusal = crate::vendor::go_consumer_sync::replace_refusal( + project_root, + &module, + &version, + GO_PATCHES_DIR, + ) + .await + .or(selection_refusal(project_root, &module, &version).await); + if let Some(refusal) = refusal { + return Some(synthesized_result( + purl, + ©, + Vec::new(), + false, + Some(refusal), + )); + } + if copy.join("go.mod").is_file() + && redirect_in_sync( + ©, + files, + project_root, + &module, + &version, + GO_PATCHES_DIR, + ) + .await + { + return Some(already_patched_result(purl, ©, files)); + } + Some(synthesized_result(purl, ©, Vec::new(), false, Some(format!( + "the local Go patch copy or replace for {module} {version} is out of sync and the upstream module is not installed; restore the committed patch files or download the upstream module before reapplying" + )))) +} + /// Materialise a project-local patched copy and wire up the `replace` redirect. /// /// * `pristine_src` — the pristine module-cache source dir (the crawler's diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 824b25970..b5fe8fbb0 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -1391,6 +1391,13 @@ commit it, and review it like any other vendored code. The wiring survives `go mod tidy`, and `apply --check` gives CI a read-only audit that the committed redirects still match the manifest. +Reinstalling the same module version (`go clean -modcache`, `go mod download`, +`go get module@version`, and `go mod tidy`) preserves the local replacement. +Agent reapplication can reuse the committed copy after cache removal, but only +after verifying its patch hashes and replacement wiring. Changing the required +module version needs a matching patch for that version; the old version-pinned +replacement does not patch a different release. + Local modes keep the replaced module's original checksum lines in `.upstream-go.sum` beside the patched module tree. Commit that file with `.socket/`: rollback restores those lines offline even after `go mod tidy` removes them from