Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md

Large diffs are not rendered by default.

47 changes: 47 additions & 0 deletions crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,53 @@ async fn gem_hosted_scan_never_pins_a_version_the_lock_does_not_resolve() {
);
}

/// #1125: the same shared-home copy, but the project has no lock at all
/// (a fresh library clone before `bundle install`). Nothing says which
/// version the project resolves, so hosted mode must neither rewrite the
/// user's `~> 2.0` down to the installed 1.0.0 nor append the gem to a
/// Gemfile that never declared it: it skips with `redirect_gem_no_lockfile`
/// (whose detail names `bundle lock`) and writes nothing.
#[tokio::test(flavor = "multi_thread")]
async fn gem_hosted_scan_without_a_lock_pins_nothing() {
let server = MockServer::start().await;
mount_api(&server, None).await;
for gemfile in [
format!("source \"https://rubygems.org\"\ngem \"{DEP}\", \"~> 2.0\"\n"),
"source \"https://rubygems.org\"\ngem \"tiny-dep\"\n".to_string(),
] {
let tmp = tempfile::tempdir().unwrap();
let proj = tmp.path().join("proj");
std::fs::create_dir_all(&proj).unwrap();
std::fs::write(proj.join("Gemfile"), &gemfile).unwrap();
materialize_installed_gem(&proj, "3.3.0", UPSTREAM_LIB);

let (code, stdout, stderr) = hosted_scan_json(&proj, &server.uri());
let env = common::parse_json_envelope(&stdout);
assert_eq!(code, 0, "{env}\nstderr:\n{stderr}");
assert_eq!(
env["redirect"]["redirected"], 0,
"nothing may be redirected: {env}\nstderr:\n{stderr}"
);
let hit: Vec<&serde_json::Value> = env["redirect"]["warnings"]
.as_array()
.expect("redirect.warnings")
.iter()
.filter(|w| w["code"] == "redirect_gem_no_lockfile")
.collect();
assert_eq!(hit.len(), 1, "the missing lock must be reported: {env}");
assert!(
hit[0]["detail"].as_str().unwrap().contains("bundle lock"),
"the remedy names `bundle lock`: {env}"
);
assert_eq!(
std::fs::read_to_string(proj.join("Gemfile")).unwrap(),
gemfile,
"the Gemfile must stay byte-identical"
);
assert!(!proj.join("Gemfile.lock").exists());
}
}

/// TWO gem homes (two ruby versions under vendor/bundle) both stale: one
/// warning per home, each naming its own home's paths — multiplicity is
/// per materialization, not per purl.
Expand Down
159 changes: 159 additions & 0 deletions crates/socket-patch-core/src/patch/redirect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6424,6 +6424,26 @@ fn rewrite_gem(
}
continue;
}
// A manifest with no lock (a fresh library clone before `bundle
// install`): nothing records which version the project resolves,
// and the crawl may hand in another project's copy from the shared
// gem home. Pinning it would overwrite the user's constraint or
// append a gem the project never declared, and a Gemfile-only pin
// is no reference `vex` / `rollback` / `remove` can see (#1125).
// Hosted mode re-points the version a lock resolves, so ask for one.
if locked.is_none() {
result.warnings.push(RewriteWarning {
code: "redirect_gem_no_lockfile".into(),
detail: format!(
"{gemfile_name} has no {lock_name}, so nothing says which version of {} \
this project resolves ({} {} may be another project's copy in the \
shared gem home); redirect skipped — run `bundle lock` (or `bundle \
install`), commit {lock_name}, and re-run the hosted scan",
dep.name, dep.name, dep.version
),
});
continue;
}

// Platform-suffixed CHECKSUMS siblings (`name (version-arm64-darwin)
// sha256=`) mean bundler resolves platform-specific gems the patch
Expand Down Expand Up @@ -14701,6 +14721,73 @@ mod tests {
assert!(out.contains(" gem \"colorize\", \"1.1.0\"\nend"), "{out}");
}

