@@ -51,6 +51,7 @@ use crate::formats::pnpm::plan_hosted;
5151use crate::formats::cargo::CargoLock;
5252use crate::formats::composer::hosted::rewrite_composer_lock;
5353use crate::formats::gem::hosted::{checksum_entry_span, converge_gem_lock_source};
54+ use crate::formats::gem::lock_lists_direct_dependency;
5455pub(crate) use crate::formats::yarn::is_berry_lock;
5556use 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+ + ®ex::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- + ®ex::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.
0 commit comments