From 00e189e851fb334add609204e5836790e12334f0 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Sat, 11 Jul 2026 05:59:11 +0900 Subject: [PATCH] Unquote Gem.ruby when spawning it as a separate argv element Gem.ruby wraps the interpreter path in double quotes when it contains whitespace. That suits single-string shell commands but breaks the multi-argument spawns that pass it as its own argv element. Under an install prefix containing a space, `gem update --system`, mkrf extension builds, `gem push` attestation, and the bundler auto-switch all fail to spawn with exit 127. Split it with Shellwords, matching the existing `Gem::Ext::Builder.ruby` idiom. Co-Authored-By: Claude Opus 4.8 --- lib/bundler/self_manager.rb | 7 ++++- lib/rubygems/commands/push_command.rb | 5 +++- lib/rubygems/commands/update_command.rb | 5 +++- lib/rubygems/ext/rake_builder.rb | 4 ++- spec/bundler/self_manager_spec.rb | 29 ++++++++++++++++++ .../test_gem_commands_push_command.rb | 30 +++++++++++++++++++ .../test_gem_commands_update_command.rb | 30 +++++++++++++++++++ test/rubygems/test_gem_ext_rake_builder.rb | 18 +++++++++++ 8 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 spec/bundler/self_manager_spec.rb diff --git a/lib/bundler/self_manager.rb b/lib/bundler/self_manager.rb index 82efbf56a4fb..4e6156ffa2f5 100644 --- a/lib/bundler/self_manager.rb +++ b/lib/bundler/self_manager.rb @@ -75,7 +75,12 @@ def restart_with(version) argv0 = File.exist?($PROGRAM_NAME) ? $PROGRAM_NAME : Process.argv0 cmd = [argv0, *ARGV] - cmd.unshift(Gem.ruby) unless File.executable?(argv0) + unless File.executable?(argv0) + # Gem.ruby is quoted if it contains whitespace, so split it into argv + # elements to keep the quotes out of the exec'd command. + require "shellwords" + cmd.unshift(*Shellwords.split(Gem.ruby)) + end Bundler.with_original_env do Kernel.exec( diff --git a/lib/rubygems/commands/push_command.rb b/lib/rubygems/commands/push_command.rb index 02931b30252a..78fb844eb963 100644 --- a/lib/rubygems/commands/push_command.rb +++ b/lib/rubygems/commands/push_command.rb @@ -147,6 +147,7 @@ def send_push_request_with_attestation(name, args) def attest!(name) require "open3" + require "shellwords" require "tempfile" tempfile = Tempfile.new([File.basename(name, ".*"), ".sigstore.json"]) @@ -154,9 +155,11 @@ def attest!(name) tempfile.close(false) env = defined?(Bundler.unbundled_env) ? Bundler.unbundled_env : ENV.to_h + # Gem.ruby is quoted if it contains whitespace, so split it into argv + # elements to keep the quotes out of the spawned command. out, st = Open3.capture2e( env, - Gem.ruby, "-S", "gem", "exec", "--conservative", + *Shellwords.split(Gem.ruby), "-S", "gem", "exec", "--conservative", "sigstore-cli", "sign", name, "--bundle", bundle, unsetenv_others: true ) diff --git a/lib/rubygems/commands/update_command.rb b/lib/rubygems/commands/update_command.rb index d9740d814a2e..cb0a2660dde4 100644 --- a/lib/rubygems/commands/update_command.rb +++ b/lib/rubygems/commands/update_command.rb @@ -183,7 +183,10 @@ def install_rubygems(spec) # :nodoc: say "Installing RubyGems #{version}" unless options[:silent] installed = preparing_gem_layout_for(version) do - system Gem.ruby, "--disable-gems", "setup.rb", *args + # Gem.ruby is quoted if it contains whitespace, so split it into argv + # elements to keep the quotes out of the spawned command. + require "shellwords" + system(*Shellwords.split(Gem.ruby), "--disable-gems", "setup.rb", *args) end unless options[:silent] diff --git a/lib/rubygems/ext/rake_builder.rb b/lib/rubygems/ext/rake_builder.rb index d702d7f33937..599580697b53 100644 --- a/lib/rubygems/ext/rake_builder.rb +++ b/lib/rubygems/ext/rake_builder.rb @@ -14,7 +14,9 @@ def self.build(extension, dest_path, results, args = [], lib_dir = nil, extensio end if /mkrf_conf/i.match?(File.basename(extension)) - run([Gem.ruby, File.basename(extension), *args], results, class_name, extension_dir) + # Gem.ruby is quoted if it contains whitespace, so split it into argv + # elements to keep the quotes out of the spawned command. + run([*shellsplit(Gem.ruby), File.basename(extension), *args], results, class_name, extension_dir) end rake = ENV["rake"] diff --git a/spec/bundler/self_manager_spec.rb b/spec/bundler/self_manager_spec.rb new file mode 100644 index 000000000000..68f5dd55d9db --- /dev/null +++ b/spec/bundler/self_manager_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +RSpec.describe Bundler::SelfManager do + describe "#restart_with" do + # When the running Ruby lives under a path containing whitespace, Gem.ruby + # returns a quoted string. That quoting must not leak into the argv passed + # to Kernel.exec, or the interpreter can't be found and the auto-switch to + # the locked bundler version fails to spawn. + it "does not embed quotes in the ruby executable when Gem.ruby is quoted" do + manager = described_class.new + + allow(Gem).to receive(:ruby).and_return('"/path with space/bin/ruby"') + + # Force the branch that prepends Gem.ruby to the command. + allow(File).to receive(:executable?).and_return(false) + allow(Bundler).to receive(:with_original_env).and_yield + + captured = nil + allow(Kernel).to receive(:exec) do |_env, *cmd| + captured = cmd + end + + manager.send(:restart_with, Gem::Version.new("1.0.0")) + + expect(captured.first).to eq("/path with space/bin/ruby") + expect(captured.first).not_to include('"') + end + end +end diff --git a/test/rubygems/test_gem_commands_push_command.rb b/test/rubygems/test_gem_commands_push_command.rb index ada95e89b4a8..f8bb09d60062 100644 --- a/test/rubygems/test_gem_commands_push_command.rb +++ b/test/rubygems/test_gem_commands_push_command.rb @@ -219,6 +219,36 @@ def test_execute_attestation_skipped_on_jruby end end + # When the running Ruby lives under a path containing whitespace, Gem.ruby + # returns a quoted string. That quoting must not leak into the argv passed to + # Open3.capture2e, or signing spawns fail and push silently falls back to an + # unsigned upload. + def test_attest_unquotes_ruby + require "open3" + + fake_status = Object.new + def fake_status.success? + true + end + + captured = nil + capture_stub = lambda do |*args, **_kwargs| + captured = args + ["", fake_status] + end + Gem.stub(:ruby, '"/path with space/bin/ruby"') do + Open3.stub(:capture2e, capture_stub) do + @cmd.send(:attest!, @path) + end + end + + refute_nil captured, "signing command should have been spawned" + # captured[0] is the env hash; the interpreter follows it. + assert_equal "/path with space/bin/ruby", captured[1] + refute_includes captured[1], '"' + assert_equal "-S", captured[2] + end + def test_execute_allowed_push_host @spec, @path = util_gem "freebird", "1.0.1" do |spec| spec.metadata["allowed_push_host"] = "https://privategemserver.example" diff --git a/test/rubygems/test_gem_commands_update_command.rb b/test/rubygems/test_gem_commands_update_command.rb index 5bc36d61035e..89b56e0f7cfd 100644 --- a/test/rubygems/test_gem_commands_update_command.rb +++ b/test/rubygems/test_gem_commands_update_command.rb @@ -816,6 +816,36 @@ def test_update_gem_unresolved_dependency assert_empty @cmd.updated end + # When the running Ruby lives under a path containing whitespace, Gem.ruby + # returns a quoted string. That quoting must not leak into the argv passed to + # the non-shell `system` call that runs setup.rb, or the interpreter can't be + # found and `gem update --system` fails to spawn. + def test_install_rubygems_unquotes_ruby + version = Gem::Version.new("99.0.0") + update_dir = File.join @tempdir, "gems", "rubygems-update-#{version}" + FileUtils.mkdir_p update_dir + + spec = Struct.new(:version, :base_dir).new(version, @tempdir) + + captured = nil + @cmd.define_singleton_method(:system) do |*args| + captured = args + true + end + + @cmd.options[:silent] = true + + Gem.stub(:ruby, '"/path with space/bin/ruby"') do + @cmd.install_rubygems(spec) + end + + refute_nil captured, "setup.rb should have been spawned" + assert_equal "/path with space/bin/ruby", captured.first + refute_includes captured.first, '"' + assert_includes captured, "--disable-gems" + assert_includes captured, "setup.rb" + end + def test_update_rubygems_arguments @cmd.options[:system] = true diff --git a/test/rubygems/test_gem_ext_rake_builder.rb b/test/rubygems/test_gem_ext_rake_builder.rb index 68ad15b044f0..0176af3abbf3 100644 --- a/test/rubygems/test_gem_ext_rake_builder.rb +++ b/test/rubygems/test_gem_ext_rake_builder.rb @@ -101,6 +101,24 @@ def test_class_build_fail end end + # When the running Ruby lives under a path containing whitespace, Gem.ruby + # returns a quoted string. That quoting must not leak into the argv passed to + # the non-shell spawn that runs mkrf_conf.rb, or the interpreter can't be found. + def test_class_build_mkrf_conf_unquotes_ruby + commands = [] + + Gem.stub(:ruby, '"/path with space/bin/ruby"') do + Gem::Ext::RakeBuilder.stub(:run, ->(command, *) { commands << command }) do + Gem::Ext::RakeBuilder.build "mkrf_conf.rb", @dest_path, [], [], nil, @ext + end + end + + mkrf_command = commands.find {|command| command.include?("mkrf_conf.rb") } + refute_nil mkrf_command, "mkrf_conf.rb should have been spawned" + assert_equal "/path with space/bin/ruby", mkrf_command.first + refute_includes mkrf_command.first, '"' + end + def create_temp_mkrf_file(rakefile_content) File.open File.join(@ext, "mkrf_conf.rb"), "w" do |mkrf_conf| mkrf_conf.puts <<-EO_MKRF