/// #1125: with no `Gemfile.lock` / `gems.locked` (a fresh library
/// clone before `bundle install`) nothing says which version the
/// project resolves, and the crawl may hand in another project's copy
/// from the shared gem home. Pinning it would downgrade the user's
/// range or append a gem the project never used. The rewriter skips
/// every gem with a lock-first remedy, writing nothing.
#[test]
fn gem_without_a_lock_is_never_pinned() {
for (manifest, gemfile) in [
(
"Gemfile",
"source \"https://rubygems.org\"\n\ngem \"colorize\", \"~> 2.0\"\n",
),
(
"Gemfile",
"source \"https://rubygems.org\"\n\ngem \"tiny-dep\"\n",
),
(
"Gemfile",
"source \"https://rubygems.org\"\n\ngem \"colorize\", \"0.8.1\"\n",
),
(
"gems.rb",
"source \"https://rubygems.org\"\n\ngem \"tiny-dep\"\n",
),
] {
let files = BTreeMap::from([(manifest.to_string(), gemfile.to_string())]);
let r = rewrite_registry_redirect(&files, &[gem_override("colorize", "0.8.1")]);
assert!(
r.files.is_empty() && r.edits.is_empty(),
"{gemfile}: a lockless gem must not be pinned\nfiles={:?}",
r.files
);
assert_eq!(
warning_codes(&r),
vec!["redirect_gem_no_lockfile"],
"{gemfile}: {:?}",
r.warnings
);
let detail = &r.warnings[0].detail;
assert!(
detail.contains("bundle lock") && detail.contains("colorize 0.8.1"),
"{detail}"
);
}
}

/// A bundler 2.2–2.5 lock (no CHECKSUMS) resolving `specs` from
/// rubygems.org, each `(name, version, deps)`, with `direct` in
/// DEPENDENCIES. Hosted mode only pins a version a lock resolves
/// (#1055, #1125); without CHECKSUMS it leaves the lock untouched.
fn gem_lock_resolving(specs: &[(&str, &str, &[&str])], direct: &[&str]) -> String {
let mut out = String::from("GEM\n remote: https://rubygems.org/\n specs:\n");
for (name, version, deps) in specs {
out.push_str(&format!(" {name} ({version})\n"));
for dep in *deps {
out.push_str(&format!(" {dep}\n"));
}
}
out.push_str("\nPLATFORMS\n ruby\n\nDEPENDENCIES\n");
for dep in direct {
out.push_str(&format!(" {dep}\n"));
}
out.push_str("\nBUNDLED WITH\n 2.5.23\n");
out
}

