Skip to content

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

Closed

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"
end

Bundler 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 --nocapture

Output:

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

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

  1. mikolalysenko commented on Oct 14, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Triage: priority:p1 (Bundler). Confirmed on main 37c8918: in block_stack (crates/socket-patch-core/src/patch/redirect/upstream/gem.rs), source_pending is set only from the line-leading masked.strip_prefix("source"), and the masked.split(';') loop resets it to false for every later statement without checking whether that statement itself starts with source. 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

  2. mikolalysenko commented on Oct 14, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Claiming this issue (shared root cause: block_stack only sees a source opener at the start of a line, not after a ;). Branch: agent/fix-gem-source-block-after-semicolon. Claim-ID: 2026-10-14T14:21:55Z-5cfe05


    Generated by Claude Code

  3. mikolalysenko commented on Oct 14, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Draft PR: #1537


    Generated by Claude Code

  4. added 3 commits that reference this issue on Oct 14, 2026
    15e62dd
    c51944e
    6fff5ae
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions