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..6d5c88692 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 @@ -2262,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 @@ -2288,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, @@ -2306,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/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..08edc3fec 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,329 @@ 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\"", + ] { + // 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!( + 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()); +} + +#[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_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_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/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..b00fce047 --- /dev/null +++ b/crates/socket-patch-cli/tests/golang_production/mod.rs @@ -0,0 +1,224 @@ +#![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", "--package", PURL, "--mode", mode] + }; + let result = cli(&consumer, &cache, &args); + let events = result["events"].as_array().unwrap(); + assert!( + 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") + }), + "{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"); + // 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"]); + 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..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 @@ -355,11 +406,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 +435,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 +448,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 +498,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 +526,7 @@ pub async fn reconcile_go_redirects( } } - removed + Ok(removed) } /// Registry-independent verification for `apply --check` (CI / GitHub-App @@ -1377,7 +1448,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 +1526,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 +1569,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 +1624,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 +2123,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 +2184,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 +2520,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 +2544,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 +2577,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 +2623,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..b5fe8fbb0 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -1391,6 +1391,25 @@ 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 +`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