Repository navigation
Hosted gem rollback / remove strip the DEPENDENCIES ! when the user's source block opener spans lines or uses braces, so frozen installs fail on Bundler 4.0.19+ #1509
Description
[agent] Found by the scheduled Bundler (RubyGems) bug-hunt routine (ledger #316).
Summary
This is a #1056 / #1476 neighbour that the #1478 fix doesn't cover. The hosted gem unwind decides whether the restored gem sits inside the user's own source block with in_source_block → block_stack (crates/socket-patch-core/src/patch/redirect/upstream/gem.rs:545, :565). That scanner only recognises a source block when one physical line both starts with source and holds the do (gem.rs:609, source_do needs !continues). It also never counts { … } blocks at all. Two valid Bundler spellings therefore lose their ! pin on rollback / remove:
- A multi-line opener, for example a URL that doesn't fit on one line:
source( "https://gems.example.com" ) do gem "vuln-gem", "1.0.0" end
- A brace block:
source("https://gems.example.com") { gem "vuln-gem", "1.0.0" }
Bundler locks a gem declared in either form as vuln-gem (= 1.0.0)!, just as it does for the one-line source "…" do. The test's own premise assertion confirms it on 2.6.9 and 4.0.22.
Impact
- After a hosted
scanfollowed byrollbackorremove, the restoredGemfile.lockhasvuln-gem (= 1.0.0)without the!, so the lock isn't byte-restored. - On Bundler 4.0.19+, every frozen / deployment install of the restored pair fails with exit 16: "Your lockfile needs to be updated, but it can't be because frozen mode is set". Bundler 2.6.9 and 4.0.18 only warn.
- For the brace form, the Gemfile also isn't restored byte-for-byte. The gem comes back at column 0 after an inserted blank line, because the indent is derived from the same block stack:
source("http://127.0.0.1:41955/upstream") { gem "vuln-gem", "1.0.0" }
Repro
This uses the repo's own harness. Apply this scratch change to crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs: in redirect_scanned_project, add a Driver::ScanVexSourceBlockAssignment arm (gated on an env var) that writes one of these Gemfiles instead:
// paren multi-line opener
format!("source \"{up}/upstream\"\n\nsource(\n \"{up}/upstream\"\n) do\n gem \"{DEP}\", \"{DEP_VERSION}\"\nend\n")
// brace block
format!("source \"{up}/upstream\"\n\nsource(\"{up}/upstream\") {{\n gem \"{DEP}\", \"{DEP_VERSION}\"\n}}\n")Then run:
gem install bundler -v 4.0.22
BUNDLER_VERSION=4.0.22 SOCKET_PATCH_BUNDLER_E2E_VERSION=4.0.22 SOCKET_PATCH_BUNDLER_E2E_REQUIRED=1 \
cargo test -p socket-patch-cli --test e2e_redirect_gem_build gem_hosted_unwind_keeps_a_source_block_bang -- --include-ignored --nocaptureThe test fails at rollback: the lock comes back byte for byte, ! included:
left: "...DEPENDENCIES\n vuln-gem (= 1.0.0)\n\nBUNDLED WITH\n 4.0.22\n"
right: "...DEPENDENCIES\n vuln-gem (= 1.0.0)!\n\nBUNDLED WITH\n 4.0.22\n"
With the byte assertions turned into prints, the frozen install of the restored pair on a fresh checkout exits 16 for both rollback and remove.
For a direct check of the scanner, in_source_block(text, text.find("gem \"rails\"").unwrap()) returns false for both texts above. It returns true for the one-line source "…" do spelling.
Expected vs actual
- Expected: CLI_CONTRACT.md / docs/ecosystems.md "Hosted unwind coverage" and the Hosted gem
rollback/removestrips theDEPENDENCIES!of a gem the user declared inside asource "https://rubygems.org" doblock, so every frozen install fails after the unwind #1056 fix promise that a gem declared inside the user's ownsourceblock keeps its!source pin on unwind, so the restored pair installs frozen. The Fix gem source-block bang lost after x = if … end (#1476) #1478 commit message describes the scanner as counting constructs "word by word wherever they start an expression". - Actual: a multi-line
source(...) doopener or asource(...) { }block isn't seen as a source block. The!is dropped, the lock isn't restored, and Bundler 4.0.19+ frozen installs fail. The scan itself accepts both shapes, so nothing refuses them up front.
Matrix (Linux, Ruby 3.3.6, main aa85c6c)
| Shape | Bundler 4.0.22 rollback | 4.0.22 remove | 2.6.9 rollback | 2.6.9 remove |
|---|---|---|---|---|
Paren multi-line opener source(\n "…"\n) do |
! dropped, frozen exit 16 (×2) |
! dropped, frozen exit 16 |
! dropped, frozen warns / exit 0 |
! dropped, frozen exit 0 |
Brace block source("…") { |
! dropped, frozen exit 16, Gemfile not byte-restored (×3) |
same | ! dropped, exit 0, Gemfile not byte-restored |
same |
One-line source "…" do (control) |
pass | pass | pass | pass |
macOS and Windows weren't probed, but the scanner is OS-independent string logic.
First bad
Not bisected. These shapes were never recognised: source-block detection arrived with the #1056 fix, and #1478 extended it only to keyword constructs.
Suspect code
crates/socket-patch-core/src/patch/redirect/upstream/gem.rs:609:source_dois only set when the line isn't a continuation, and thedomust sit on that same line.crates/socket-patch-core/src/patch/redirect/upstream/gem.rs:620-650:{/}never open or close a frame, so a brace block (source or otherwise) is invisible.
A fail-closed fallback, refusing the unwind when the gem's original line sits under a source opener the scanner can't model, would also avoid the silent ! loss.
Activity
mikolalysenko commentedon Oct 14, 2026
[agent] Claiming this issue (shared root cause: the hosted gem unwind's source-block scanner only recognises a one-line
source … doopener and ignores brace blocks). Branch: agent/fix-gem-source-block-scanner. Claim-ID: 2026-10-14T04:20:38Z-016237Generated by Claude Code
mikolalysenko commentedon Oct 14, 2026
[agent] Draft PR: #1520
Generated by Claude Code
Start fix for #1509
Keep gem source pins for multi-line and {} blocks
Tighten gem source-block brace and paren rules
Keep keyword blocks inside brackets in gem scan