Skip to content
Open
1 change: 1 addition & 0 deletions crates/socket-patch-cli/src/commands/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,7 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 {
| GoDrift::WrongReplacePath { purl, .. }
| GoDrift::ResolvedVersionMismatch { purl, .. }
| GoDrift::UnprovenSelection { purl, .. }
| GoDrift::UnprovenStaleSum { purl, .. }
| GoDrift::ReplaceTargetOnly { purl, .. } => purl.clone(),
GoDrift::OrphanReplace { module } => module.clone(),
};
Expand Down
5 changes: 5 additions & 0 deletions crates/socket-patch-cli/src/commands/vex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1044,6 +1044,11 @@ async fn generate_vex(
{version} is ignored and the build links the unpatched module",
entry.module
),
Some(Selection::Unproven) => format!(
"nothing shows the build selects {} {version}: {}",
entry.module,
socket_patch_core::vendor::go_build_list::UNPROVEN_REASON
),
Some(Selection::Unknown)
if socket_patch_core::vendor::go_workspace::resolve(&root)
!= socket_patch_core::vendor::go_workspace::GoWork::None =>
Expand Down
167 changes: 167 additions & 0 deletions crates/socket-patch-cli/tests/e2e_golang_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2215,3 +2215,170 @@ fn goflags_modfile_keeps_the_go_mod_replace_from_the_build() {

chmod_writable(tmp.path());
}

// ── a go.sum line a downgrade left behind (#1862) ──