fn gem_override(name: &str, version: &str) -> DepOverride {
DepOverride {
ecosystem: "gem".into(),
Expand Down Expand Up @@ -14782,6 +14869,13 @@ mod tests {
"source \"https://rubygems.org\"\n\ngem \"rack-mini-profiler\", \"3.1.0\", require: false\n"
.to_string(),
);
files.insert(
"Gemfile.lock".to_string(),
gem_lock_resolving(
&[("rack-mini-profiler", "3.1.0", &[])],
&["rack-mini-profiler (= 3.1.0)"],
),
);
let r = rewrite_registry_redirect(&files, &[gem_override("rack-mini-profiler", "3.1.0")]);
let out = r.files.get("Gemfile").expect("Gemfile rewritten");
assert!(
Expand Down Expand Up @@ -14895,6 +14989,10 @@ mod tests {
gem \"rails\", \"7.0.0\"\n"
.to_string(),
);
files.insert(
"Gemfile.lock".to_string(),
gem_lock_resolving(&[("rails", "7.0.0", &[])], &["rails (= 7.0.0)"]),
);
let r = rewrite_registry_redirect(&files, &[gem_override("rails", "7.0.0")]);
let out = r.files.get("Gemfile").expect("Gemfile rewritten");
assert!(
Expand Down Expand Up @@ -14928,6 +15026,10 @@ mod tests {
"Gemfile".to_string(),
"source \"https://rubygems.org\"\n\ngem \"rails\", \"7.0.0\"\n".to_string(),
);
files.insert(
"Gemfile.lock".to_string(),
gem_lock_resolving(&[("rails", "7.0.0", &[])], &["rails (= 7.0.0)"]),
);
let first = rewrite_registry_redirect(&files, &[ov("tok-one")]);
let redirected = first.files.get("Gemfile").expect("first run rewrites");
files.insert("Gemfile".to_string(), redirected.clone());
Expand Down Expand Up @@ -14999,6 +15101,10 @@ mod tests {
"Gemfile".to_string(),
"source \"https://rubygems.org\"\n\ngem \"rails\", \"7.0.0\"\n".to_string(),
);
files.insert(
"Gemfile.lock".to_string(),
gem_lock_resolving(&[("rails", "7.0.0", &[])], &["rails (= 7.0.0)"]),
);
let first = rewrite_registry_redirect(&files, &[ov("tok-one")]);
files.insert(
"Gemfile".to_string(),
Expand Down Expand Up @@ -15070,6 +15176,10 @@ mod tests {
let mut files = BTreeMap::new();
files.insert("gems.rb".to_string(), gemfile.clone());
files.insert("Gemfile".to_string(), gemfile);
files.insert(
"gems.locked".to_string(),
gem_lock_resolving(&[("rails", "7.0.0", &[])], &["rails (= 7.0.0)"]),
);
let first = rewrite_registry_redirect(&files, &[ov("tok-one")]);
for (name, content) in first.files {
files.insert(name, content);
Expand Down Expand Up @@ -15116,6 +15226,10 @@ mod tests {
gem \"rails\", \"7.0.0\"\r\nend\r\n";
let mut files = BTreeMap::new();
files.insert("Gemfile".to_string(), crlf_gemfile.to_string());
files.insert(
"Gemfile.lock".to_string(),
gem_lock_resolving(&[("rails", "7.0.0", &[])], &["rails (= 7.0.0)"]),
);

// Same grant: recognized in place, a true no-op.
let same = rewrite_registry_redirect(&files, &[ov("tok-one")]);
Expand Down Expand Up @@ -15320,6 +15434,10 @@ mod tests {
#[test]
fn gemfile_source_option_refusal_prescribes_vendor_revert_for_own_wiring() {
let mut files = BTreeMap::new();
files.insert(
"Gemfile.lock".to_string(),
gem_lock_resolving(&[("rails", "7.0.0", &[])], &["rails (= 7.0.0)"]),
);
files.insert(
"Gemfile".to_string(),
"source \"https://rubygems.org\"\n\n\
Expand Down Expand Up @@ -15520,6 +15638,13 @@ mod tests {
gem\t\"puma\", \"6.0.0\"\n"
.to_string(),
);
files.insert(
"Gemfile.lock".to_string(),
gem_lock_resolving(
&[("puma", "6.0.0", &[]), ("rails", "7.0.0", &[])],
&["puma (= 6.0.0)", "rails (= 7.0.0)"],
),
);
let r = rewrite_registry_redirect(
&files,
&[
Expand Down Expand Up @@ -15557,6 +15682,10 @@ mod tests {
"Gemfile".to_string(),
"source \"https://rubygems.org\"\n\ngem\"rails\", \"7.0.0\"\n".to_string(),
);
files.insert(
"Gemfile.lock".to_string(),
gem_lock_resolving(&[("rails", "7.0.0", &[])], &["rails (= 7.0.0)"]),
);
let r = rewrite_registry_redirect(&files, &[gem_override("rails", "7.0.0")]);
assert!(
r.files.is_empty() && r.edits.is_empty(),
Expand Down Expand Up @@ -16648,6 +16777,10 @@ mod tests {
let mut files = BTreeMap::new();
files.insert("gems.rb".to_string(), gemfile.clone());
files.insert("Gemfile".to_string(), gemfile);
files.insert(
"gems.locked".to_string(),
gem_lock_resolving(&[("rails", "7.0.0", &[])], &["rails (= 7.0.0)"]),
);
let r = rewrite_registry_redirect(&files, &[gem_override("rails", "7.0.0")]);
assert!(
r.files.contains_key("gems.rb") && !r.files.contains_key("Gemfile"),
Expand Down Expand Up @@ -16697,6 +16830,10 @@ mod tests {
#[test]
fn gems_rb_divergence_only_in_redirected_dep_line_proceeds() {
let mut files = BTreeMap::new();
files.insert(
"gems.locked".to_string(),
gem_lock_resolving(&[("rails", "7.0.0", &[])], &["rails (= 7.0.0)"]),
);
files.insert(
"gems.rb".to_string(),
"source \"https://rubygems.org\"\n\ngem \"rails\", \"7.0.0\"\n".to_string(),
Expand Down Expand Up @@ -16783,6 +16920,10 @@ mod tests {
let mut files = BTreeMap::new();
files.insert("gems.rb".to_string(), gemfile.clone());
files.insert("Gemfile".to_string(), gemfile);
files.insert(
"gems.locked".to_string(),
gem_lock_resolving(&[("rails", "7.0.0", &[])], &["rails (= 7.0.0)"]),
);
let first = rewrite_registry_redirect(&files, &[ov("tok-one")]);
for (name, content) in first.files {
files.insert(name, content);
Expand Down Expand Up @@ -16827,6 +16968,16 @@ mod tests {
let mut files = BTreeMap::new();
files.insert("gems.rb".to_string(), gemfile.clone());
files.insert("Gemfile".to_string(), gemfile);
files.insert(
"gems.locked".to_string(),
gem_lock_resolving(
&[
("rack", "3.0.0", &["rails (>= 7)"]),
("rails", "7.0.0", &[]),
],
&["rack (= 3.0.0)"],
),
);
let ovr = gem_override("rails", "7.0.0");
let first = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr));
assert!(
Expand Down Expand Up @@ -16877,6 +17028,10 @@ mod tests {
// gems.rb exactly as run 1 wrote it, after a CRLF checkout; the
// Gemfile twin got the same CRLF treatment but never had the block.
let mut files = BTreeMap::new();
files.insert(
"gems.locked".to_string(),
gem_lock_resolving(&[("rails", "7.0.0", &[])], &["rails (= 7.0.0)"]),
);
files.insert(
"gems.rb".to_string(),
"source \"https://rubygems.org\"\r\n\r\n\
Expand Down Expand Up @@ -22687,6 +22842,10 @@ packages:
"Gemfile".to_string(),
"source \"https://rubygems.org\"\n\ngem(\"rails\",\n \"7.0.0\")\n".to_string(),
);
files.insert(
"Gemfile.lock".to_string(),
gem_lock_resolving(&[("rails", "7.0.0", &[])], &["rails (= 7.0.0)"]),
);
let r = rewrite_registry_redirect(&files, &[gem_override("rails", "7.0.0")]);
assert!(
r.files.is_empty() && r.edits.is_empty(),
Expand Down
13 changes: 12 additions & 1 deletion crates/socket-patch-core/src/patch/redirect/upstream/gem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1147,7 +1147,18 @@ mod tests {
}),
..ov.clone()
};
let files = BTreeMap::from([("Gemfile".to_string(), lf.to_string())]);
// A CHECKSUMS-less lock resolving both (hosted mode pins only a
// version a lock resolves, #1125) and left untouched by the rewrite.
let files = BTreeMap::from([
("Gemfile".to_string(), lf.to_string()),
(
"Gemfile.lock".to_string(),
"GEM\n remote: https://rubygems.org/\n specs:\n puma (6.0.0)\n \
rack (>= 3)\n rails (>= 7)\n rack (3.0.0)\n rails (7.0.0)\n\n\
PLATFORMS\n ruby\n\nDEPENDENCIES\n puma\n\nBUNDLED WITH\n 2.5.23\n"
.to_string(),
),
]);
let gemfile =
rewrite_registry_redirect(&files, &[ov.clone(), rack_ov]).files["Gemfile"].clone();
let rack = || Gem {
Expand Down
Loading