Skip to content

Commit 94270b4

Browse files
committed
Stop Gemfile rewrites declaring a gem twice
Hosted and vendored modes could leave a Gemfile that Bundler refuses on every install ("You cannot specify the same gem twice"): - Hosted mode rewrote only the first of several declarations of a gem (for example one in each of two `group` blocks), leaving an exact pin next to the original requirement (#548). It now refuses with redirect_gem_declared_more_than_once, as vendored mode already did. - Both modes treated a gem they could not see declared in the root Gemfile as transitive and appended a second declaration, even when the lock lists it under DEPENDENCIES because the Gemfile declares it through eval_gemfile or a loop (#482). Both now refuse instead. Fixes #482 Fixes #548 Assisted-by: Claude Code:claude-opus-5-5
1 parent 44275bd commit 94270b4

3 files changed

Lines changed: 213 additions & 11 deletions

File tree

‎crates/socket-patch-core/src/formats/gem/mod.rs‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,10 @@ pub(crate) struct GemfileLock<'t> {
114114
pub(crate) checksums: Option<HashMap<(&'t str, &'t str), Option<String>>>,
115115
/// `DEPENDENCIES` entries bundler marks source-pinned (`name …!`).
116116
pub(crate) pinned: BTreeSet<&'t str>,
117+
/// Every `DEPENDENCIES` entry: the gems bundler treats as DIRECT
118+
/// dependencies, however the Gemfile declares them (`eval_gemfile`, a
119+
/// loop, a `gemspec` development dependency, a plain `gem` line).
120+
pub(crate) direct: BTreeSet<&'t str>,
117121
/// Why bundler would refuse this lock, first problem first.
118122
pub(crate) problems: Vec<String>,
119123
}
@@ -222,6 +226,7 @@ pub(crate) fn parse(text: &str) -> GemfileLock<'_> {
222226
let mut in_checksums = false;
223227
let mut in_dependencies = false;
224228
let mut pinned: BTreeSet<&str> = BTreeSet::new();
229+
let mut direct: BTreeSet<&str> = BTreeSet::new();
225230
let mut seen_header = false;
226231
let mut bundler_shaped = false;
227232

