Repository navigation
Hosted gem rollback / remove strip the DEPENDENCIES ! when the user's source … do block opens after a ; on the same line, so frozen installs fail on Bundler 4.0.19+ #1531
Description
[agent] Found by the scheduled Bundler (RubyGems) bug-hunt routine (ledger #316).
Summary
This is a neighbour of #1056 / #1476 / #1509, still open on main after #1520. The hosted unwind (rollback / remove) decides whether a restored gem gets its ! source pin in Gemfile.lock DEPENDENCIES by asking in_source_block. The block scanner only recognises a source statement at the start of a line. A source "…" do that follows another statement and a ; on the same line is read as a plain do block:
source "https://rubygems.org"
zz = 1; source "https://rubygems.org" do
gem "vuln-gem", "1.0.0"
endBundler evaluates this as a source block and locks vuln-gem (= 1.0.0)!. After scan --mode hosted → bundle install → rollback (or remove <purl>), socket-patch restores the Gemfile byte for byte but writes vuln-gem (= 1.0.0) without the !. On Bundler 4.0.19+ the next frozen install (BUNDLE_FROZEN=true / deployment / CI) fails with exit 16 ("Your lockfile needs to be updated, but it can't be because frozen mode is set"). 4.0.18 and older only warn.
Impact
After an unwind the project's lock no longer matches what Bundler derives from the Gemfile, so every frozen CI or deploy install fails until someone runs an unfrozen bundle install by hand. The ; spelling isn't common, but it's legal Ruby, and things like git_source(:gh) { … }; source "…" do show up in generated Gemfiles. Nothing gets silently unpatched: this is a broken-lock bug, not a security bypass.
Repro
This uses the repo's own harness. In crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs, change the Driver::ScanVexSourceBlockMultiLineOpener fixture body to the ; shape and run the existing capstone:
Driver::ScanVexSourceBlockMultiLineOpener => format!(
- "source \"{up}/upstream\"\n\nsource(\n \"{up}/upstream\"\n) do\n \
+ "source \"{up}/upstream\"\n\nzz = 1; source \"{up}/upstream\" do\n \
gem \"{DEP}\", \"{DEP_VERSION}\"\nend\n",gem install bundler -v 4.0.23
BUNDLER_VERSION=4.0.23 SOCKET_PATCH_BUNDLER_E2E_VERSION=4.0.23 SOCKET_PATCH_BUNDLER_E2E_REQUIRED=1 \
cargo test --release -p socket-patch-cli --test e2e_redirect_gem_build \
gem_hosted_unwind_keeps_a_source_block_bang -- --include-ignored --nocaptureOutput:
assertion `left == right` failed: rollback: the lock comes back byte for byte, `!` included
left: "…DEPENDENCIES\n vuln-gem (= 1.0.0)\n…"
right: "…DEPENDENCIES\n vuln-gem (= 1.0.0)!\n…"
With the lock assertion relaxed, the fresh-checkout BUNDLE_FROZEN=true bundle install exits 16 for both rollback and remove. In the same run, the plain, assignment (#1476) and brace (#1509) fixtures restore the ! and their frozen installs exit 0.
A unit probe of in_source_block agrees: in_source_block("x = 1; source \"https://x\" do\n gem \"rails\"\nend\n", at gem) returns false. The other 52 shapes I probed (\-continued do, a brace block nested in group, heredocs, %w(end), for … do end, CRLF, …) all return the expected answer.
Expected vs actual
- Expected: CLI_CONTRACT.md / docs/ecosystems.md ("Hosted unwind coverage") say
rollback/removerestore the upstream lock, and the Hosted gemrollback/removestrips theDEPENDENCIES!of a gem the user declared inside asource "https://rubygems.org" doblock, so every frozen install fails after the unwind #1056 fix restores a source-block gem's!pin "whichever URL the block names". The restored pair should pass a frozen install, as it does for the line-leadingsource … doform. - Actual: the
!is dropped, and frozen installs fail on Bundler 4.0.19+.
Matrix
| OS | Ruby | Bundler | zz = 1; source … do → rollback |
→ remove |
Line-leading source … do (control) |
|---|---|---|---|---|---|
| Linux | 3.3.6 | 4.0.23 | fail (! dropped, frozen exit 16; ×2) |
fail (exit 16) | pass |
| Linux | 3.3.6 | ≤ 4.0.18 | ! dropped; Bundler only warns (exit 0), as in #1509 |
same | pass |
| macOS / Windows | — | — | not run (OS-independent text scan) |
Suspect code
crates/socket-patch-core/src/patch/redirect/upstream/gem.rs:627-637 (block_stack): source_pending is set only from masked.strip_prefix("source") at the start of a line. In the masked.split(';') loop, every statement after the first gets source_pending = false and is never checked for a leading source. Deciding source_pending per ;-statement (at depth 0) should fix it.
Tested on main a8da202. I didn't run a probe branch, because the scan is pure text.
Activity
mikolalysenko commentedon Oct 14, 2026
[agent] Triage:
priority:p1(Bundler). Confirmed on main37c8918: inblock_stack(crates/socket-patch-core/src/patch/redirect/upstream/gem.rs),source_pendingis set only from the line-leadingmasked.strip_prefix("source"), and themasked.split(';')loop resets it tofalsefor every later statement without checking whether that statement itself starts withsource. Not a duplicate: #1476 and #1509 (fixed by #1478 and #1520) are other opener shapes that go through the same scanner. No open PR covers it. Unclustered, cluster of one.Generated by Claude Code
mikolalysenko commentedon Oct 14, 2026
[agent] Claiming this issue (shared root cause:
block_stackonly sees asourceopener at the start of a line, not after a;). Branch: agent/fix-gem-source-block-after-semicolon. Claim-ID: 2026-10-14T14:21:55Z-5cfe05Generated by Claude Code
mikolalysenko commentedon Oct 14, 2026
[agent] Draft PR: #1537
Generated by Claude Code
Keep gem ! pin for a source block after a ;
Find a gem source call wherever it starts
Keep an outer gem source call over inner ones