/// A go 1.16 consumer of the upstream at `from`, downgraded to `to` by
/// `go get`, which lowers the require but keeps `from`'s go.sum lines
/// until `go mod tidy`.
fn downgraded_consumer(consumer: &Path, from: &str, to: &str, env: &[(&str, &str)]) {
std::fs::write(
consumer.join("go.mod"),
format!("module example.com/consumer\n\ngo 1.16\n\nrequire {UMOD} {from}\n"),
)
.unwrap();
for args in [vec!["mod", "tidy"], vec!["get", &format!("{UMOD}@{to}")]] {
let out = go(consumer, &args, env);
assert!(
out.status.success(),
"go {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
}
let go_mod = std::fs::read_to_string(consumer.join("go.mod")).unwrap();
assert!(go_mod.contains(&format!("{UMOD} {to}")), "{go_mod}");
let sum = std::fs::read_to_string(consumer.join("go.sum")).unwrap();
assert!(
sum.contains(&format!("{UMOD} {from} h1:")),
"go get must keep the stale zip line:\n{sum}"
);
}

/// Publish the upstream at `version` with a greeting of `greeting`.
fn publish_upstream(tmp: &Path, version: &str, greeting: &str) {
publish(
tmp,
UMOD,
version,
&[
("go.mod", &format!("module {UMOD}\n\ngo 1.16\n")),
(
"lib.go",
&format!(
"package upstream\n\nfunc Greeting() string {{ return \"{greeting}\" }}\n"
),
),
],
);
}

/// #1862 case 1: downgraded below the patched version, the build links
/// the older module. The stale zip line at the patched version must not
/// pass for proof it is built: apply refuses, and a replace written
/// before the downgrade fails `apply --check` (with or without the module
/// cache to read the graph from) and is left out of VEX.
#[test]
fn downgrade_below_the_patch_leaves_its_replace_unattested() {
if !golang_e2e_matrix::toolchain_ready("e2e_golang_build(downgrade-below)") {
return;
}
let tmp = tempfile::tempdir().unwrap();
let (consumer, modcache, proxy_url) = stage_with(tmp.path(), "1.16");
publish_upstream(tmp.path(), "v0.9.0", "OLDER");
let env = go_env(modcache.to_str().unwrap(), &proxy_url);
let readonly = readonly_env(&env);
let empty_cache = tmp.path().join("empty-modcache");
std::fs::create_dir_all(&empty_cache).unwrap();

// A fresh apply after the downgrade refuses and writes nothing.
downgraded_consumer(&consumer, UVER, "v0.9.0", &env);
write_patch(&consumer);
let (code, so, se) = apply_json(&consumer, &modcache);
assert_ne!(code, 0, "apply must refuse the inert replace:\n{so}\n{se}");
assert!(
so.contains(&format!("the build selects {UMOD} v0.9.0")),
"{so}\n{se}"
);
assert!(replace_lines(&consumer.join("go.mod")).is_empty());
// Applied before the downgrade: the replace is inert afterwards.
std::fs::write(
consumer.join("go.mod"),
format!("module example.com/consumer\n\ngo 1.16\n\nrequire {UMOD} {UVER}\n"),
)
.unwrap();
let tidy = go(&consumer, &["mod", "tidy"], &env);
assert!(tidy.status.success());
let (code, so, se) = apply_json(&consumer, &modcache);
assert_eq!(code, 0, "apply at the required version:\n{so}\n{se}");
assert!(go_out(&consumer, &readonly).contains("OUT: PATCHED"));
let socket_replace = replace_lines(&consumer.join("go.mod"));
assert_eq!(socket_replace.len(), 1, "{socket_replace:?}");
let get = go(&consumer, &["get", &format!("{UMOD}@v0.9.0")], &env);
assert!(
get.status.success(),
"{}",
String::from_utf8_lossy(&get.stderr)
);
// The directory replace dropped the original's go.sum lines; an
// untidied go.sum from before the apply still carries them.
let mut sum = std::fs::read_to_string(consumer.join("go.sum")).unwrap();
if !sum.contains(&format!("{UMOD} {UVER} h1:")) {
let pristine = tmp.path().join("pristine");
consumer_module(
&pristine,
"example.com/pristine",
modcache.to_str().unwrap(),
&proxy_url,
);
sum.push_str(&std::fs::read_to_string(pristine.join("go.sum")).unwrap());
std::fs::write(consumer.join("go.sum"), sum).unwrap();
}
assert_eq!(replace_lines(&consumer.join("go.mod")), socket_replace);
assert!(go_out(&consumer, &readonly).contains("OUT: OLDER"));

let (code, so, se) = check(&consumer, &modcache);
assert_eq!(
code, 1,
"apply --check must flag the inert replace:\n{so}\n{se}"
);
assert!(
format!("{so}{se}").contains("is not the version the build selects (v0.9.0)"),
"{so}\n{se}"
);
let (code, so, se) = check(&consumer, &empty_cache);
assert_eq!(code, 1, "without the cache too:\n{so}\n{se}");
assert!(
format!("{so}{se}").contains("nothing shows the build selects the patched version"),
"{so}\n{se}"
);
let r = VexRun {
offline: true,
product: Some("pkg:golang/example.com/consumer".into()),
..VexRun::default()
}
.env("GOMODCACHE", &modcache)
.env("GOFLAGS", "");
let out = run_vex(&vex_e2e_common::binary(), &consumer, &r);
assert_absent(out.doc.as_ref(), UPURL);
vex_e2e_common::assert_not_attested(&out.envelope, UPURL, "go_version_not_selected");

chmod_writable(tmp.path());
}

/// #1862 case 2: downgraded to the patched version, the build links it.
/// The stale zip line above it must not refuse the patch.
#[test]
fn downgrade_to_the_patch_applies_and_checks_in_sync() {
if !golang_e2e_matrix::toolchain_ready("e2e_golang_build(downgrade-to)") {
return;
}
let tmp = tempfile::tempdir().unwrap();
let (consumer, modcache, proxy_url) = stage_with(tmp.path(), "1.16");
publish_upstream(tmp.path(), "v1.1.0", "NEWER");
let env = go_env(modcache.to_str().unwrap(), &proxy_url);
let readonly = readonly_env(&env);

downgraded_consumer(&consumer, "v1.1.0", UVER, &env);
assert!(go_out(&consumer, &readonly).contains("OUT: PRISTINE"));
write_patch(&consumer);
let (code, so, se) = apply_json(&consumer, &modcache);
assert_eq!(
code, 0,
"apply must patch the selected version:\n{so}\n{se}"
);
assert!(go_out(&consumer, &readonly).contains("OUT: PATCHED"));
let (code, so, se) = check(&consumer, &modcache);
assert_eq!(code, 0, "apply --check:\n{so}\n{se}");

chmod_writable(tmp.path());
}
13 changes: 11 additions & 2 deletions crates/socket-patch-core/src/hosted/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -565,14 +565,23 @@ async fn read_go_workspace(
}
}
// Module mode: whether a module-to-module replace is in effect
// can turn on modules only the dependencies require (#1840).
// can turn on modules only the dependencies require (#1840),
// and which version the build selects on a go.sum zip line a
// downgrade may have left behind (#1862).
let mut module_graph = None;
if loaded == Loaded::None {
touched.push(root.join("go.mod"));
touched.push(root.join("go.sum"));
let go_mod = crate::utils::fs::read_regular_to_string(&root.join("go.mod")).await;
let go_sum = crate::utils::fs::read_regular_to_string(&root.join("go.sum"))
.await
.ok();
if let Ok(go_mod) = go_mod {
let go_mod = crate::vendor::go_mod_edit::normalize_for_read(&go_mod);
if crate::vendor::go_build_list::may_need_module_graph(&go_mod) {
if crate::vendor::go_build_list::may_need_module_graph(
&go_mod,
go_sum.as_deref(),
) {
touched.extend(
crate::vendor::go_build_list::module_graph_reads(&go_mod)
.into_iter()
Expand Down
26 changes: 26 additions & 0 deletions crates/socket-patch-core/src/patch/redirect/golang_local.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,15 @@ pub enum Drift {
purl: String,
patched_version: String,
},
/// A go 1.16-or-older go.mod requires the module below a go.sum zip
/// line, and the module cache lacks a go.mod its graph needs. The line
/// is a dependency's raise or one `go get` left behind after a
/// downgrade, so nothing shows the build selects the patched version
/// (#1862).
UnprovenStaleSum {
purl: String,
patched_version: String,
},
/// go.mod builds the patched version only as the target of another
/// module's `replace` (`replace <via> => <module> <version>`). go
/// doesn't apply a replace to a replacement, so the Socket `replace` is
Expand Down Expand Up @@ -147,6 +156,15 @@ impl std::fmt::Display for Drift {
replacement directory nor the module cache (`go mod download` fetches it), \
and go may link the UNPATCHED module"
),
Drift::UnprovenStaleSum {
purl,
patched_version,
} => write!(
f,
"{purl}: nothing shows the build selects the patched version \
{patched_version}: {}; go may link the UNPATCHED module",
go_build_list::UNPROVEN_REASON
),
Drift::ReplaceTargetOnly {
purl,
patched_version,
Expand Down Expand Up @@ -724,6 +742,10 @@ pub async fn verify_go_redirect_state(
patched_version: version.to_string(),
})
}
Some(Selection::Unproven) => drifts.push(Drift::UnprovenStaleSum {
purl: purl.clone(),
patched_version: version.to_string(),
}),
Some(Selection::ReplaceTarget(via)) => drifts.push(Drift::ReplaceTargetOnly {
purl: purl.clone(),
patched_version: version.to_string(),
Expand Down Expand Up @@ -833,6 +855,10 @@ pub(crate) async fn selection_refusal(
replace to a replacement: its replace would be ignored and the build would \
link the unpatched module; nothing written"
)),
Selection::Unproven => Some(format!(
"nothing shows the build selects {module} {version}: {}; nothing written",
go_build_list::UNPROVEN_REASON
)),
Selection::Selected | Selection::Unknown => None,
}
}
Expand Down
16 changes: 14 additions & 2 deletions crates/socket-patch-core/src/patch/redirect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9829,6 +9829,8 @@ fn rewrite_golang_target(
"the go.work workspace selects"
} else if scan.required.as_deref() == Some(selected.as_str()) {
"go.mod requires"
} else if graph.as_ref().is_some_and(|g| g.complete) {
"the module graph selects"
} else {
"go.sum shows the build selects"
};
Expand Down Expand Up @@ -9894,6 +9896,7 @@ fn rewrite_golang_target(
return false;
} else if selection != go_build_list::Selection::Selected
&& (in_workspace && selection == go_build_list::Selection::Unknown
|| selection == go_build_list::Selection::Unproven
|| prior.is_none_or(|e| e.version.as_deref() != Some(dep.version.as_str())))
{
// Not selected by go.mod's require, no go.sum zip line at this
Expand All @@ -9902,10 +9905,19 @@ fn rewrite_golang_target(
// module cache). Its replace would be inert, and confirming it
// would attest a patch no build links. A workspace graph that
// could not be read proves nothing either way, so there even our
// own pin is no proof the build selects this version.
// own pin is no proof the build selects this version. Nor is it
// over a go.sum zip line above go.mod's require that a downgrade
// may have left behind (#1862).
result.warnings.push(RewriteWarning {
code: "redirect_golang_not_in_module_graph".into(),
detail: if in_workspace && selection == go_build_list::Selection::Unknown {
detail: if selection == go_build_list::Selection::Unproven {
format!(
"{fname}@{}: nothing shows the build selects this version — {}; \
nothing redirected",
dep.version,
go_build_list::UNPROVEN_REASON
)
} else if in_workspace && selection == go_build_list::Selection::Unknown {
format!(
"{fname}@{}: nothing shows the go.work workspace building this version \
— a go.mod its module graph needs is not in the module cache (`go mod \
Expand Down
Loading
Loading