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 787acab34..30f47256c 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -254,18 +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. -async fn reconcile_local_go(common: &GlobalArgs, target_manifest_purls: &HashSet) { +/// 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( + args: &ApplyArgs, + target_manifest_purls: &HashSet, +) -> Vec { + let common = &args.common; if !eco_in_local_scope(common, Ecosystem::Golang) { - return; + 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" @@ -284,6 +292,36 @@ async fn reconcile_local_go(common: &GlobalArgs, target_manifest_purls: &HashSet println!(" {purl}"); } } + 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 @@ -294,7 +332,9 @@ async fn reconcile_local_go(common: &GlobalArgs, target_manifest_purls: &HashSet /// 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)), @@ -367,6 +407,29 @@ 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_tree( + &args.common.cwd, + purl, + record, + &no_texts, + ) + .await + { + drifts.push(( + purl.clone(), + socket_patch_core::vendor::go_consumer_sync::NATIVE_VENDOR_CODE.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). @@ -465,10 +528,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())); } @@ -1402,11 +1466,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().flat_map(|r| { let note = r.error.as_deref().filter(|_| r.success).unwrap_or_default(); socket_patch_core::patch::apply::advisory_warning_codes(note) @@ -1690,11 +1749,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(cache_warnings); - warnings.extend(nested_skip_warnings); + warnings.extend( + run_warnings + .iter() + .filter(|w| !is_stage_failure_code(&w.code)) + .cloned(), + ); let (applied, skipped) = collect_apply_outcomes( &results, &unmatched, @@ -2212,7 +2276,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 @@ -2221,13 +2285,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(), @@ -2256,6 +2321,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(); @@ -2324,7 +2390,39 @@ 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(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 // unmatched. An all-vendored manifest with an absent installed @@ -2350,7 +2448,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, @@ -2368,7 +2466,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 @@ -2826,15 +2924,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 @@ -2855,6 +2946,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 +} + /// The Go modules a (global, in-place) apply left patched in the module /// cache, and their cache copies, for the installed-tool check (#422). /// Already-patched copies count too: a tool built before an earlier run 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 46488779b..8d0b21bfd 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -1300,6 +1300,17 @@ 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 { + // 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(); + } if let Some((module, _)) = socket_patch_core::utils::purl::parse_golang_purl(key) { let issues: Vec = go_sync_issues .iter() diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index 21f688fe7..58231db94 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -1073,6 +1073,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 @@ -2009,6 +2070,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 6c913c704..77d615ac0 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, @@ -613,6 +656,15 @@ fn go_work_vendor_member_needs_go_work_vendor_after_rollback_and_remove() { /// 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; } @@ -643,7 +695,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, @@ -691,7 +748,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()), @@ -702,6 +759,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(); @@ -750,7 +813,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) @@ -761,6 +824,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()); } @@ -779,10 +883,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( @@ -1097,6 +1202,332 @@ 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()); +} + /// #1751: a go.work build list spans every `use` member's requirements, so /// when member b requires a newer upstream than a, the workspace builds a /// with b's version and a replace pinned to a's is inert. apply in a must @@ -1187,7 +1618,6 @@ fn go_work_member_requiring_a_newer_version_makes_the_replace_inert() { assert!(go_out(&b, &env).contains("OUT: PATCHED")); let (code, so, se) = check(&a, &modcache); assert_eq!(code, 0, "{so}\n{se}"); - 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 a04dd0dfb..3b91499e3 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 9b4f52950..9d7f9bd05 100644 --- a/crates/socket-patch-cli/tests/e2e_golang_workspace_build.rs +++ b/crates/socket-patch-cli/tests/e2e_golang_workspace_build.rs @@ -115,6 +115,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 { @@ -484,6 +520,13 @@ fn go_work_vendored_members_build_patched_and_attest_without_manifest() { "vendored, go.work replace", ); + reinstall_workspace( + root, + &Machine::new(tmp.path(), "vendored-reinstall"), + // Older Go work sync loads each member's original module metadata. + &day2(&ws.proxy), + ); + tail(&Tail { tmp: tmp.path(), ws: &ws, @@ -699,6 +742,12 @@ fn go_work_hosted_members_build_patched_and_attest_without_manifest() { "hosted, go.work replace + go.work.sum", ); + reinstall_workspace( + root, + &Machine::new(tmp.path(), "hosted-reinstall"), + &day2(&ws.proxy), + ); + tail(&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 bd772e205..8f72f7dbf 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( @@ -1036,6 +1041,85 @@ 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()); +} + // ── #1777: vendored undo vs a committed vendor/ ─────────────────────── /// #1777: in a project with a committed `vendor/`, a vendored patch plus 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/src/patch/redirect/golang_local.rs b/crates/socket-patch-core/src/patch/redirect/golang_local.rs index 28d39e7e0..d06967c43 100644 --- a/crates/socket-patch-core/src/patch/redirect/golang_local.rs +++ b/crates/socket-patch-core/src/patch/redirect/golang_local.rs @@ -175,6 +175,65 @@ 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; + } + // 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, + &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 @@ -404,11 +463,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 @@ -419,38 +484,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, -) -> Vec { +) -> 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()) { - let _ = 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; - if let Some(v) = &entry.version { - let purl = build_golang_purl(&entry.module, v); - if !removed.contains(&purl) { - removed.push(purl); + .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(purl) = purl { + if !out.removed.contains(&purl) { + out.removed.push(purl); } } } @@ -480,31 +575,54 @@ 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()) }) { - let _ = 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() + .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 { 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); } } } - removed + out } /// Registry-independent verification for `apply --check` (CI / GitHub-App @@ -706,7 +824,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; + } } } @@ -1457,6 +1587,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; @@ -1589,7 +1842,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.removed; assert!(removed.contains(&PURL.to_string())); assert!(!root .join(".socket/go-patches/github.com/foo/bar@v1.4.2") @@ -1667,7 +1920,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.removed; assert!(removed.is_empty()); let entries = read_replace_entries(root).await; assert!(entries @@ -1710,7 +1963,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.removed; assert!(removed.contains(&PURL.to_string())); assert!( !root @@ -1765,7 +2018,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.removed; assert!(removed.contains(&PURL.to_string())); assert!( !root @@ -2264,7 +2517,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.removed; assert!( removed.is_empty(), "a desired (qualified) redirect must not be pruned: {removed:?}" @@ -2325,7 +2578,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.removed; assert!( removed.is_empty(), "a desired encoded version must not be pruned: {removed:?}" @@ -2661,7 +2914,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 + .removed; assert!( removed.contains(&PURL.to_string()), "dry-run must still report the orphan: {removed:?}" @@ -2683,7 +2938,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 + .removed; assert_eq!( removed_wet, removed, "dry-run report must match the real run" @@ -2714,7 +2971,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 + .removed; assert!( read_replace_entries(root).await.is_empty(), "the version-less orphan directive must be dropped" @@ -2758,7 +3017,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 + .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 0347f1175..3d4cfd453 100644 --- a/crates/socket-patch-core/src/vendor/go_consumer_sync.rs +++ b/crates/socket-patch-core/src/vendor/go_consumer_sync.rs @@ -357,6 +357,168 @@ 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. 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.filter(|_| explicit); + } + 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=")) + { + explicit |= !value.is_empty(); + 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.unwrap_or_else(|| project_root.join("go.mod")); + let text = read_regular_to_string(&config).await.ok()?; + 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(()); + }; + 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 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( + &super::go_native_vendor::Target { + root: project_root, + dir: &dir, + module: module.as_ref(), + version: version.as_ref(), + replaces: &replaces, + known, + command, + }, + record, + ) + .await +} + fn workspace_file(project_root: &Path) -> Option { match go_workspace::resolve(project_root) { GoWork::File(file) => Some(file), @@ -643,6 +805,46 @@ 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); + // 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"; 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 b29ab5c10..970723eab 100644 --- a/crates/socket-patch-core/src/vendor/go_mod_edit.rs +++ b/crates/socket-patch-core/src/vendor/go_mod_edit.rs @@ -207,6 +207,13 @@ pub async fn ensure_replace_entry( } let host = crate::vendor::go_workspace::replace_host(project_root, module).await; let host_path = project_root.join(host.file_name()); + if !dry_run { + let content = read_regular_to_string(&host_path) + .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?; + } let upsert = plan_edit(&host_path, |c| { upsert_replace_entry(c, module, version, base_rel) }) @@ -269,6 +276,28 @@ pub async fn drop_replace_entry( module: &str, owner: ReplaceOwner, dry_run: bool, +) -> Result { + let entries: Vec<_> = read_replace_entries(project_root) + .await + .into_iter() + .filter(|entry| entry.module == module && entry.owner == Some(owner)) + .collect(); + 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?; + } + remove_from_replace_files(project_root, module, owner, dry_run).await +} + +/// Remove `module`'s `owner` replace from the root `go.work` (when present) +/// and the root `go.mod`, leaving go.sum alone. Returns whether a file +/// changed. +async fn remove_from_replace_files( + project_root: &Path, + module: &str, + owner: ReplaceOwner, + dry_run: bool, ) -> Result { let work = project_root.join("go.work"); let go_mod = go_mod_path(project_root); @@ -285,6 +314,29 @@ pub async fn drop_replace_entry( Ok(changed) } +/// [`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); + } + remove_from_replace_files(project_root, module, owner, false) + .await + .map(|_| false) +} + // ── file resolution + read/write ────────────────────────────────────────────── fn go_mod_path(project_root: &Path) -> PathBuf { @@ -513,19 +565,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 new file mode 100644 index 000000000..e3e2dfe78 --- /dev/null +++ b/crates/socket-patch-core/src/vendor/go_native_vendor.rs @@ -0,0 +1,1044 @@ +//! 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}; + +/// 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(t: &Target<'_>, name: &str, hash: &str) -> Option { + if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + 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(t.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(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| revendor(format!("cannot read {}/modules.txt: {e}", t.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); + } + } + } + // 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(t, name, &info.after_hash) + .await + .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)) + { + // 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}; run `go mod tidy`, then `{}`, and commit go.mod, go.sum \ + and vendor/", + t.command + )); + } + } + continue; + } + if !compiled || name == "go.sum" || name.ends_with("_test.go") { + continue; + } + 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(revendor(format!( + "{} does not match the patched file", + path.display() + ))) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + 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() { + 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(t.dir, module, name, &packages, &mut embeds) + .await + .map_err(revendor)? + { + continue; + } + return Err(revendor(format!( + "{} is missing a patched embedded asset", + path.display() + ))); + } + if name.ends_with(".go") { + if let Some(text) = expected_text(t, name, &info.after_hash).await { + if omitted_by_build_constraint(&text) { + continue; + } + } + } + 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( + dir: &Path, + module: &str, + name: &str, + packages: &HashSet<&str>, + cache: &mut EmbedCache, +) -> Result { + // 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}"), + }; + 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); + } + } + } + } + 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 pattern in patterns { + let (all, glob) = match pattern.strip_prefix("all:") { + Some(glob) => (true, glob.to_string()), + None => (false, pattern), + }; + 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, + } + } + 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('"') { + 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?..]; + if !quoted_end(text) { + return None; + } + 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'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. +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) +} + +/// 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(); + 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(); + } + 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 { + 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 !line.is_empty() && !line.starts_with("//") { + break; + } + } + if let Some(expr) = modern { + return constraint(expr) == Some(false); + } + legacy[..legacy_end] + .iter() + .any(|expr| plus_build_excludes(expr)) +} + +#[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); + // 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] + 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" + )); + } + + #[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 new file mode 100644 index 000000000..3e0c34489 --- /dev/null +++ b/crates/socket-patch-core/src/vendor/go_sum_backup.rs @@ -0,0 +1,295 @@ +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. +/// 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(); + for entry in entries { + 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(); + 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 !dry_run && 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); + // 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 de7f1fdfa..6b3ab488b 100644 --- a/crates/socket-patch-core/src/vendor/golang.rs +++ b/crates/socket-patch-core/src/vendor/golang.rs @@ -614,12 +614,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/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index 651d3119f..4eb9c71a4 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 go_workspace; pub mod golang; diff --git a/docs/ecosystems.md b/docs/ecosystems.md index feb2028eb..90420cd00 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -1429,6 +1429,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