From a2a686ae93d6d988f9e4ab03518816e4932093ab Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 23 Oct 2026 15:14:14 -0400 Subject: [PATCH 1/3] 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/3] 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 From 6341c0c608e6de3218d9be146c70f1f1a1176fbd Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 28 Oct 2026 13:36:06 -0400 Subject: [PATCH 3/3] Fix Go delivery review findings Apply the 15 verified findings from /code-review max on #1698, including porting Go's path.Match into go_native_vendor.rs so core drops the glob dep. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 - .../src/commands/agent_download.rs | 12 +- crates/socket-patch-cli/src/commands/apply.rs | 168 +++- crates/socket-patch-cli/src/commands/get.rs | 29 + .../socket-patch-cli/src/commands/vendor.rs | 8 +- crates/socket-patch-cli/src/commands/vex.rs | 61 ++ crates/socket-patch-core/Cargo.toml | 1 - .../src/patch/redirect/golang_local.rs | 236 +++++- .../src/vendor/go_consumer_sync.rs | 128 ++- .../src/vendor/go_mod_edit.rs | 81 +- .../src/vendor/go_native_vendor.rs | 786 +++++++++++++++--- .../src/vendor/go_sum_backup.rs | 85 +- crates/socket-patch-core/src/vendor/golang.rs | 12 +- crates/socket-patch-core/src/vex/verify.rs | 16 - 14 files changed, 1387 insertions(+), 237 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 03fbdf3c9..35faf6ba6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1951,7 +1951,6 @@ dependencies = [ "flate2", "fs2", "futures-util", - "glob", "hex", "ignore", "libc", diff --git a/crates/socket-patch-cli/src/commands/agent_download.rs b/crates/socket-patch-cli/src/commands/agent_download.rs index 44bfbbe8a..ca6484a27 100644 --- a/crates/socket-patch-cli/src/commands/agent_download.rs +++ b/crates/socket-patch-cli/src/commands/agent_download.rs @@ -1557,7 +1557,17 @@ pub(crate) fn record_apply_outcome( let Some(report) = report else { return; }; - env.warnings.extend(report.warnings.iter().cloned()); + // The caller may already carry the same run-level advisory (scan reports + // the gem `.bundle/config` / stale-plugin warnings from its own crawl). + let reported = env.warnings.len(); + for warning in &report.warnings { + if !env.warnings[..reported] + .iter() + .any(|w| w.code == warning.code && w.detail == warning.detail) + { + env.warnings.push(warning.clone()); + } + } let mut failed_any = false; for (purl, uuid) in recorded { if let Some(failure) = report diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 6d5c88692..ccf5f2e0e 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -254,21 +254,26 @@ 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. +/// Before the apply loop: prune local-go redirects whose patches were +/// dropped from the manifest. No-op unless local go is in scope. An orphan +/// whose directive cannot be dropped (go.sum conflicts with its saved +/// upstream checksum, say) is kept, and comes back as a failed result: the +/// run fails, but every other patch still applies. async fn reconcile_local_go( - common: &GlobalArgs, + args: &ApplyArgs, target_manifest_purls: &HashSet, -) -> Result<(), String> { +) -> Vec { + let common = &args.common; if !eco_in_local_scope(common, Ecosystem::Golang) { - return Ok(()); + return Vec::new(); } 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 reconciled = reconcile_go_redirects(&common.cwd, &desired, common.dry_run).await; + let removed = reconciled.removed; if !removed.is_empty() && !common.silent && !common.json { let verb = if common.dry_run { "Would remove" @@ -287,7 +292,36 @@ async fn reconcile_local_go( println!(" {purl}"); } } - Ok(()) + reconciled + .failed + .into_iter() + .map(|(purl, error)| { + // Errors print even under --silent. + if args.prints_errors() { + eprintln!("{}", format_patch_failure(&purl, &error)); + } + let package_path = parse_golang_purl(&purl) + .map(|(module, version)| { + socket_patch_core::patch::redirect::golang_local::copy_dir_for( + &common.cwd, + socket_patch_core::vendor::go_mod_edit::GO_PATCHES_DIR, + &module, + &version, + ) + }) + .unwrap_or_else(|| common.cwd.clone()); + ApplyResult { + package_key: purl, + package_path: package_path.display().to_string(), + success: false, + files_verified: Vec::new(), + files_patched: Vec::new(), + applied_via: HashMap::new(), + error: Some(error), + sidecar: None, + } + }) + .collect() } /// Read-only verification that the manifest's patches are in place, for CI @@ -298,7 +332,9 @@ async fn reconcile_local_go( /// not vendor-owned (`vendor --check` audits those): /// /// * local Go patches: the committed `.socket/go-patches/` copies and -/// `go.mod` `replace` directives ([`verify_go_redirect_state`]); +/// `go.mod` `replace` directives ([`verify_go_redirect_state`]); and, for +/// every in-scope Go patch (vendor-owned ones too), the native `vendor/` +/// tree a default build compiles and the consumer-sync state; /// * every other patch: each installed copy must hash to the record's /// `afterHash` — the same verifier `vex` attests with /// ([`applied_patches_with_copies`](socket_patch_core::vex::applied_patches_with_copies)), @@ -387,17 +423,26 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { drifts.push((id, "go_redirect_drift".to_string(), d.to_string())); } } + // The native vendor tree a default build compiles must carry + // every in-scope Go patch, vendor-owned ones included. Its + // modules.txt drift is the audit's below: one report per cause. + let no_texts = HashMap::new(); 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( + socket_patch_core::vendor::go_consumer_sync::verify_native_vendor_tree( &args.common.cwd, purl, record, + &no_texts, ) .await { - drifts.push((purl.clone(), "go_vendor_content_mismatch".into(), detail)); + drifts.push(( + purl.clone(), + socket_patch_core::vendor::go_consumer_sync::NATIVE_VENDOR_CODE.into(), + detail, + )); } } } @@ -499,10 +544,11 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { eprintln!(" {detail}"); } // A go consumer-sync drift names its own go command; apply - // cannot regenerate vendor/modules.txt or go.sum. + // cannot regenerate vendor/ or go.sum. if drifts.iter().any(|(_, code, _)| { code != socket_patch_core::vendor::go_consumer_sync::VENDOR_MODULES_TXT_CODE && code != socket_patch_core::vendor::go_consumer_sync::REQUIREMENTS_CODE + && code != socket_patch_core::vendor::go_consumer_sync::NATIVE_VENDOR_CODE }) { eprintln!("{}", check_remedy(&args.common, !protected.is_empty())); } @@ -1642,10 +1688,16 @@ pub(crate) async fn run_locked( .await; } - // The mismatch overwrites, for a nested caller's envelope (the - // JSON events above are the standalone apply's copy). + // The mismatch overwrites and the run's advisories, for a nested + // caller's envelope (the JSON events above are the standalone + // apply's copy). A stage failure reaches it as `run_error`. let mut warnings = mismatch_overwrite_warnings(&results, args.common.dry_run); - warnings.extend(run_warnings.iter().cloned()); + warnings.extend( + run_warnings + .iter() + .filter(|w| !is_stage_failure_code(&w.code)) + .cloned(), + ); let (applied, skipped) = collect_apply_outcomes( &results, &unmatched, @@ -2163,7 +2215,7 @@ async fn apply_patches_inner( .chain(user_patched) .cloned() .collect(); - reconcile_local_go(&args.common, &recorded).await?; + let reconcile_failures = reconcile_local_go(args, &recorded).await; if partitioned.is_empty() { // Nothing in scope: the manifest lists no patches (or every patch was @@ -2172,13 +2224,14 @@ async fn apply_patches_inner( // `postinstall` hook runs `apply` on every install, including fresh // projects whose manifest has no matching patches yet. Decided // BEFORE the ledger read, gem discovery and the crawl — none of which - // can add work to an empty scope — but AFTER the staging above. - if !args.common.silent && !args.common.json { + // can add work to an empty scope — but AFTER the staging above. A + // stale Go redirect the reconcile had to keep still fails the run. + if reconcile_failures.is_empty() && !args.common.silent && !args.common.json { println!("No patches to apply."); } return Ok(ApplyOutcome { - success: true, - results: Vec::new(), + success: reconcile_failures.is_empty(), + results: reconcile_failures, unmatched: Vec::new(), lockfile_only: HashSet::new(), run_warnings: Vec::new(), @@ -2206,6 +2259,7 @@ async fn apply_patches_inner( let is_vendored = |p: &str| purl_keys_cover(&vendored_purls, p); let (mut results, mut matched_manifest_purls, vendored_bases) = synthesize_vendor_owned_results(&target_manifest_purls, &vendored_purls); + results.extend(reconcile_failures); let crawler_options = args.common.crawler_options(); @@ -2304,15 +2358,8 @@ async fn apply_patches_inner( 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 }), - ); + run_warnings + .extend(go_sync_warnings(&args.common, &HashMap::new(), &manifest, &results).await); } // Vendored purls are already accounted for (synthesized Skipped/ // vendored results above); only the remainder is genuinely @@ -2815,15 +2862,8 @@ async fn apply_patches_inner( // `vendor/modules.txt` and its go.mod requirements still agree with the // `replace` directives (#343, #618): name the regeneration step. if !args.common.dry_run && eco_in_local_scope(&args.common, Ecosystem::Golang) { - 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 }), - ); + run_warnings + .extend(go_sync_warnings(&args.common, &go_pristine_mods, &manifest, &results).await); } // Note: `apply` deliberately does NOT garbage-collect unused blobs in @@ -2844,6 +2884,60 @@ async fn apply_patches_inner( }) } +/// The Go consumer-sync advisories after a run that wired or reused local Go +/// redirects (#343, #618), plus the native vendor tree's content: the tree a +/// default build compiles must carry each Go patch the run applied (a patch +/// superseded at the same version leaves modules.txt in sync while vendor/ +/// still holds the old bytes). A module whose modules.txt drift is already +/// named is not reported twice. +async fn go_sync_warnings( + common: &GlobalArgs, + pristine_go_mods: &HashMap, + manifest: &PatchManifest, + results: &[ApplyResult], +) -> Vec { + use socket_patch_core::vendor::go_consumer_sync::{ + audit, verify_native_vendor_tree, GoSyncIssue, NATIVE_VENDOR_CODE, + }; + let issues = audit(&common.cwd, pristine_go_mods).await; + let stale: HashSet<&str> = issues + .iter() + .filter(|issue| matches!(issue, GoSyncIssue::VendorModulesTxt { .. })) + .map(GoSyncIssue::module) + .collect(); + let mut warnings: Vec = issues + .iter() + .map(|issue| RunWarning { + code: issue.code().to_string(), + detail: issue.to_string(), + }) + .collect(); + let no_texts = HashMap::new(); + let mut checked: HashSet<&str> = HashSet::new(); + for result in results + .iter() + .filter(|r| r.success && r.package_path != VENDOR_OWNED_MARKER) + { + let purl = result.package_key.as_str(); + let Some((module, _)) = parse_golang_purl(purl) else { + continue; + }; + if stale.contains(module.as_ref()) || !checked.insert(purl) { + continue; + } + let Some(record) = manifest.patches.get(purl) else { + continue; + }; + if let Err(detail) = verify_native_vendor_tree(&common.cwd, purl, record, &no_texts).await { + warnings.push(RunWarning { + code: NATIVE_VENDOR_CODE.to_string(), + detail: format!("{purl}: {detail}"), + }); + } + } + warnings +} + /// One Maven base purl for [`apply_maven_base`]. struct MavenBase<'a> { args: &'a ApplyArgs, diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 2d242dcf8..b6ba85f35 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -3540,6 +3540,35 @@ mod tests { assert_eq!(outcomes(&env), vec![o("failed", "", "", "apply_failed")]); } + #[test] + fn record_apply_outcome_skips_warnings_the_caller_already_reported() { + // scan reports the gem config / stale-plugin advisories from its own + // crawl; the nested apply's copies must not repeat them. Identical + // entries within the report (one per patched copy) all stay. + let mut env = Envelope::new(JsonCommand::Scan); + env.warn("gem_bundle_config_path_ignored", "outside"); + let report = ApplyRunReport { + applied: vec!["pkg:npm/a@1.0.0".into()], + warnings: vec![ + RunWarning::new("gem_bundle_config_path_ignored", "outside"), + RunWarning::new("content_mismatch_overwritten", "w"), + RunWarning::new("content_mismatch_overwritten", "w"), + ], + ..ApplyRunReport::default() + }; + let recs = recorded(&[("pkg:npm/a@1.0.0", "ua")]); + record_apply_outcome(&mut env, &recs, Some(&report), |_| None); + let codes: Vec<&str> = env.warnings.iter().map(|w| w.code.as_str()).collect(); + assert_eq!( + codes, + vec![ + "gem_bundle_config_path_ignored", + "content_mismatch_overwritten", + "content_mismatch_overwritten" + ] + ); + } + // --- write_blob_entry ------------------------------------------------ // Blob hashes come straight from the API response and are used as // filesystem path components (`blobs_dir.join(hash)`). A hostile or diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index b61967a43..76b0d3d7a 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -1257,8 +1257,12 @@ async fn run_check(args: &VendorArgs) -> i32 { // 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, + // Its modules.txt drift is the audit's just below. + failure = socket_patch_core::vendor::go_consumer_sync::verify_native_vendor_tree( + root, + key, + record, + &HashMap::new(), ) .await .err(); diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index 518d8425b..20310866a 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -1028,6 +1028,67 @@ async fn generate_vex( } } + // A natively vendored build compiles vendor/, not the replacement the + // checks above verified: attest a Go patch only when the vendor tree go + // selects carries it too. Local only, like the checks above, and after + // them, so a more specific reason wins; `--no-verify` skips it with the + // rest of the hashing; and a purl an in-run hosted redirect just + // confirmed stays exempt (its bytes arrive with the next vendoring, which + // the run's own consumer-sync warning names). + if !common.is_global() && !params.no_verify { + use socket_patch_core::utils::purl::strip_purl_qualifiers; + use socket_patch_core::vendor::go_consumer_sync::{ + patched_go_mod_hash, verify_native_vendor, NATIVE_VENDOR_CODE, + }; + let exempt: HashSet<&str> = params + .assume_applied + .iter() + .map(|s| strip_purl_qualifiers(s)) + .collect(); + let mut kept = Vec::new(); + for purl in std::mem::take(&mut outcome.applied) { + let Some(record) = manifest + .patches + .get(&purl) + .filter(|_| !exempt.contains(strip_purl_qualifiers(&purl))) + else { + kept.push(purl); + continue; + }; + let mut verdict = + verify_native_vendor(&common.cwd, &purl, record, &HashMap::new()).await; + // A vendored or hosted checkout may keep no local copy of the + // patched go.mod its dependency check needs: fetch it. + if verdict.is_err() { + if let Some(hash) = patched_go_mod_hash(record) { + if let Some(text) = patched_blob(common, params, hash) + .await + .and_then(|bytes| String::from_utf8(bytes).ok()) + { + let known = HashMap::from([(hash.to_string(), text)]); + verdict = verify_native_vendor(&common.cwd, &purl, record, &known).await; + } + } + } + match verdict { + Ok(()) => kept.push(purl), + Err(detail) => { + note_warning( + warnings, + common, + NATIVE_VENDOR_CODE, + format!("{purl}: {detail}"), + ); + outcome.failed.push(FailedPatch { + purl, + reason: NATIVE_VENDOR_CODE.to_string(), + }); + } + } + } + outcome.applied = kept; + } + // The plan's gate omissions (record_unavailable / record_mismatch / // vendor_unwired / redirect_unwired / wiring_conflict) join the omission // channel so they surface as per-purl `skipped` events like any diff --git a/crates/socket-patch-core/Cargo.toml b/crates/socket-patch-core/Cargo.toml index cd7d02b8c..5f061cfc6 100644 --- a/crates/socket-patch-core/Cargo.toml +++ b/crates/socket-patch-core/Cargo.toml @@ -37,7 +37,6 @@ 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/patch/redirect/golang_local.rs b/crates/socket-patch-core/src/patch/redirect/golang_local.rs index 5396173f6..4dc20fc60 100644 --- a/crates/socket-patch-core/src/patch/redirect/golang_local.rs +++ b/crates/socket-patch-core/src/patch/redirect/golang_local.rs @@ -174,6 +174,14 @@ pub async fn reuse_go_redirect( if !copy.is_dir() { return None; } + // A hosted or vendored replace owns the module (see + // `verify_go_redirect_state`): this copy is not what the build uses, so + // there is nothing to reuse and the module stays "not installed". + if read_replace_entries(project_root).await.iter().any(|e| { + e.module == module.as_ref() && e.socket_owned() && e.owner != Some(ReplaceOwner::GoPatches) + }) { + return None; + } let refusal = crate::vendor::go_consumer_sync::replace_refusal( project_root, &module, @@ -427,42 +435,68 @@ pub async fn remove_go_redirect( Ok(()) } +/// What [`reconcile_go_redirects`] did: the PURLs whose redirects it +/// removed, and `(purl, why)` for each orphan it had to keep because its +/// directive could not be dropped (e.g. go.sum conflicts with the saved +/// upstream checksum). A kept orphan keeps its copy too, so its directive +/// never dangles; the rest of the reconcile, and the run, go on. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Reconciled { + pub removed: Vec, + pub failed: Vec<(String, String)>, +} + /// Prune **go-patches-owned** `replace` directives + copy dirs no longer in -/// `desired` (patches dropped from the manifest). Returns the removed PURLs. -/// Vendor-owned directives and `.socket/vendor/` copies are never touched — -/// they are reconciled by the vendor command against its own state. +/// `desired` (patches dropped from the manifest). Vendor-owned directives and +/// `.socket/vendor/` copies are never touched — they are reconciled by the +/// vendor command against its own state. pub async fn reconcile_go_redirects( project_root: &Path, desired: &HashSet, dry_run: bool, -) -> Result, String> { +) -> Reconciled { let desired_modules: HashSet = desired .iter() .filter_map(|p| parse_golang_purl(p).map(|(m, _)| m.into_owned())) .collect(); - let mut removed: Vec = Vec::new(); + let mut out = Reconciled::default(); + // Modules whose directive survived a refused drop: their copies stay. + let mut kept: HashSet = HashSet::new(); // (a) Orphan go-patches-owned `replace` directives (module no longer patched). for entry in read_replace_entries(project_root).await { if entry.owner == Some(ReplaceOwner::GoPatches) && !desired_modules.contains(entry.module.as_str()) + && !kept.contains(entry.module.as_str()) { - go_mod_edit::drop_replace_entry( + let purl = entry + .version + .as_deref() + .map(|v| build_golang_purl(&entry.module, v)); + if let Err(e) = go_mod_edit::drop_replace_entry( project_root, &entry.module, ReplaceOwner::GoPatches, dry_run, ) - .await?; + .await + { + let purl = purl.unwrap_or_else(|| entry.module.clone()); + out.failed.push(( + purl.clone(), + format!("kept the stale Go patch redirect for {purl}: {e}"), + )); + kept.insert(entry.module.clone()); + continue; + } 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) { - removed.push(purl); + if let Some(purl) = purl { + if !out.removed.contains(&purl) { + out.removed.push(purl); } } } @@ -492,19 +526,32 @@ pub async fn reconcile_go_redirects( // desired version's copy is never touched. if let Some((module, version)) = parse_golang_purl(&purl) { let (module, version) = (module.as_ref(), version.as_ref()); + // (a) kept this module's directive: keep its copies too. + if kept.contains(module) { + continue; + } let target = replace_target_path(GO_PATCHES_DIR, module, version); if entries.iter().any(|e| { e.owner == Some(ReplaceOwner::GoPatches) && e.module == module && e.path.as_deref() == Some(target.as_str()) }) { - go_mod_edit::drop_replace_entry( + if let Err(e) = go_mod_edit::drop_replace_entry( project_root, module, ReplaceOwner::GoPatches, dry_run, ) - .await?; + .await + { + // The directive still targets this copy: keep both. + out.failed.push(( + purl.clone(), + format!("kept the stale Go patch redirect for {purl}: {e}"), + )); + kept.insert(module.to_string()); + continue; + } if !dry_run { let matching: Vec<_> = entries .iter() @@ -520,13 +567,13 @@ pub async fn reconcile_go_redirects( if !dry_run { let _ = remove_tree_and_prune(&dir, &project_root.join(SOCKET_DIR)).await; } - if !removed.contains(&purl) { - removed.push(purl); + if !out.removed.contains(&purl) { + out.removed.push(purl); } } } - Ok(removed) + out } /// Registry-independent verification for `apply --check` (CI / GitHub-App @@ -711,7 +758,19 @@ async fn teardown_failed_redirect( let _ = remove_tree_and_prune(copy_dir, &project_root.join(SOCKET_DIR)).await; if let Some(owner) = go_mod_edit::detect_owner(&replace_target_path(base_rel, module, version)) { - let _ = go_mod_edit::drop_replace_entry(project_root, module, owner, false).await; + // The copy is gone: the directive must go even when restoring the + // upstream go.sum lines is refused, or every `go build` fails on the + // missing replacement directory. + let entries: Vec<_> = read_replace_entries(project_root) + .await + .into_iter() + .filter(|e| e.module == module && e.owner == Some(owner)) + .collect(); + if go_mod_edit::drop_replace_entry_for_cleanup(project_root, module, owner).await + == Ok(true) + { + crate::vendor::go_sum_backup::forget(project_root, &entries).await; + } } } @@ -1316,6 +1375,129 @@ mod tests { ); } + /// The failure cleanup's directive drop must survive a refused go.sum + /// restore: the copy is already gone, so a kept directive would dangle. + #[tokio::test] + async fn test_failed_recopy_drops_directive_despite_go_sum_conflict() { + let (dir, blobs, pristine, files, _after) = fixture().await; + let root = dir.path(); + let sums = "github.com/foo/bar v1.4.2 h1:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=\n\ + github.com/foo/bar v1.4.2/go.mod h1:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb=\n"; + tokio::fs::write(root.join("go.sum"), sums).await.unwrap(); + let sources = PatchSources::blobs_only(&blobs); + let apply = || { + apply_go_redirect( + PURL, + MODULE, + VERSION, + &pristine, + root, + GO_PATCHES_DIR, + &files, + &sources, + false, + MismatchPolicy::Warn, + ) + }; + let result = apply().await; + assert!(result.success, "setup apply failed: {:?}", result.error); + + let conflict = sums.replace("h1:aaaa", "h1:zzzz"); + tokio::fs::write(root.join("go.sum"), &conflict) + .await + .unwrap(); + let copy_dir = root.join(".socket/go-patches/github.com/foo/bar@v1.4.2"); + tokio::fs::write(copy_dir.join("bar.go"), b"corrupted") + .await + .unwrap(); + tokio::fs::remove_dir_all(&pristine).await.unwrap(); + assert!(!apply().await.success); + + assert!(!copy_dir.exists()); + let entries = read_replace_entries(root).await; + assert!( + !entries + .iter() + .any(|e| e.module == MODULE && e.socket_owned()), + "the directive must not dangle at the deleted copy: {entries:?}" + ); + assert_eq!( + tokio::fs::read_to_string(root.join("go.sum")) + .await + .unwrap(), + conflict, + "a refused restore leaves go.sum as it was" + ); + } + + /// One orphan whose go.sum restore is refused is kept, with its copy; + /// the rest of the reconcile still goes ahead. + #[tokio::test] + async fn test_reconcile_keeps_a_conflicting_orphan_and_removes_the_rest() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let (a, b, v) = ("example.com/a", "example.com/b", "v1.0.0"); + tokio::fs::write( + root.join("go.mod"), + format!("module consumer\n\ngo 1.21\n\nrequire (\n\t{a} {v}\n\t{b} {v}\n)\n"), + ) + .await + .unwrap(); + let sums = format!( + "{a} {v} h1:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=\n\ + {a} {v}/go.mod h1:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb=\n\ + {b} {v} h1:ccccccccccccccccccccccccccccccccccccccccccc=\n\ + {b} {v}/go.mod h1:ddddddddddddddddddddddddddddddddddddddddddd=\n" + ); + tokio::fs::write(root.join("go.sum"), &sums).await.unwrap(); + for module in [a, b] { + let copy = copy_dir_for(root, GO_PATCHES_DIR, module, v); + tokio::fs::create_dir_all(©).await.unwrap(); + tokio::fs::write(copy.join("go.mod"), format!("module {module}\n")) + .await + .unwrap(); + go_mod_edit::ensure_replace_entry(root, module, v, GO_PATCHES_DIR, false) + .await + .unwrap(); + } + tokio::fs::write(root.join("go.sum"), sums.replace("h1:cccc", "h1:zzzz")) + .await + .unwrap(); + + let out = reconcile_go_redirects(root, &HashSet::new(), false).await; + assert_eq!(out.removed, vec![build_golang_purl(a, v)]); + assert_eq!(out.failed.len(), 1, "{out:?}"); + assert_eq!(out.failed[0].0, build_golang_purl(b, v)); + assert!(out.failed[0].1.contains("conflicts"), "{out:?}"); + let entries = read_replace_entries(root).await; + assert!(entries.iter().any(|e| e.module == b)); + assert!(!entries.iter().any(|e| e.module == a)); + assert!(copy_dir_for(root, GO_PATCHES_DIR, b, v).is_dir()); + assert!(!copy_dir_for(root, GO_PATCHES_DIR, a, v).exists()); + } + + /// A hosted replace owns the module: the agent copy left beside it is not + /// what the build uses, so a cache-less apply has nothing to reuse. + #[tokio::test] + async fn test_reuse_defers_to_a_hosted_replace() { + let (dir, _blobs, _pristine, files, _after) = fixture().await; + let root = dir.path(); + let copy = root.join(".socket/go-patches/github.com/foo/bar@v1.4.2"); + tokio::fs::create_dir_all(©).await.unwrap(); + tokio::fs::write(copy.join("go.mod"), "module github.com/foo/bar\n") + .await + .unwrap(); + tokio::fs::write( + root.join("go.mod"), + "module example.com/app\n\ngo 1.21\n\nrequire github.com/foo/bar v1.4.2\n\n\ + replace github.com/foo/bar v1.4.2 => \ + patch.socket.dev/gopatch/55555555-5555-4555-8555-555555555555 v1.4.2-socketpatch.1\n", + ) + .await + .unwrap(); + assert!(reuse_go_redirect(PURL, root, &files).await.is_none()); + } + #[tokio::test] async fn test_synthesizes_go_mod_for_pre_modules_package() { let (dir, blobs, pristine, files, _after) = fixture().await; @@ -1448,7 +1630,7 @@ mod tests { .await; let desired: HashSet = HashSet::new(); - let removed = reconcile_go_redirects(root, &desired, false).await.unwrap(); + let removed = reconcile_go_redirects(root, &desired, false).await.removed; assert!(removed.contains(&PURL.to_string())); assert!(!root .join(".socket/go-patches/github.com/foo/bar@v1.4.2") @@ -1526,7 +1708,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.unwrap(); + let removed = reconcile_go_redirects(root, &desired, false).await.removed; assert!(removed.is_empty()); let entries = read_replace_entries(root).await; assert!(entries @@ -1569,7 +1751,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.unwrap(); + let removed = reconcile_go_redirects(root, &desired, false).await.removed; assert!(removed.contains(&PURL.to_string())); assert!( !root @@ -1624,7 +1806,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.unwrap(); + let removed = reconcile_go_redirects(root, &desired, false).await.removed; assert!(removed.contains(&PURL.to_string())); assert!( !root @@ -2123,7 +2305,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.unwrap(); + let removed = reconcile_go_redirects(root, &desired, false).await.removed; assert!( removed.is_empty(), "a desired (qualified) redirect must not be pruned: {removed:?}" @@ -2184,7 +2366,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.unwrap(); + let removed = reconcile_go_redirects(root, &desired, false).await.removed; assert!( removed.is_empty(), "a desired encoded version must not be pruned: {removed:?}" @@ -2522,7 +2704,7 @@ mod tests { let removed = reconcile_go_redirects(root, &HashSet::new(), true) .await - .unwrap(); + .removed; assert!( removed.contains(&PURL.to_string()), "dry-run must still report the orphan: {removed:?}" @@ -2546,7 +2728,7 @@ mod tests { // Parity: the real run removes exactly what the dry run reported. let removed_wet = reconcile_go_redirects(root, &HashSet::new(), false) .await - .unwrap(); + .removed; assert_eq!( removed_wet, removed, "dry-run report must match the real run" @@ -2579,7 +2761,7 @@ mod tests { let removed = reconcile_go_redirects(root, &HashSet::new(), false) .await - .unwrap(); + .removed; assert!( read_replace_entries(root).await.is_empty(), "the version-less orphan directive must be dropped" @@ -2625,7 +2807,7 @@ mod tests { let removed = reconcile_go_redirects(root, &HashSet::new(), false) .await - .unwrap(); + .removed; 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 b3d6e0680..59e47156e 100644 --- a/crates/socket-patch-core/src/vendor/go_consumer_sync.rs +++ b/crates/socket-patch-core/src/vendor/go_consumer_sync.rs @@ -281,14 +281,17 @@ 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. +// not unescape its contents. Both -flag and --flag are accepted. Like Go's +// explicitStringFlag, an empty `-mod=` leaves the mode to be chosen +// automatically unless an earlier non-empty `-mod` already made it explicit. fn go_mod_flag(mut flags: &str) -> Option<&str> { let space = |c: char| matches!(c, ' ' | '\t' | '\n' | '\r'); let mut mode = None; + let mut explicit = false; loop { flags = flags.trim_start_matches(space); if flags.is_empty() { - return mode; + return mode.filter(|_| explicit); } let (word, rest) = if let Some(quote) = flags.chars().next().filter(|c| *c == '\'' || *c == '"') { @@ -302,6 +305,7 @@ fn go_mod_flag(mut flags: &str) -> Option<&str> { .strip_prefix("-mod=") .or_else(|| word.strip_prefix("--mod=")) { + explicit |= !value.is_empty(); mode = Some(value); } flags = rest; @@ -337,26 +341,71 @@ pub async fn native_vendor_dir(project_root: &Path) -> Option { if workspace_tree != work.is_some() { return None; } - let config = work.clone().unwrap_or_else(|| project_root.join("go.mod")); + let config = work.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) + let go = go_mod_edit::go_directive_version(&go_mod_edit::normalize_for_read(&text))?; + vendor_by_default(go).then_some(dir) +} + +/// Whether a `go` directive makes Go select an existing vendor tree without +/// `-mod=vendor`: go 1.14 or later by gover.Compare, so a prerelease such as +/// `1.22rc1` counts while `1.14rc1` sorts below 1.14. +fn vendor_by_default(go: go_mod_edit::GoDirective) -> bool { + go.major > 1 || go.major == 1 && (go.minor > 14 || go.minor == 14 && go.release) +} + +/// Drift / omission code for a patch whose bytes the native vendor tree Go +/// builds does not carry (or cannot be shown to carry). +pub const NATIVE_VENDOR_CODE: &str = "go_vendor_content_mismatch"; + +/// The `afterHash` of a Go patch record's module-root `go.mod`, when the +/// patch changes it: the dependency requirements the native vendor check +/// needs the patched text for (see [`verify_native_vendor`]'s `known`). +pub fn patched_go_mod_hash(record: &crate::manifest::schema::PatchRecord) -> Option<&str> { + record.files.iter().find_map(|(name, info)| { + (crate::patch::apply::normalize_file_path(name) == "go.mod") + .then_some(info.after_hash.as_str()) + .filter(|hash| !hash.is_empty()) + }) } /// 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. +/// +/// Also fails the patch when `vendor/modules.txt` does not record its +/// module's socket `replace`, for callers (`vex`) that do not run +/// [`audit`]; the audits that report that drift themselves use +/// [`verify_native_vendor_tree`]. `known` maps an `afterHash` to patched +/// text the caller already holds (e.g. fetched from the patch API), for a +/// patched `go.mod` no local blob or copy provides. pub async fn verify_native_vendor( project_root: &Path, purl: &str, record: &crate::manifest::schema::PatchRecord, + known: &HashMap, +) -> Result<(), String> { + verify_native_vendor_with(project_root, purl, record, known, true).await +} + +/// [`verify_native_vendor`] without the modules.txt replacement check, for +/// callers that report [`GoSyncIssue::VendorModulesTxt`] through [`audit`]: +/// one root cause, one report. +pub async fn verify_native_vendor_tree( + project_root: &Path, + purl: &str, + record: &crate::manifest::schema::PatchRecord, + known: &HashMap, +) -> Result<(), String> { + verify_native_vendor_with(project_root, purl, record, known, false).await +} + +async fn verify_native_vendor_with( + project_root: &Path, + purl: &str, + record: &crate::manifest::schema::PatchRecord, + known: &HashMap, + check_modules_txt: bool, ) -> Result<(), String> { let Some((module, version)) = crate::utils::purl::parse_golang_purl(purl) else { return Ok(()); @@ -368,17 +417,29 @@ pub async fn verify_native_vendor( 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()); + if check_modules_txt { + 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/")) + super::go_native_vendor::verify( + &super::go_native_vendor::Target { + root: project_root, + dir: &dir, + module: module.as_ref(), + version: version.as_ref(), + replaces: &replaces, + known, + command, + }, + record, + ) + .await } /// Whether the go command builds `project_root` in workspace mode (a @@ -808,6 +869,31 @@ mod tests { ); assert_eq!(go_mod_flag("'-ldflags=-X main.flag=-mod=vendor'"), None); assert_eq!(go_mod_flag("'unterminated"), None); + // An empty value is not explicit: Go still selects vendor/ itself... + assert_eq!(go_mod_flag("-mod="), None); + assert_eq!(go_mod_flag("--mod= -tags=linux"), None); + // ...unless an earlier non-empty -mod already made the mode explicit. + assert_eq!(go_mod_flag("-mod=vendor -mod="), Some("")); + } + + #[test] + fn vendor_is_selected_by_default_from_go_1_14_including_prereleases() { + let go = |text: &str| go_mod_edit::go_directive_version(text).unwrap(); + for selected in [ + "go 1.14", + "go 1.14.1", + "go 1.21", + "go 1.21.0", + "go 1.22rc1", + "go 1.21beta1", + "go 2", + ] { + assert!(vendor_by_default(go(selected)), "{selected}"); + } + for ignored in ["go 1.13", "go 1.14rc1", "go 1.14beta2", "go 1"] { + assert!(!vendor_by_default(go(ignored)), "{ignored}"); + } + assert!(go_mod_edit::go_directive_version("module m\n").is_none()); } const COPY: &str = ".socket/go-patches/example.com/upstream@v1.0.0"; 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 6ff5238e9..7f3d4ad47 100644 --- a/crates/socket-patch-core/src/vendor/go_mod_edit.rs +++ b/crates/socket-patch-core/src/vendor/go_mod_edit.rs @@ -217,14 +217,40 @@ pub async fn drop_replace_entry( .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?; + if !entries.is_empty() { + // A dry run makes the same checks (a conflicting or invalid saved + // checksum refuses) without writing go.sum, so it predicts the run. + super::go_sum_backup::restore(project_root, &entries, dry_run).await?; } - let changed = edit_go_mod(project_root, dry_run, |c| { + edit_go_mod(project_root, dry_run, |c| { remove_replace_entry(c, module, owner) }) - .await?; - Ok(changed) + .await +} + +/// [`drop_replace_entry`] for failure cleanup that has removed, or is about +/// to remove, the directive's target: the directive must not survive a +/// refused go.sum restore (a conflicting or unreadable saved checksum), or +/// every `go build` fails on the missing replacement directory. The restore +/// is attempted first; when it refuses, the directive is still dropped and +/// go.sum (and the saved lines) are left as they were. Returns whether the +/// saved lines were restored. +pub(crate) async fn drop_replace_entry_for_cleanup( + project_root: &Path, + module: &str, + owner: ReplaceOwner, +) -> Result { + if drop_replace_entry(project_root, module, owner, false) + .await + .is_ok() + { + return Ok(true); + } + edit_go_mod(project_root, false, |c| { + remove_replace_entry(c, module, owner) + }) + .await + .map(|_| false) } // ── file resolution + read/write ────────────────────────────────────────────── @@ -426,19 +452,44 @@ pub fn parse_required_versions(content: &str) -> HashMap { /// provides a package to the build. Without the directive go assumes 1.16, /// whose `require` set is only a set of lower bounds. pub fn prunes_module_graph(content: &str) -> bool { - let mut pruned = false; + matches!(go_directive_version(content), Some(GoDirective { major, minor, .. }) if major > 1 || major == 1 && minor >= 17) +} + +/// The version a go.mod / go.work `go` directive declares. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct GoDirective { + pub major: u32, + pub minor: u32, + /// False for a prerelease of `minor` (`1.22rc1`, `1.21beta1`), which Go + /// orders below the release it precedes (`1.22rc1 < 1.22`). + pub release: bool, +} + +/// The last `go` directive in `content`, parsed as [`GoDirective`]: the +/// minor version's leading digits, so `1.22rc1` reads as a 1.22 prerelease. +/// `None` without a directive or with an unparseable version. +pub(crate) fn go_directive_version(content: &str) -> Option { + let mut found = None; let _ = for_each_directive_body(content, "go", |_, body| { - let mut parts = body.split_whitespace().next().unwrap_or("").split('.'); - let major = parts.next().and_then(|p| p.parse::().ok()); - let minor = parts.next().and_then(|p| { - let digits: String = p.chars().take_while(char::is_ascii_digit).collect(); - digits.parse::().ok() - }); - pruned = matches!((major, minor), (Some(m), _) if m > 1) - || matches!((major, minor), (Some(1), Some(n)) if n >= 17); + let version = body.split_whitespace().next().unwrap_or(""); + let mut parts = version.splitn(2, '.'); + found = parts + .next() + .and_then(|p| p.parse::().ok()) + .and_then(|major| { + let rest = parts.next().unwrap_or("0"); + let digits: String = rest.chars().take_while(char::is_ascii_digit).collect(); + let minor = digits.parse::().ok()?; + let suffix = &rest[digits.len()..]; + Some(GoDirective { + major, + minor, + release: suffix.is_empty() || suffix.starts_with('.'), + }) + }); Ok(()) }); - pruned + found } /// The module path the go.mod `module` directive declares, in its diff --git a/crates/socket-patch-core/src/vendor/go_native_vendor.rs b/crates/socket-patch-core/src/vendor/go_native_vendor.rs index b8031186a..e3e2dfe78 100644 --- a/crates/socket-patch-core/src/vendor/go_native_vendor.rs +++ b/crates/socket-patch-core/src/vendor/go_native_vendor.rs @@ -16,25 +16,41 @@ use crate::utils::fs::read_regular_to_string; use super::go_mod_edit::{self, ReplaceEntry}; +/// One patched module's native vendor check. +pub(super) struct Target<'a> { + /// The project root (the consumer go.mod's directory). + pub root: &'a Path, + /// The vendor directory Go selects (the one holding modules.txt). + pub dir: &'a Path, + pub module: &'a str, + pub version: &'a str, + /// The project go.mod's `replace` directives. + pub replaces: &'a [ReplaceEntry], + /// Patched text the caller already holds, by `afterHash`. + pub known: &'a HashMap, + /// `go mod vendor` or `go work vendor`. + pub command: &'static str, +} + /// 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 { +async fn expected_text(t: &Target<'_>, 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(text) = t + .known + .get(hash) + .filter(|text| compute_git_sha256_from_bytes(text.as_bytes()) == hash) + { + return Some(text.clone()); + } + let mut paths = vec![t.root.join(".socket/blobs").join(hash)]; + for entry in t.replaces.iter().filter(|e| { + e.module == t.module && (e.version.is_none() || e.version.as_deref() == Some(t.version)) }) { if let Some(path) = &entry.path { - paths.push(root.join(path).join(name)); + paths.push(t.root.join(path).join(name)); } else if let (Some(cache), Some(target), Some(ver)) = ( GoCrawler::get_gomodcache(), &entry.rhs_module, @@ -63,17 +79,13 @@ async fn expected_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")) +pub(super) async fn verify(t: &Target<'_>, record: &PatchRecord) -> Result<(), String> { + let (module, version) = (t.module, t.version); + // Advice for drift that regenerating the vendor tree clears. + let revendor = |detail: String| format!("{detail}; run `{}` and commit vendor/", t.command); + let text = read_regular_to_string(&t.dir.join("modules.txt")) .await - .map_err(|e| format!("cannot read {}/modules.txt: {e}", dir.display()))?; + .map_err(|e| revendor(format!("cannot read {}/modules.txt: {e}", t.dir.display())))?; let packages: HashSet<_> = text .lines() .filter(|s| !s.starts_with('#') && !s.is_empty()) @@ -95,20 +107,35 @@ pub(super) async fn verify( } } } - if versions.get(module).copied() != Some(version) { - return Err(format!( - "vendor/modules.txt does not select {module} {version}" - )); - } + // A module in the build list that provides no vendored package (one only + // a dependency's tests import, say) has no section, just its trailing + // `# M v => …` replacement line, and `go mod vendor` cannot add one. Go + // compiles none of its files, so only its patched requirements matter. + let compiled = match versions.get(module).copied() { + Some(selected) if selected == version => true, + Some(other) => { + return Err(format!( + "vendor/modules.txt selects {module} {other}, not the patched {version}, so \ + the vendored build does not use the patch" + )) + } + None => false, + }; + let mut embeds = EmbedCache::new(); 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) + let patched = expected_text(t, name, &info.after_hash) .await - .ok_or_else(|| format!("cannot verify the patched {module}/go.mod requirements"))?; + .ok_or_else(|| { + format!( + "cannot verify the patched {module}/go.mod requirements: no local \ + patch blob or patched copy holds the patched go.mod" + ) + })?; for (dep, required) in go_mod_edit::parse_required_versions(&go_mod_edit::normalize_for_read(&patched)) { @@ -118,26 +145,29 @@ pub(super) async fn verify( super::go_sum_edit::go_semver_cmp(selected, &required).is_lt() }) { return Err(format!( - "vendor/modules.txt selects {dep} below patched requirement {required}" + "vendor/modules.txt selects {dep} below patched requirement \ + {required}; run `go mod tidy`, then `{}`, and commit go.mod, go.sum \ + and vendor/", + t.command )); } } continue; } - if name == "go.sum" || name.ends_with("_test.go") { + if !compiled || name == "go.sum" || name.ends_with("_test.go") { continue; } - let path = dir.join(module).join(name); + let path = t.dir.join(module).join(name); match compute_file_git_sha256(&path).await { Ok(hash) if hash == info.after_hash => continue, Ok(_) => { - return Err(format!( + return Err(revendor(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())), + Err(e) => return Err(revendor(format!("cannot verify {}: {e}", path.display()))), } // Empty after hashes denote deletions, not empty files. if info.after_hash.is_empty() { @@ -153,31 +183,36 @@ pub(super) async fn verify( .replace('\\', "/") }; if !packages.contains(package.as_str()) { - if !is_embedded(dir, module, name, &packages).await? { + if !is_embedded(t.dir, module, name, &packages, &mut embeds) + .await + .map_err(revendor)? + { continue; } - return Err(format!( + return Err(revendor(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 let Some(text) = expected_text(t, name, &info.after_hash).await { if omitted_by_build_constraint(&text) { continue; } } } - return Err(format!( + return Err(revendor(format!( "{} is missing a patched runtime file", path.display() - )); + ))); } Ok(()) } +/// The `(all:, pattern)` embed patterns of each vendored package read so +/// far, so several missing files share one scan of a package's sources. +type EmbedCache = HashMap>; + // Embedded files can live below a selected package without being packages // themselves. Match both explicit files and recursively embedded directories. async fn is_embedded( @@ -185,76 +220,141 @@ async fn is_embedded( module: &str, name: &str, packages: &HashSet<&str>, + cache: &mut EmbedCache, ) -> 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; + // A pattern names paths relative to its package's directory, so only the + // vendored packages at or above the file can embed it. + let parts: Vec<&str> = name.split('/').collect(); + for depth in 0..parts.len() { + let package = match parts[..depth].join("/") { + subdir if subdir.is_empty() => module.to_string(), + subdir => format!("{module}/{subdir}"), }; - 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; + if !packages.contains(package.as_str()) { + continue; + } + if !cache.contains_key(&package) { + let found = package_embeds(dir, &package).await?; + cache.insert(package.clone(), found); + } + let relative = &parts[depth..]; + for (all, pattern) in &cache[&package] { + for end in 1..=relative.len() { + // A matched directory embeds its files recursively, except + // hidden (`.`/`_`) ones below it unless the pattern is `all:`. + if path_match(pattern, &relative[..end].join("/")) == Ok(true) + && (*all || relative[end..].iter().all(|p| !p.starts_with(['.', '_']))) + { + return Ok(true); + } } - let source = read_regular_to_string(&file.path()) - .await - .map_err(|e| e.to_string())?; - if omitted_by_build_constraint(&source) { + } + } + Ok(false) +} + +/// The embed patterns of one vendored package's Go files, as go/build reads +/// them: `_`/`.` files are not part of the package, an `ignore`-constrained +/// file is never vendored, and a malformed directive or an invalid pattern is +/// skipped (the compiler and `go mod vendor` report those themselves). +async fn package_embeds(dir: &Path, package: &str) -> Result, String> { + let mut out = Vec::new(); + let package_dir = dir.join(package); + let mut files = match tokio::fs::read_dir(&package_dir).await { + Ok(files) => files, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out), + Err(e) => return Err(format!("cannot read {}: {e}", package_dir.display())), + }; + 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") + || filename.starts_with(['.', '_']) + { + continue; + } + let source = read_regular_to_string(&file.path()) + .await + .map_err(|e| format!("cannot read {}: {e}", file.path().display()))?; + if omitted_by_build_constraint(&source) { + continue; + } + for args in embed_directives(&source) { + let Some(patterns) = embed_patterns(args) else { 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; + }; + for pattern in patterns { + let (all, glob) = match pattern.strip_prefix("all:") { + Some(glob) => (true, glob.to_string()), + None => (false, pattern), }; - 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); - } + if path_match(&glob, "").is_ok() { + out.push((all, glob)); + } + } + } + } + Ok(out) +} + +/// The argument text of each `//go:embed` directive in Go source, found the +/// way go/build finds them: a `//` comment token (never one inside a string, +/// rune, raw string or block comment) whose directive name is exactly +/// `embed`. +fn embed_directives(source: &str) -> Vec<&str> { + let bytes = source.as_bytes(); + let mut out = Vec::new(); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'/' if bytes.get(i + 1) == Some(&b'/') => { + let end = source[i..].find('\n').map_or(bytes.len(), |n| i + n); + let comment = source[i..end].trim_end_matches('\r'); + if let Some(rest) = comment.strip_prefix("//go:embed") { + // ast.ParseDirective: the name runs to the first space. + if rest.is_empty() || rest.starts_with(char::is_whitespace) { + out.push(rest); } } + i = end; + } + b'/' if bytes.get(i + 1) == Some(&b'*') => { + i = source[i + 2..] + .find("*/") + .map_or(bytes.len(), |n| i + 2 + n + 2); + } + b'`' => { + i = source[i + 1..] + .find('`') + .map_or(bytes.len(), |n| i + 1 + n + 1); } + quote @ (b'"' | b'\'') => { + i += 1; + while i < bytes.len() && bytes[i] != quote && bytes[i] != b'\n' { + i += if bytes[i] == b'\\' { 2 } else { 1 }; + } + i += 1; + } + _ => i += 1, } } - Ok(false) + out } +/// A `//go:embed` directive's arguments, as ast.Directive.ParseArgs splits +/// them: bare words, or Go string literals that must be followed by a space +/// or the end. `None` for a malformed line. fn embed_patterns(mut text: &str) -> Option> { + let quoted_end = |rest: &str| rest.is_empty() || rest.starts_with(char::is_whitespace); 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('`')?; + if !quoted_end(rest) { + return None; + } patterns.push(pattern.to_string()); text = rest; } else if let Some(rest) = text.strip_prefix('"') { @@ -299,6 +399,9 @@ fn embed_patterns(mut text: &str) -> Option> { } } text = &rest[end?..]; + if !quoted_end(text) { + return None; + } patterns.push(pattern); } else { let end = text.find(char::is_whitespace).unwrap_or(text.len()); @@ -309,6 +412,187 @@ fn embed_patterns(mut text: &str) -> Option> { Some(patterns) } +/// Go's `path.Match` (path/match.go), which `//go:embed` patterns use: `*` +/// and `?` never match `/`, `[^…]` negates a class, `\` escapes, and `**` +/// is just `*`. `Err(())` for a malformed pattern. +fn path_match(pattern: &str, name: &str) -> Result { + let mut pattern = pattern.as_bytes(); + let mut name = name.as_bytes(); + 'pattern: while !pattern.is_empty() { + let (star, chunk, rest) = scan_chunk(pattern); + pattern = rest; + if star && chunk.is_empty() { + // A trailing * matches the rest of the name unless it has a /. + return Ok(!name.contains(&b'/')); + } + // Look for a match at the current position. The last chunk must + // exhaust the name, or a later star might still match. + match match_chunk(chunk, name)? { + Some(t) if t.is_empty() || !pattern.is_empty() => { + name = t; + continue; + } + _ => {} + } + if star { + // Look for a match skipping i+1 bytes; a star cannot skip a /. + let mut i = 0; + while i < name.len() && name[i] != b'/' { + if let Some(t) = match_chunk(chunk, &name[i + 1..])? { + // The last chunk must exhaust the name. + if !pattern.is_empty() || t.is_empty() { + name = t; + continue 'pattern; + } + } + i += 1; + } + } + // Before returning false, check that the rest of the pattern is + // well-formed. + while !pattern.is_empty() { + let (_, chunk, rest) = scan_chunk(pattern); + pattern = rest; + match_chunk(chunk, b"")?; + } + return Ok(false); + } + Ok(name.is_empty()) +} + +/// path.Match's scanChunk: leading stars, then the chunk up to the next star +/// outside a class. +fn scan_chunk(mut pattern: &[u8]) -> (bool, &[u8], &[u8]) { + let mut star = false; + while let [b'*', rest @ ..] = pattern { + pattern = rest; + star = true; + } + let mut in_range = false; + let mut i = 0; + while i < pattern.len() { + match pattern[i] { + b'\\' => { + if i + 1 < pattern.len() { + i += 1; + } + } + b'[' => in_range = true, + b']' => in_range = false, + b'*' if !in_range => return (star, &pattern[..i], &pattern[i..]), + _ => {} + } + i += 1; + } + (star, pattern, &[]) +} + +/// path.Match's matchChunk: the rest of `s` after `chunk` matched its start, +/// `None` on no match. Keeps checking the chunk's syntax after a mismatch. +fn match_chunk<'s>(mut chunk: &[u8], mut s: &'s [u8]) -> Result, ()> { + let mut failed = false; + while !chunk.is_empty() { + failed = failed || s.is_empty(); + match chunk[0] { + b'[' => { + let mut r = 0; + if !failed { + let (c, n) = decode_rune(s); + r = c; + s = &s[n..]; + } + chunk = &chunk[1..]; + let negated = chunk.first() == Some(&b'^'); + if negated { + chunk = &chunk[1..]; + } + let mut matched = false; + let mut ranges = 0; + loop { + if chunk.first() == Some(&b']') && ranges > 0 { + chunk = &chunk[1..]; + break; + } + let (lo, rest) = get_esc(chunk)?; + chunk = rest; + let mut hi = lo; + if chunk[0] == b'-' { + let (h, rest) = get_esc(&chunk[1..])?; + hi = h; + chunk = rest; + } + matched = matched || lo <= r && r <= hi; + ranges += 1; + } + failed = failed || matched == negated; + } + b'?' => { + if !failed { + failed = s[0] == b'/'; + let (_, n) = decode_rune(s); + s = &s[n..]; + } + chunk = &chunk[1..]; + } + b'\\' => { + chunk = &chunk[1..]; + if chunk.is_empty() { + return Err(()); + } + if !failed { + failed = chunk[0] != s[0]; + s = &s[1..]; + } + chunk = &chunk[1..]; + } + c => { + if !failed { + failed = c != s[0]; + s = &s[1..]; + } + chunk = &chunk[1..]; + } + } + } + Ok((!failed).then_some(s)) +} + +/// path.Match's getEsc: one possibly escaped class character, which must +/// be followed by more of the class. +fn get_esc(mut chunk: &[u8]) -> Result<(u32, &[u8]), ()> { + if chunk.is_empty() || chunk[0] == b'-' || chunk[0] == b']' { + return Err(()); + } + if chunk[0] == b'\\' { + chunk = &chunk[1..]; + if chunk.is_empty() { + return Err(()); + } + } + let (r, n) = decode_rune(chunk); + if (r == 0xFFFD && n == 1) || chunk.len() == n { + return Err(()); + } + Ok((r, &chunk[n..])) +} + +/// utf8.DecodeRune: the first rune and its length, `(U+FFFD, 1)` for an +/// invalid encoding. +fn decode_rune(s: &[u8]) -> (u32, usize) { + let len = match s.first() { + None => return (0xFFFD, 0), + Some(&b) if b < 0x80 => return (u32::from(b), 1), + Some(&b) if b >= 0xF0 => 4, + Some(&b) if b >= 0xE0 => 3, + Some(&b) if b >= 0xC0 => 2, + Some(_) => return (0xFFFD, 1), + }; + s.get(..len) + .and_then(|bytes| std::str::from_utf8(bytes).ok()) + .and_then(|text| text.chars().next()) + .map_or((0xFFFD, 1), |c| (u32::from(c), len)) +} + // 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. @@ -362,6 +646,45 @@ fn constraint(expr: &str) -> Option { input.trim().is_empty().then_some(value.0) } +/// The expression of a `//go:build` line (imports.isGoBuildComment): the +/// prefix followed by whitespace or nothing. +fn go_build_expr(line: &str) -> Option<&str> { + let rest = line.strip_prefix("//go:build")?; + (rest.is_empty() || rest.starts_with(char::is_whitespace)).then(|| rest.trim()) +} + +/// The expression of a `// +build` line (constraint.splitPlusBuild): `//`, +/// optional whitespace, `+build`, then whitespace or nothing. +fn plus_build_expr(line: &str) -> Option<&str> { + let rest = line + .strip_prefix("//")? + .trim_start() + .strip_prefix("+build")?; + (rest.is_empty() || rest.starts_with(char::is_whitespace)).then(|| rest.trim()) +} + +/// Whether a `// +build` line excludes the file when every tag but `ignore` +/// is both true and false (Go's AnyTags), following +/// constraint.parsePlusBuildExpr: space-separated clauses OR, comma-separated +/// literals AND, and `!!x`, a lone `!`, an invalid tag and an empty line all +/// read as `ignore`. `!x` holds for any `x`. +fn plus_build_excludes(expr: &str) -> bool { + let valid_tag = |tag: &str| { + !tag.is_empty() + && tag + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '.') + }; + let excludes = |lit: &str| { + if lit.starts_with("!!") || lit == "!" { + return true; + } + !lit.starts_with('!') && (lit == "ignore" || !valid_tag(lit)) + }; + let mut clauses = expr.split_whitespace().peekable(); + clauses.peek().is_none() || clauses.all(|clause| clause.split(',').any(excludes)) +} + fn omitted_by_build_constraint(text: &str) -> bool { let mut modern = None; let mut legacy = Vec::new(); @@ -373,6 +696,17 @@ fn omitted_by_build_constraint(text: &str) -> bool { if line.is_empty() && legacy_allowed { legacy_end = legacy.len(); } + if !block { + if let Some(expr) = go_build_expr(line) { + if modern.replace(expr).is_some() { + return false; + } + } else if legacy_allowed { + if let Some(expr) = plus_build_expr(line) { + legacy.push(expr); + } + } + } loop { if block { let Some((_, rest)) = line.split_once("*/") else { @@ -391,15 +725,6 @@ fn omitted_by_build_constraint(text: &str) -> bool { 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; } @@ -407,12 +732,9 @@ fn omitted_by_build_constraint(text: &str) -> bool { 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")) - }) + legacy[..legacy_end] + .iter() + .any(|expr| plus_build_excludes(expr)) } #[cfg(test)] @@ -431,6 +753,107 @@ mod tests { ]) ); assert_eq!(embed_patterns("\"unterminated"), None); + // A quoted argument must end at a space (ast.Directive.ParseArgs). + assert_eq!(embed_patterns(r#""a"b"#), None); + assert_eq!(embed_patterns("`a`b"), None); + } + + #[test] + fn embed_directives_are_line_comments_outside_literals() { + let source = "package p\n\nimport \"embed\"\n\n\ + //go:embed static/*\n\ + var a embed.FS\n\ + //go:embed\ttabbed\r\n\ + var b embed.FS\n\ + //go:embedded not-a-directive\n\ + const tmpl = `\n//go:embed in-raw-string\n`\n\ + const s = \"//go:embed in-string\"\n\ + /*\n//go:embed in-block-comment\n*/\n"; + let found: Vec<_> = embed_directives(source) + .into_iter() + .map(str::trim) + .collect(); + assert_eq!(found, vec!["static/*", "tabbed"]); + } + + #[test] + fn path_match_follows_go() { + // Cases from Go's path/match_test.go plus embed patterns the glob + // crate read differently. + for (pattern, name, want) in [ + ("abc", "abc", Ok(true)), + ("*", "abc", Ok(true)), + ("*c", "abc", Ok(true)), + ("a*", "a", Ok(true)), + ("a*", "abc", Ok(true)), + ("a*", "ab/c", Ok(false)), + ("a*/b", "abc/b", Ok(true)), + ("a*/b", "a/c/b", Ok(false)), + ("a*b*c*d*e*/f", "axbxcxdxe/f", Ok(true)), + ("a*b*c*d*e*/f", "axbxcxdxexxx/f", Ok(true)), + ("a*b*c*d*e*/f", "axbxcxdxe/xxx/f", Ok(false)), + ("a*b*c*d*e*/f", "axbxcxdxexxx/fff", Ok(false)), + ("a*b?c*x", "abxbbxdbxebxczzx", Ok(true)), + ("a*b?c*x", "abxbbxdbxebxczzy", Ok(false)), + ("ab[c]", "abc", Ok(true)), + ("ab[b-d]", "abc", Ok(true)), + ("ab[e-g]", "abc", Ok(false)), + ("ab[^c]", "abc", Ok(false)), + ("ab[^b-d]", "abc", Ok(false)), + ("ab[^e-g]", "abc", Ok(true)), + ("a\\*b", "a*b", Ok(true)), + ("a\\*b", "ab", Ok(false)), + ("a?b", "a\u{263a}b", Ok(true)), + ("a[^a]b", "a\u{263a}b", Ok(true)), + ("a???b", "a\u{263a}b", Ok(false)), + ("a[^a][^a][^a]b", "a\u{263a}b", Ok(false)), + ("[a-\u{3b6}]*", "\u{3b1}", Ok(true)), + ("*[a-\u{3b6}]", "A", Ok(false)), + ("a?b", "a/b", Ok(false)), + ("a*b", "a/b", Ok(false)), + ("[\\]a]", "]", Ok(true)), + ("[\\-]", "-", Ok(true)), + ("[x\\-]", "x", Ok(true)), + ("[x\\-]", "-", Ok(true)), + ("[x\\-]", "z", Ok(false)), + ("[\\-x]", "x", Ok(true)), + ("[\\-x]", "-", Ok(true)), + ("[\\-x]", "a", Ok(false)), + ("[]a]", "]", Err(())), + ("[-]", "-", Err(())), + ("[x-]", "x", Err(())), + ("[x-]", "-", Err(())), + ("[x-]", "z", Err(())), + ("[-x]", "x", Err(())), + ("[-x]", "-", Err(())), + ("[-x]", "a", Err(())), + ("\\", "a", Err(())), + ("[a-b-c]", "a", Err(())), + ("[", "a", Err(())), + ("[^", "a", Err(())), + ("[^bc", "a", Err(())), + ("a[", "a", Err(())), + ("a[", "ab", Err(())), + ("a[", "x", Err(())), + ("a/b[", "x", Err(())), + ("*x", "xxx", Ok(true)), + ("static/a**", "static/abc", Ok(true)), + ("static/**.css", "static/x.css", Ok(true)), + ( + "templates/**/*.html", + "templates/admin/index.html", + Ok(true), + ), + ("templates/**/*.html", "templates/a/b/c.html", Ok(false)), + ("templates/**/*.html", "templates/top.html", Ok(false)), + ("static/[!.]*", "static/app.js", Ok(false)), + ("static/[!.]*", "static/!x", Ok(true)), + ("static/[^.]*", "static/app.js", Ok(true)), + ("static/[^.]*", "static/.env", Ok(false)), + ("pages/\\[id\\].html", "pages/[id].html", Ok(true)), + ] { + assert_eq!(path_match(pattern, name), want, "{pattern} vs {name}"); + } } #[test] @@ -475,4 +898,147 @@ mod tests { "package main\n//go:build ignore\n" )); } + + #[test] + fn build_constraint_headers_and_plus_build_literals_follow_go() { + // Spellings go mod vendor treats as excluding the file. + for header in [ + "//go:build\tignore", + "//+build ignore", + "// +build ignore", + "// +build\tignore", + "// +build", + "// +build !!linux", + "// +build !", + "// +build integration-test", + "// +build linux,!!amd64 darwin-x", + ] { + assert!( + omitted_by_build_constraint(&format!("{header}\n\npackage main\n")), + "{header:?}" + ); + } + // And ones it keeps: negations hold for any tag, and a longer word + // is not the directive. + for header in [ + "// +build !ignore", + "// +build !integration-test", + "// +buildfoo ignore", + "//go:buildx ignore", + ] { + assert!( + !omitted_by_build_constraint(&format!("{header}\n\npackage main\n")), + "{header:?}" + ); + } + } + + fn record(files: &[(&str, &str)]) -> PatchRecord { + PatchRecord { + uuid: "u".into(), + exported_at: "t".into(), + files: files + .iter() + .map(|(name, after)| { + ( + name.to_string(), + crate::manifest::schema::PatchFileInfo { + before_hash: String::new(), + after_hash: after.to_string(), + }, + ) + }) + .collect(), + vulnerabilities: HashMap::new(), + description: String::new(), + license: String::new(), + tier: String::new(), + } + } + + #[tokio::test] + async fn module_without_vendored_packages_is_not_drift() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let dir = root.join("vendor"); + std::fs::create_dir_all(&dir).unwrap(); + // example.com/testonly is in the build list (a dependency's tests + // import it), so go mod vendor writes only its replacement line. + std::fs::write( + dir.join("modules.txt"), + "# example.com/dep v1.0.0\n## explicit; go 1.21\nexample.com/dep\n\ + # example.com/testonly v1.0.0 => ./.socket/go-patches/example.com/testonly@v1.0.0\n", + ) + .unwrap(); + let hash = compute_git_sha256_from_bytes(b"package testonly\n"); + let known = HashMap::new(); + let target = |module| Target { + root, + dir: &dir, + module, + version: "v1.0.0", + replaces: &[], + known: &known, + command: "go mod vendor", + }; + let rec = record(&[("lib.go", &hash)]); + assert_eq!(verify(&target("example.com/testonly"), &rec).await, Ok(())); + // A selected module's missing file is still drift. + let err = verify(&target("example.com/dep"), &rec).await.unwrap_err(); + assert!(err.contains("missing a patched runtime file"), "{err}"); + // So is another selected version, without re-vendoring advice. + std::fs::write( + dir.join("modules.txt"), + "# example.com/testonly v1.1.0\n## explicit; go 1.21\nexample.com/testonly\n", + ) + .unwrap(); + let err = verify(&target("example.com/testonly"), &rec) + .await + .unwrap_err(); + assert!(err.contains("selects example.com/testonly v1.1.0"), "{err}"); + assert!(!err.contains("go mod vendor"), "{err}"); + } + + #[tokio::test] + async fn embeds_use_go_pattern_rules_and_skip_unbuilt_sources() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("vendor"); + let pkg = dir.join("example.com/m"); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("assets.go"), + "package m\n\nimport \"embed\"\n\n//go:embed templates/**/*.html static/a** css/[^_]*\n\ + var fs embed.FS\nconst t = `\n//go:embed raw/*\n`\n", + ) + .unwrap(); + std::fs::write( + pkg.join("_old.go"), + "package m\n\nimport \"embed\"\n\n//go:embed old/*\nvar o embed.FS\n", + ) + .unwrap(); + std::fs::write( + pkg.join("gen.go"), + "//go:build ignore\n\npackage main\n\nimport \"embed\"\n\n//go:embed gen/*\nvar g embed.FS\n", + ) + .unwrap(); + let packages: HashSet<&str> = ["example.com/m"].into_iter().collect(); + let mut cache = EmbedCache::new(); + for (name, want) in [ + ("templates/admin/index.html", true), + ("templates/admin/deep/x.html", false), + ("templates/top.html", false), + ("static/abc.txt", true), + ("css/site.css", true), + ("css/_partial.css", false), + ("raw/x.txt", false), + ("old/x.txt", false), + ("gen/x.txt", false), + ] { + assert_eq!( + is_embedded(&dir, "example.com/m", name, &packages, &mut cache).await, + Ok(want), + "{name}" + ); + } + } } diff --git a/crates/socket-patch-core/src/vendor/go_sum_backup.rs b/crates/socket-patch-core/src/vendor/go_sum_backup.rs index 88c22bbc2..3e0c34489 100644 --- a/crates/socket-patch-core/src/vendor/go_sum_backup.rs +++ b/crates/socket-patch-core/src/vendor/go_sum_backup.rs @@ -81,7 +81,16 @@ fn backup(root: &Path, entry: &ReplaceEntry) -> Option { /// 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> { +/// A version the build no longer selects (go.mod now requires another one, +/// or a pruned graph no longer has the module) needs no checksums: restoring +/// them would leave go.sum untidy and make discovery see the old version +/// again. `dry_run` makes every check without writing go.sum. +pub(super) async fn restore( + root: &Path, + entries: &[ReplaceEntry], + dry_run: bool, +) -> Result<(), String> { + use super::go_build_list::{project_selection, Selection}; let path = root.join("go.sum"); let mut sums = read(&path).await?; let original = sums.clone(); @@ -89,6 +98,12 @@ pub(super) async fn restore(root: &Path, entries: &[ReplaceEntry]) -> Result<(), let (Some(backup), Some(version)) = (backup(root, entry), entry.version.as_deref()) else { continue; }; + if matches!( + project_selection(root, &entry.module, version).await, + Some(Selection::Other(_) | Selection::NotInGraph) + ) { + continue; + } let saved = selected(&read(&backup).await?, &entry.module, version); for line in saved.lines() { let fields: Vec<_> = line.split_whitespace().collect(); @@ -109,7 +124,7 @@ pub(super) async fn restore(root: &Path, entries: &[ReplaceEntry]) -> Result<(), sums = new; } } - if sums != original { + if !dry_run && sums != original { atomic_write_bytes_preserving_mode(&path, sums.as_bytes()) .await .map_err(|e| e.to_string())?; @@ -210,5 +225,71 @@ mod tests { ); assert_eq!(read(&root.join("go.mod")).await.unwrap(), before); assert_eq!(read(&root.join("go.sum")).await.unwrap(), conflict); + // A dry run makes the same refusal, and writes nothing either. + assert!( + drop_replace_entry(root, MODULE, ReplaceOwner::GoPatches, true) + .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); + } + + #[tokio::test] + async fn dry_run_drop_restores_nothing() { + 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, false) + .await + .unwrap(); + // `go mod tidy` under the directory replace drops the sums. + tokio::fs::write(root.join("go.sum"), "").await.unwrap(); + drop_replace_entry(root, MODULE, ReplaceOwner::GoPatches, true) + .await + .unwrap(); + assert_eq!(read(&root.join("go.sum")).await.unwrap(), ""); + drop_replace_entry(root, MODULE, ReplaceOwner::GoPatches, false) + .await + .unwrap(); + assert_eq!(read(&root.join("go.sum")).await.unwrap(), SUMS); + } + + /// After `go get` moves the module to another version, the patched + /// version's sums are not needed: restoring them would leave go.sum + /// untidy and make discovery see the old version again. + #[tokio::test] + async fn restore_skips_a_version_the_build_no_longer_selects() { + 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, false) + .await + .unwrap(); + let go_mod = read(&root.join("go.mod")).await.unwrap().replace( + "require example.com/upstream v1.0.0", + "require example.com/upstream v1.0.1", + ); + tokio::fs::write(root.join("go.mod"), go_mod).await.unwrap(); + let bumped = + "example.com/upstream v1.0.1 h1:ccccccccccccccccccccccccccccccccccccccccccc=\n"; + tokio::fs::write(root.join("go.sum"), bumped).await.unwrap(); + drop_replace_entry(root, MODULE, ReplaceOwner::GoPatches, false) + .await + .unwrap(); + assert_eq!(read(&root.join("go.sum")).await.unwrap(), bumped); } } diff --git a/crates/socket-patch-core/src/vendor/golang.rs b/crates/socket-patch-core/src/vendor/golang.rs index cbe6cafb0..911341b09 100644 --- a/crates/socket-patch-core/src/vendor/golang.rs +++ b/crates/socket-patch-core/src/vendor/golang.rs @@ -595,12 +595,16 @@ async fn teardown_failed_service_copy( wired: bool, ) { let uuid_dir = project_root.join(base_rel); - let _ = remove_tree(&uuid_dir).await; - prune_empty_vendor_levels(&uuid_dir).await; if wired { - let _ = go_mod_edit::drop_replace_entry(project_root, module, ReplaceOwner::Vendor, false) - .await; + // Before the uuid dir goes: it holds the saved upstream go.sum lines + // the drop restores. A refused restore still drops the directive, + // which would otherwise dangle at the deleted copy. + let _ = + go_mod_edit::drop_replace_entry_for_cleanup(project_root, module, ReplaceOwner::Vendor) + .await; } + let _ = remove_tree(&uuid_dir).await; + prune_empty_vendor_levels(&uuid_dir).await; } /// Failure cleanup for the STAGED service leg: the stage is always removed. A diff --git a/crates/socket-patch-core/src/vex/verify.rs b/crates/socket-patch-core/src/vex/verify.rs index 56d6f3aab..45b485ba6 100644 --- a/crates/socket-patch-core/src/vex/verify.rs +++ b/crates/socket-patch-core/src/vex/verify.rs @@ -186,22 +186,6 @@ 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 {