Repository navigation
Fix gem pins breaking frozen installs with gemspec dev deps (#985) - #1718
Conversation
Assisted-by: Claude Code:claude-opus-5-5
On Bundler 2.7+, a Gemfile `gemspec` directive folds the gemspec's development dependency on a gem into a later `gem` line for the same gem, and the lock records the union, e.g. `vuln-gem (>= 0, = 1.0.0)!`. Both the hosted and the vendored lock writers wrote the bare `(= 1.0.0)!`, so every frozen or deployment install of the committed pair failed (exit 16) while the scan reported success. Both writers now read the gemspec the directive loads and pin the requirement Bundler derives: the gemspec's requirements plus the exact version, sorted the way Bundler writes them. Bundler < 2.7 and a `gem` line before the directive keep the exact pin, as Bundler does. When the gemspec cannot be read, hosted mode keeps the exact pin and warns (`redirect_gem_gemspec_dependency_unreadable`); vendored mode refuses before writing anything. Fixes #985 Assisted-by: Claude Code:claude-opus-5-5
Real Bundler (2.7.2 and 4.0.18) rejects even its own lock under
frozen mode when a path gem's `gem` line follows a `gemspec`
directive whose gemspec declares the same gem as a development
dependency ("rack from `<path>` to `<path>`"). So no lock vendored
mode can write keeps its frozen-install promise in that shape.
Vendoring now refuses it before any write
(`gemfile_declaration_not_editable`) and names the fix: declare the
gem above `gemspec`, where Bundler keeps its own requirement and the
vendored pair installs frozen, or use hosted mode. Bundler < 2.7 and
a `gem` line before the directive vendor as before.
Adds real-Bundler e2e coverage for both modes: the hosted pair
installs frozen with the merged pin, and the vendored refusal and
its remedy behave as described. Documents both in CLI_CONTRACT.md.
Refs #985
Assisted-by: Claude Code:claude-opus-5-5
The hosted-to-vendored takeover asks the gem backend's refusals before it restores the hosted pin, so a gem vendored mode cannot wire keeps its hosted wiring. That preflight did not know about the new gemspec development dependency refusal: a takeover in that shape restored the gem upstream and then refused, leaving it unpatched in both modes. The preflight now runs the same check, so the takeover (and its dry-run preview) refuses with the hosted pin untouched. The refusal now names the remedy only when Bundler would merge the requirements; when the gemspec cannot be read it says so instead. Refs #985 Assisted-by: Claude Code:claude-opus-5-5
|
BugBot review Generated by Claude Code |
A gemspec development dependency spelled `%q(name)` or `%q[name]` with arguments the reader cannot parse was treated as absent rather than unreadable, so hosted mode wrote the bare exact pin without a warning and vendored mode skipped its refusal. Every bracket pair the literal reader accepts now counts as a mention. Refs #985 Assisted-by: Claude Code:claude-opus-5-5
|
BugBot review Generated by Claude Code |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit fb0db48. Configure here.
|
Labeled Ready for review by the burn-down agent.
Slack announcement could not be sent from this run (no Slack send tool available). Generated by Claude Code |
|
Labeled Ready for review by the burn-down agent.
Slack announcement not sent this run (no send tool available); the next run retries. Generated by Claude Code |
Final review briefWhat it does: On Bundler 2.7+, a Risk: low–medium. The change only applies when the lock says Look here:
Verified:
Changes I made: none. Open questions: none blocking. The follow-up named in the PR body is still open: pairs vendored by an earlier release in this shape aren't flagged. Auto-merge is armed, so approving sends this straight to the merge queue. Generated by Claude Code |
LLM Description written by Claude Code:claude-opus-5-5
Fixes #985
Summary
In a gem library, the
Gemfilehasgemspec, the.gemspecdeclaresadd_development_dependency "x", and theGemfilealso declaresgem "x"after the directive. In that shape, Bundler 2.7+ merges the two requirements into one Gemfile dependency and locks the union (x (>= 0, = 1.0.0)!). Both socket-patch lock writers wrote the barex (= 1.0.0)!. Every frozen or deployment install of the committed pair then failed with exit 16, and the scan still reported success.Root cause
Neither pin writer (hosted
formats/gem/hosted.rsconverge_gem_lock_source, vendoredvendor/gem.rsedit_lock) modelled Bundler 2.7'sDsl#add_dependencymerge of agemspecdevelopment dependency into a latergemline.The details, from Bundler's source and confirmed against real Bundler:
gemline follows the directive. If thegemline comes first, the Gemfile dependency wins unmerged.to_lockwrites the merged requirement list sorted, then reversed.Fix
formats/gem/gemspec.rs:locked_exact_requirementreturns the requirement Bundler locks for an exact pin. It checks the lock'sBUNDLED WITHfor 2.7+, looks forgemspecdirectives before the gem's declaration (honouringpath:andname:), finds the gemspec the way Bundler does ({,*}.gemspec), reads literaladd_development_dependencycalls (including%q<>,.freezeand array forms), and builds the union in Bundler's order. A gemspec it cannot read returns an error, never a guess.<socket-patch:gem-gemspecs>key), and the lock pin carries the merged requirement, so the pair installs frozen. If the gemspec can't be read, the run keeps the exact pin and warnsredirect_gem_gemspec_dependency_unreadable. Hosted unwind already restores a consistent pair: the restoredgem "x", "1.0.0"re-merges to the unpinnedx (>= 0, = 1.0.0).gemline follows the directive fails even the lock Bundler writes itself under frozen mode (* rack fromvend/rack-3.2.7tovend/rack-3.2.7``). Nothing this backend could write would keep vendored mode's frozen-install promise, so vendoring refuses before any write withgemfile_declaration_not_editableand names the remedy: move the `gem` line above `gemspec` (Bundler then keeps its own requirement, and the pair installs frozen), or use hosted mode. The issue's expected behaviour allows "refuse or warn".gem_vendor_target_preflight) runs the same check. A hosted→vendored takeover in this shape now refuses with the hosted pin untouched, in the dry-run preview too, instead of un-hosting the gem and then refusing.CLI_CONTRACT.mddocuments both modes.Test evidence
patch::redirect::tests::gem_pin_carries_the_gemspec_dev_dependency_bundler_merges(unit)left: " rails (= 7.0.0)!" right: " rails (>= 6, = 7.0.0)!"→ greene2e_redirect_gem_build::gem_hosted_gemspec_dev_dependency_frozen_install_accepts_the_pinlock must pin " vuln-gem (>= 0, = 1.0.0)!"→ green on Bundler 4.0.18, 2.7.2 and 2.6.9 (2.6 keeps the exact pin)vendor::gem::tests::gemspec_dev_dependency_merge_refuses_before_any_write(unit) ande2e_vendor_gem_build::gem_vendor_gemspec_dev_dependency_refuses_or_installs_frozen(real Bundler: refusal leaves files byte-identical; the remedy ordering installs frozen and reverts byte-identical)vendor must refuse→ green on 4.0.18, 2.7.2 and 2.6.9get/scan --mode vendored, wet and dry-run)get --mode vendored (dry_run=true)reportedverified→ greenformats::gem::gemspec::tests(directive options,%q<>/arrays,if/elseduplicates, unreadable/ambiguous gemspecs, Bundler version gate, CRLF)Commands run locally:
cargo fmt --all -- --check: okcargo clippy --workspace --all-features -- -D warnings: okcargo test -p socket-patch-core --lib -- gem: 379 passed--ignoredon Bundler 4.0.18, 2.7.2 and 2.6.9 (BUNDLER_VERSION+SOCKET_PATCH_BUNDLER_E2E_VERSION)cargo test --workspace --all-features --no-fail-fast(local, as root): 234 suites passed. 11 tests outside gem code failed for environmental reasons: 9 simulate write or remove failures with chmod, which root bypasses (partial_lockfile_write_failure_exits_1_and_writes_no_ledger,repair_cleanup_failure_is_reported_in_json_and_silent_modes,wire_write_failure_maps_error_and_leaves_lock_untouched, …), and 2e2e_bun_lockbtests need patches-api.socket.dev, which the sandbox blocks (tunnel error). CI runs them non-root and with network.No wrapper (
npm/,pypi/,gem/) changes are needed: they only dispatch to the binary.Bugbot
%q()/%q[]mentions of the gem in an unreadable call were treated as absent. Each now counts as a mention, with a regression test./code-review high findings
Fixed:
(>= 0, …)form.Not changed:
= vand skips the refusal." Bundler merges only whencurrent.requirement != dep.requirement(Dsl#add_dependency). Equal requirements are not merged: the Gemfile dependency is kept as-is, which is the shape that installs frozen.vendor --dry-rundoesn't preview the refusal." This matches every other Gemfile-plan refusal of the gem backend, which a plainvendor --dry-runalso doesn't preview. The hosted→vendored dry-run preview does show it now, through the preflight.vendor --revert, move thegemline abovegemspec, then re-vendor.https://claude.ai/code/session_017rTaUDxqff57aFGyTyecVD
Generated by Claude Code