@@ -281,6 +286,10 @@ pub(crate) fn parse(text: &str) -> GemfileLock<'_> {
281286
_ => {}
282287
}
283288
} else if in_dependencies && indent == 2 {
289+
let name = trimmed.split([' ', '(', '!']).next().unwrap_or_default();
290+
if !name.is_empty() {
291+
direct.insert(name);
292+
}
284293
if let Some(entry) = trimmed.strip_suffix('!') {
285294
let name = entry.split([' ', '(']).next().unwrap_or_default();
286295
if !name.is_empty() {
@@ -306,10 +315,21 @@ pub(crate) fn parse(text: &str) -> GemfileLock<'_> {
306315
sections,
307316
checksums,
308317
pinned,
318+
direct,
309319
problems,
310320
}
311321
}
312322

323+
/// Whether the Bundler lock `lock` lists `name` under `DEPENDENCIES`, i.e.
324+
/// bundler resolved it as a DIRECT dependency of the Gemfile. The Gemfile
325+
/// rewriters consult it before treating a gem they cannot see declared as
326+
/// transitive: appending a declaration for a gem the Gemfile already
327+
/// declares out of sight (`eval_gemfile`, a loop) leaves it declared twice,
328+
/// and bundler refuses every install (#482).
329+
pub(crate) fn lock_lists_direct_dependency(lock: &str, name: &str) -> bool {
330+
parse(lock).direct.contains(name)
331+
}
332+
313333
/// The plain gem-token charset (letters, digits, `.`, `_`, `-`). The vendor
314334
/// backend applies it before embedding coordinates into Ruby source and lock
315335
/// line grammar (see the SECURITY note in `crate::vendor::gem`'s

‎crates/socket-patch-core/src/patch/redirect/mod.rs‎

Lines changed: 137 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ use crate::formats::pnpm::plan_hosted;
5151
use crate::formats::cargo::CargoLock;
5252
use crate::formats::composer::hosted::rewrite_composer_lock;
5353
use crate::formats::gem::hosted::{checksum_entry_span, converge_gem_lock_source};
54+
use crate::formats::gem::lock_lists_direct_dependency;
5455
pub(crate) use crate::formats::yarn::is_berry_lock;
5556
use crate::formats::cargo::hosted::CargoLockPlan;
5657
#[cfg(test)]
@@ -4945,6 +4946,31 @@ fn rewrite_gem(
49454946
// written or already present) — the lock pin below is gated on it.
49464947
let mut source_placed = false;
49474948
if let Some(gf) = gemfile.as_mut() {
4949+
// Looser "declared at all?" probe: counts every `gem` call that
4950+
// names the gem, in any form (indented in a group, parenthesized,
4951+
// our own source block). It gates the append branch (appending
4952+
// next to a declaration the recognizer below cannot parse would
4953+
// leave the gem declared twice) and catches a gem declared more
4954+
// than once: rewriting only one of them leaves `= x.y.z` next to
4955+
// the other requirement, and bundler refuses the Gemfile (#548).
4956+
let declared_re = Regex::new(
4957+
&(String::from(r#"(?m)^[ \t]*gem\b[^\n]*["']"#)
4958+
+ &regex::escape(&dep.name)
4959+
+ r#"["']"#),
4960+
)
4961+
.expect("declaration probe regex from the escaped gem name is valid");
4962+
if declared_re.find_iter(gf).nth(1).is_some() {
4963+
result.warnings.push(RewriteWarning {
4964+
code: "redirect_gem_declared_more_than_once".into(),
4965+
detail: format!(
4966+
"`gem \"{}\"` is declared more than once in {gemfile_name}; \
4967+
rewriting one declaration would leave conflicting requirements \
4968+
bundler refuses — merge them into one declaration and re-run",
4969+
dep.name
4970+
),
4971+
});
4972+
continue;
4973+
}
49484974
// Grant-agnostic idempotency guard: the grant-token (and patch
49494975
// uuid) segments of the index URL rotate per request, so an
49504976
// exact-URL check misses the block a previous run wrote and this
@@ -4994,16 +5020,6 @@ fn rewrite_gem(
49945020
+ r#"["']([^\n]*)$"#),
49955021
)
49965022
.expect("gem-line regex from the escaped gem name is valid");
4997-
// Looser "declared at all?" probe: gates the append branch —
4998-
// appending next to a declaration the recognizer above cannot
4999-
// parse would leave the gem declared twice (bundler
5000-
// hard-fails on the duplicate).
5001-
let declared_re = Regex::new(
5002-
&(String::from(r#"(?m)^[ \t]*gem\b[^\n]*["']"#)
5003-
+ &regex::escape(&dep.name)
5004-
+ r#"["']"#),
5005-
)
5006-
.expect("declaration probe regex from the escaped gem name is valid");
50075023
if let Some(m) = gem_line_re.captures(gf) {
50085024
let range = m.get(0).expect("group 0 is the whole match").range();
50095025
let original = m
@@ -5116,6 +5132,26 @@ fn rewrite_gem(
51165132
),
51175133
});
51185134
continue;
5135+
} else if files
5136+
.get(lock_name)
5137+
.is_some_and(|lk| lock_lists_direct_dependency(lk, &dep.name))
5138+
{
5139+
// Not declared where the rewriter can see it, yet bundler
5140+
// resolved it as a DIRECT dependency: the Gemfile declares
5141+
// it out of sight (`eval_gemfile`, a loop, a gemspec).
5142+
// Appending a block would declare it twice (#482).
5143+
result.warnings.push(RewriteWarning {
5144+
code: "redirect_gem_declaration_not_visible".into(),
5145+
detail: format!(
5146+
"{lock_name} lists {} as a direct dependency, but {gemfile_name} \
5147+
declares it somewhere the rewriter cannot edit (an \
5148+
`eval_gemfile`d file, a loop, a gemspec); appending a second \
5149+
declaration would make bundler refuse the Gemfile — redirect \
5150+
skipped",
5151+
dep.name
5152+
),
5153+
});
5154+
continue;
51195155
} else {
51205156
// Genuinely undeclared (a transitive dep): append a block.
51215157
let block = format!(
@@ -11042,6 +11078,97 @@ mod tests {
1104211078
);
1104311079
}
1104411080

11081+
/// #548: bundler accepts a gem declared more than once with the same
11082+
/// requirement (two `group` blocks, or top level plus a group).
11083+
/// Rewriting only the first declaration leaves `= 1.0.0` next to
11084+
/// `>= 0`, which bundler refuses on every install. Fail closed before
11085+
/// any write, like vendored mode's `gemfile_declaration_not_editable`.
11086+
#[test]
11087+
fn gemfile_gem_declared_twice_fails_closed() {
11088+
let lock = "GEM\n remote: https://rubygems.org/\n specs:\n vuln-gem (1.0.0)\n\n\
11089+
PLATFORMS\n ruby\n\nDEPENDENCIES\n vuln-gem\n\n\
11090+
CHECKSUMS\n vuln-gem (1.0.0) sha256="
11091+
.to_string()
11092+
+ &"2".repeat(64)
11093+
+ "\n\nBUNDLED WITH\n 4.0.17\n";
11094+
for gemfile in [
11095+
"source \"https://rubygems.org\"\n\ngroup :development do\n gem \"vuln-gem\"\nend\n\n\
11096+
group :test do\n gem \"vuln-gem\"\nend\n",
11097+
"source \"https://rubygems.org\"\n\ngem \"vuln-gem\"\n\n\
11098+
group :test do\n gem \"vuln-gem\"\nend\n",
11099+
"source \"https://rubygems.org\"\n\ngem \"vuln-gem\"\ngem(\"vuln-gem\")\n",
11100+
] {
11101+
let mut files = BTreeMap::new();
11102+
files.insert("Gemfile".to_string(), gemfile.to_string());
11103+
files.insert("Gemfile.lock".to_string(), lock.clone());
11104+
let r = rewrite_registry_redirect(&files, &[gem_override("vuln-gem", "1.0.0")]);
11105+
assert!(
11106+
r.files.is_empty() && r.edits.is_empty(),
11107+
"a gem declared twice must not be half-rewritten: {gemfile}\nfiles={:?} edits={:?}",
11108+
r.files,
11109+
r.edits
11110+
);
11111+
assert_eq!(
11112+
warning_codes(&r),
11113+
vec!["redirect_gem_declared_more_than_once"],
11114+
"{gemfile}: {:?}",
11115+
r.warnings
11116+
);
11117+
}
11118+
}
11119+
11120+
/// #482: a DIRECT dependency the root Gemfile declares out of the
11121+
/// rewriter's sight (`eval_gemfile`, a loop) is listed under the lock's
11122+
/// DEPENDENCIES. Appending a source block for it declares it twice and
11123+
/// bundler refuses every install, so fail closed instead.
11124+
#[test]
11125+
fn gemfile_direct_dependency_declared_out_of_sight_is_not_appended() {
11126+
let lock = "GEM\n remote: https://rubygems.org/\n specs:\n rack (3.1.8)\n\n\
11127+
PLATFORMS\n ruby\n\nDEPENDENCIES\n rack (~> 3.1)\n\n\
11128+
BUNDLED WITH\n 4.0.17\n";
11129+
for gemfile in [
11130+
"source \"https://rubygems.org\"\neval_gemfile \"Gemfile.common\"\n",
11131+
"source \"https://rubygems.org\"\n%w[rack].each { |g| gem g, \"~> 3.1\" }\n",
11132+
] {
11133+
let mut files = BTreeMap::new();
11134+
files.insert("Gemfile".to_string(), gemfile.to_string());
11135+
files.insert("Gemfile.lock".to_string(), lock.to_string());
11136+
let r = rewrite_registry_redirect(&files, &[gem_override("rack", "3.1.8")]);
11137+
assert!(
11138+
r.files.is_empty() && r.edits.is_empty(),
11139+
"a direct dep declared out of sight must not be appended: {gemfile}\n\
11140+
files={:?} edits={:?}",
11141+
r.files,
11142+
r.edits
11143+
);
11144+
assert_eq!(
11145+
warning_codes(&r),
11146+
vec!["redirect_gem_declaration_not_visible"],
11147+
"{gemfile}: {:?}",
11148+
r.warnings
11149+
);
11150+
}
11151+
// Control: a genuinely transitive gem (absent from DEPENDENCIES) is
11152+
// still appended.
11153+
let mut files = BTreeMap::new();
11154+
files.insert(
11155+
"Gemfile".to_string(),
11156+
"source \"https://rubygems.org\"\ngem \"rails\"\n".to_string(),
11157+
);
11158+
files.insert(
11159+
"Gemfile.lock".to_string(),
11160+
lock.replace("DEPENDENCIES\n rack (~> 3.1)", "DEPENDENCIES\n rails"),
11161+
);
11162+
let r = rewrite_registry_redirect(&files, &[gem_override("rack", "3.1.8")]);
11163+
let out = r.files.get("Gemfile").expect("transitive gem appended");
11164+
assert!(
11165+
out.ends_with(
11166+
"source \"https://patch.test/gem/tok/uuid/\" do\n gem \"rack\", \"3.1.8\"\nend\n"
11167+
),
11168+
"{out}"
11169+
);
11170+
}
11171+
1104511172
/// The CHECKSUMS pin is gated on the Gemfile source redirect being in
1104611173
/// place: with no Gemfile in the candidate map, pinning the patched sha
1104711174
/// while the gem still resolves upstream guarantees a checksum failure.

‎crates/socket-patch-core/src/vendor/gem.rs‎

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,9 @@ fn gem_edits(
415415
) -> Result<(GemfilePlan, LockEdit), Box<VendorOutcome>> {
416416
let (name, version) = (prelude.name.as_str(), prelude.version.as_str());
417417
// ── Gemfile edit plan (refusals before any write) ────────────────────
418-
let plan = match plan_gemfile_edit(&prelude.gemfile_text, name, version, &prelude.copy_rel) {
418+
let plan = match plan_gemfile_edit(&prelude.gemfile_text, name, version, &prelude.copy_rel)
419+
.and_then(|plan| refuse_append_of_direct_dependency(plan, &prelude.lock_text, name))
420+
{
419421
Ok(p) => p,
420422
Err(detail) => {
421423
return Err(Box::new(refused(
@@ -1428,6 +1430,29 @@ fn plan_gemfile_edit(
14281430
})
14291431
}
14301432

1433+
/// Refuse an [`GemfilePlan::Append`] for a gem the lock lists under
1434+
/// `DEPENDENCIES`: bundler resolved it as a DIRECT dependency, so the Gemfile
1435+
/// declares it somewhere the line grammar cannot see (`eval_gemfile`, a
1436+
/// loop, a gemspec). The managed block would declare it a second time and
1437+
/// bundler refuses every install (#482). Every other plan passes through.
1438+
fn refuse_append_of_direct_dependency(
1439+
plan: GemfilePlan,
1440+
lock_text: &str,
1441+
name: &str,
1442+
) -> Result<GemfilePlan, String> {
1443+
if matches!(plan, GemfilePlan::Append { .. })
1444+
&& crate::formats::gem::lock_lists_direct_dependency(lock_text, name)
1445+
{
1446+
return Err(format!(
1447+
"Gemfile.lock lists \"{name}\" as a direct dependency, but the Gemfile declares \
1448+
it somewhere the line grammar cannot edit (an `eval_gemfile`d file, a loop, a \
1449+
gemspec); refusing to append a second declaration (bundler hard-fails on \
1450+
duplicates)"
1451+
));
1452+
}
1453+
Ok(plan)
1454+
}
1455+
14311456
/// Looser "declared at all?" probe — the redirect rewriter's `declared_re`
14321457
/// twin. True when a non-comment line is a `gem` call (the keyword followed
14331458
/// by anything but an identifier character) whose arguments quote the exact
@@ -5488,6 +5513,36 @@ mod tests {
54885513
);
54895514
}
54905515

5516+
/// #482: a DIRECT dependency declared where the line grammar cannot see
5517+
/// it (`eval_gemfile`, a loop) is listed under the lock's DEPENDENCIES.
5518+
/// Appending the managed block would declare it twice and bundler
5519+
/// refuses every install, so the plan must refuse before any write.
5520+
#[tokio::test]
5521+
async fn direct_dependency_declared_out_of_sight_refuses_instead_of_appending() {
5522+
for gemfile in [
5523+
"source \"https://rubygems.org\"\n\ngem \"puma\"\neval_gemfile \"Gemfile.common\"\n",
5524+
"source \"https://rubygems.org\"\n\ngem \"puma\"\n%w[rack].each { |g| gem g, \"~> 3.1\" }\n",
5525+
] {
5526+
let (_tmp, root, installed, blobs, record) = fixture(gemfile, LOCK_DIRECT).await;
5527+
let (code, detail) =
5528+
unwrap_refused(run_vendor(&root, &blobs, &installed, &record, false).await);
5529+
assert_eq!(code, "gemfile_declaration_not_editable", "{gemfile}");
5530+
assert!(detail.contains("direct dependency"), "{detail}");
5531+
assert!(!root.join(".socket").exists());
5532+
assert_eq!(
5533+
tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(),
5534+
gemfile,
5535+
"refusal must write nothing: {detail}"
5536+
);
5537+
assert_eq!(
5538+
tokio::fs::read_to_string(root.join(GEMFILE_LOCK))
5539+
.await
5540+
.unwrap(),
5541+
LOCK_DIRECT
5542+
);
5543+
}
5544+
}
5545+
54915546
/// Re-vendor (new uuid) over a lock whose CHECKSUMS entry was ALREADY
54925547
/// bare pre-vendor: the first run recorded no checksum wiring, so the
54935548
/// re-vendor's `original: None` checksum record has nothing to

0 commit comments

Comments
 (0)