From 113437caf4b4650355983abd5ecee45f1842a281 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 10:20:27 +0900 Subject: [PATCH 01/23] Add the Gem::CredentialStore wrapper An opt-in facade for storing authentication secrets (API keys, host credentials) in a platform credential store instead of a plain text file. Every operation traps its own errors and returns nil/false so callers can fall back to file storage, and get() results are memoized per process since the platform backends landing next all shell out. No platform backend is registered yet; #default_backend returns nil until macOS, Linux, and Windows support land in follow-up commits. Co-Authored-By: Claude Sonnet 5 --- Manifest.txt | 1 + lib/rubygems/credential_store.rb | 128 ++++++++++++++++ test/rubygems/test_gem_credential_store.rb | 165 +++++++++++++++++++++ 3 files changed, 294 insertions(+) create mode 100644 lib/rubygems/credential_store.rb create mode 100644 test/rubygems/test_gem_credential_store.rb diff --git a/Manifest.txt b/Manifest.txt index 8d7b9a0f0961..91a32f55c547 100644 --- a/Manifest.txt +++ b/Manifest.txt @@ -395,6 +395,7 @@ lib/rubygems/core_ext/kernel_gem.rb lib/rubygems/core_ext/kernel_require.rb lib/rubygems/core_ext/kernel_warn.rb lib/rubygems/core_ext/tcpsocket_init.rb +lib/rubygems/credential_store.rb lib/rubygems/defaults.rb lib/rubygems/dependency.rb lib/rubygems/dependency_installer.rb diff --git a/lib/rubygems/credential_store.rb b/lib/rubygems/credential_store.rb new file mode 100644 index 000000000000..c5527ca08c39 --- /dev/null +++ b/lib/rubygems/credential_store.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +## +# Gem::CredentialStore is opt-in storage for authentication secrets (API +# keys, host credentials) in the operating system's native secret store +# instead of a plain text file. Platform backends (macOS Keychain, Linux +# Secret Service, Windows Credential Manager) register themselves with +# #default_backend as they land; see the credential_store/ directory. +# +# Every public method traps all errors and returns +nil+/+false+ instead of +# raising, so that callers can transparently fall back to their existing +# file-based storage when the native store is unavailable or fails (a +# locked keychain over SSH, a headless Linux session without a keyring +# daemon, ...). + +class Gem::CredentialStore + SERVICE_NAME = "rubygems" + + ## + # The single, process-wide store instance. Reusing one instance keeps the + # backend selection and the read cache shared across every caller in the + # process, which matters most on Windows where each PowerShell + # invocation costs hundreds of milliseconds. + + def self.instance + @instance ||= new + end + + ## + # Overrides the memoized #instance. Intended for tests that need to + # inject a store backed by a fake backend. + + def self.instance=(instance) + @instance = instance + end + + ## + # Resets the memoized #instance and the one-time warning flag. Intended + # for tests only. + + def self.reset! + @instance = nil + @warned = false + end + + def self.warn_once(message) + return if @warned + @warned = true + Gem.ui.alert_warning message + end + + ## + # No native backend is registered yet; macOS, Linux, and Windows support + # land in follow-up commits, each adding its own branch here and + # requiring only its own backend file. + + def self.default_backend + nil + end + + ## + # +backend+ is only used by tests to inject a fake backend regardless of + # the platform the test suite happens to run on. + + def initialize(backend: self.class.default_backend) + @backend = backend + @cache = {} + end + + ## + # True if a native credential backend is usable on this platform. + + def available? + !@backend.nil? + end + + ## + # Returns the secret stored for +account+, or +nil+ if there is none or + # the backend is unavailable/fails. + + def get(account) + return nil unless @backend + return @cache[account] if @cache.key?(account) + + @cache[account] = @backend.get(SERVICE_NAME, account) + rescue StandardError => e + warn_failure(e) + nil + end + + ## + # Stores +secret+ for +account+. Returns +true+ on success. + + def set(account, secret) + return false unless @backend + + if @backend.set(SERVICE_NAME, account, secret) + @cache[account] = secret + true + else + false + end + rescue StandardError => e + warn_failure(e) + false + end + + ## + # Removes the secret stored for +account+. Returns +true+ if the entry is + # gone, whether or not it existed beforehand. + + def delete(account) + return false unless @backend + + result = @backend.delete(SERVICE_NAME, account) + @cache.delete(account) + result + rescue StandardError => e + warn_failure(e) + false + end + + private + + def warn_failure(error) + self.class.warn_once "Credential store unavailable (#{error.class}: #{error.message}); falling back to file storage." + end +end diff --git a/test/rubygems/test_gem_credential_store.rb b/test/rubygems/test_gem_credential_store.rb new file mode 100644 index 000000000000..c10cd2d90452 --- /dev/null +++ b/test/rubygems/test_gem_credential_store.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require_relative "helper" +require "rubygems/credential_store" + +class TestGemCredentialStore < Gem::TestCase + class FakeBackend + attr_reader :calls + + def initialize + @calls = [] + @data = {} + @get_calls = 0 + end + + def get(service, account) + @calls << [:get, service, account] + @get_calls += 1 + @data[[service, account]] + end + + def set(service, account, secret) + @calls << [:set, service, account, secret] + @data[[service, account]] = secret + true + end + + def delete(service, account) + @calls << [:delete, service, account] + @data.delete([service, account]) + true + end + + def get_call_count + @get_calls + end + end + + class RaisingBackend + def get(_service, _account) + raise Errno::ENOENT, "security" + end + + def set(_service, _account, _secret) + raise Errno::ENOENT, "security" + end + + def delete(_service, _account) + raise Errno::ENOENT, "security" + end + end + + def setup + super + Gem::CredentialStore.reset! + end + + def teardown + Gem::CredentialStore.reset! + super + end + + def test_available_without_backend + store = Gem::CredentialStore.new(backend: nil) + refute store.available? + end + + def test_available_with_backend + store = Gem::CredentialStore.new(backend: FakeBackend.new) + assert store.available? + end + + def test_get_set_delete_roundtrip + store = Gem::CredentialStore.new(backend: FakeBackend.new) + + assert_nil store.get("example.org") + assert store.set("example.org", "s3cr3t") + assert_equal "s3cr3t", store.get("example.org") + assert store.delete("example.org") + end + + def test_set_uses_service_name + backend = FakeBackend.new + store = Gem::CredentialStore.new(backend: backend) + + store.set("example.org", "s3cr3t") + + assert_includes backend.calls, [:set, Gem::CredentialStore::SERVICE_NAME, "example.org", "s3cr3t"] + end + + def test_get_is_memoized_per_account + backend = FakeBackend.new + backend.set(Gem::CredentialStore::SERVICE_NAME, "example.org", "s3cr3t") + store = Gem::CredentialStore.new(backend: backend) + + 3.times { store.get("example.org") } + + assert_equal 1, backend.get_call_count + end + + def test_set_updates_cache_without_extra_get + backend = FakeBackend.new + store = Gem::CredentialStore.new(backend: backend) + + store.set("example.org", "s3cr3t") + assert_equal "s3cr3t", store.get("example.org") + + assert_equal 0, backend.get_call_count + end + + def test_delete_clears_cache + backend = FakeBackend.new + backend.set(Gem::CredentialStore::SERVICE_NAME, "example.org", "s3cr3t") + store = Gem::CredentialStore.new(backend: backend) + store.get("example.org") + + store.delete("example.org") + store.get("example.org") + + assert_equal 2, backend.get_call_count + end + + def test_operations_without_backend_are_safe_noops + store = Gem::CredentialStore.new(backend: nil) + + assert_nil store.get("example.org") + refute store.set("example.org", "s3cr3t") + refute store.delete("example.org") + end + + def test_get_swallows_backend_errors_and_returns_nil + store = Gem::CredentialStore.new(backend: RaisingBackend.new) + + assert_nil store.get("example.org") + end + + def test_set_swallows_backend_errors_and_returns_false + store = Gem::CredentialStore.new(backend: RaisingBackend.new) + + refute store.set("example.org", "s3cr3t") + end + + def test_delete_swallows_backend_errors_and_returns_false + store = Gem::CredentialStore.new(backend: RaisingBackend.new) + + refute store.delete("example.org") + end + + def test_warning_is_emitted_only_once_per_process + store = Gem::CredentialStore.new(backend: RaisingBackend.new) + + use_ui(@ui) do + store.get("a") + store.get("b") + store.set("c", "x") + end + + warning_count = @ui.errs.string.scan(/WARNING:/).length + assert_equal 1, warning_count + end + + def test_instance_returns_the_same_object + assert_same Gem::CredentialStore.instance, Gem::CredentialStore.instance + end +end From 628837ec5f38f9ba85b69c19f9124072f7970014 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 10:21:17 +0900 Subject: [PATCH 02/23] Add the macOS Keychain backend Shells out to /usr/bin/security. Reads never put the secret on argv (the security CLI's own -w flag takes it there safely), but writes have no such option for add-generic-password, so #set drives security -i (batch mode) and writes a single tokenized command line to its stdin instead, keeping the secret out of ps output. A secret containing a literal newline can't be represented in that batch syntax and is rejected up front. Co-Authored-By: Claude Sonnet 5 --- Manifest.txt | 1 + lib/rubygems/credential_store.rb | 19 ++- .../credential_store/macos_backend.rb | 49 +++++++ ...test_gem_credential_store_macos_backend.rb | 136 ++++++++++++++++++ 4 files changed, 198 insertions(+), 7 deletions(-) create mode 100644 lib/rubygems/credential_store/macos_backend.rb create mode 100644 test/rubygems/test_gem_credential_store_macos_backend.rb diff --git a/Manifest.txt b/Manifest.txt index 91a32f55c547..b71940f165bf 100644 --- a/Manifest.txt +++ b/Manifest.txt @@ -396,6 +396,7 @@ lib/rubygems/core_ext/kernel_require.rb lib/rubygems/core_ext/kernel_warn.rb lib/rubygems/core_ext/tcpsocket_init.rb lib/rubygems/credential_store.rb +lib/rubygems/credential_store/macos_backend.rb lib/rubygems/defaults.rb lib/rubygems/dependency.rb lib/rubygems/dependency_installer.rb diff --git a/lib/rubygems/credential_store.rb b/lib/rubygems/credential_store.rb index c5527ca08c39..259317084d69 100644 --- a/lib/rubygems/credential_store.rb +++ b/lib/rubygems/credential_store.rb @@ -3,9 +3,12 @@ ## # Gem::CredentialStore is opt-in storage for authentication secrets (API # keys, host credentials) in the operating system's native secret store -# instead of a plain text file. Platform backends (macOS Keychain, Linux -# Secret Service, Windows Credential Manager) register themselves with -# #default_backend as they land; see the credential_store/ directory. +# instead of a plain text file: +# +# * macOS: Keychain, via the +security+ command line tool. +# +# Linux and Windows backends register themselves with #default_backend as +# they land; see the credential_store/ directory. # # Every public method traps all errors and returns +nil+/+false+ instead of # raising, so that callers can transparently fall back to their existing @@ -50,12 +53,14 @@ def self.warn_once(message) end ## - # No native backend is registered yet; macOS, Linux, and Windows support - # land in follow-up commits, each adding its own branch here and - # requiring only its own backend file. + # Linux and Windows support land in follow-up commits, each adding its + # own branch here and requiring only its own backend file. def self.default_backend - nil + if RUBY_PLATFORM.include?("darwin") + require_relative "credential_store/macos_backend" + MacOSBackend + end end ## diff --git a/lib/rubygems/credential_store/macos_backend.rb b/lib/rubygems/credential_store/macos_backend.rb new file mode 100644 index 000000000000..acc41ec6e86a --- /dev/null +++ b/lib/rubygems/credential_store/macos_backend.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "open3" + +class Gem::CredentialStore; end unless defined?(Gem::CredentialStore) + +## +# Stores credentials in the macOS Keychain via the +security+ command line +# tool. +security+ has no way to read a password from stdin as raw bytes +# for +add-generic-password+, so #set uses +security -i+ (batch/interactive +# mode, one tokenized command per stdin line) to keep the secret off argv +# and out of +ps+ output. This means a secret containing a literal newline +# cannot be stored; that is a documented limitation, not a bug. + +class Gem::CredentialStore::MacOSBackend + NOT_FOUND_STATUS = 44 + + def self.get(service, account) + out, status = Open3.capture2( + "security", "find-generic-password", "-a", account, "-s", service, "-w", + err: File::NULL + ) + return nil unless status.success? + + secret = out.chomp + secret.empty? ? nil : secret + end + + def self.set(service, account, secret) + raise ArgumentError, "credential secret must not contain a newline" if secret.include?("\n") + + command = "add-generic-password -U -a #{quote(account)} -s #{quote(service)} -w #{quote(secret)}\n" + _out, status = Open3.capture2("security", "-i", stdin_data: command, err: File::NULL) + status.success? + end + + def self.delete(service, account) + _out, status = Open3.capture2( + "security", "delete-generic-password", "-a", account, "-s", service, + err: File::NULL + ) + status.success? || status.exitstatus == NOT_FOUND_STATUS + end + + def self.quote(value) + %("#{value.gsub("\\", "\\\\\\\\").gsub('"', '\\"')}") + end + private_class_method :quote +end diff --git a/test/rubygems/test_gem_credential_store_macos_backend.rb b/test/rubygems/test_gem_credential_store_macos_backend.rb new file mode 100644 index 000000000000..fafcb4edb855 --- /dev/null +++ b/test/rubygems/test_gem_credential_store_macos_backend.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require_relative "helper" +require "rubygems/credential_store/macos_backend" +require "json" + +class TestGemCredentialStoreMacosBackend < Gem::TestCase + FAKE_COMMAND = <<~'RUBY' + #!/usr/bin/env ruby + require "json" + stdin_content = $stdin.read + if record_path = ENV["RUBYGEMS_FAKE_CMD_RECORD"] + File.write(record_path, {"argv" => ARGV, "stdin" => stdin_content}.to_json) + end + $stdout.write(ENV["RUBYGEMS_FAKE_CMD_STDOUT"].to_s) + $stderr.write(ENV["RUBYGEMS_FAKE_CMD_STDERR"].to_s) + exit(ENV["RUBYGEMS_FAKE_CMD_EXIT"].to_i) + RUBY + + def setup + super + pend "fake shebang executables aren't supported on native Windows" if Gem.win_platform? + + @fake_bin_dir = File.join(@tempdir, "fake-bin") + FileUtils.mkdir_p(@fake_bin_dir) + fake_path = File.join(@fake_bin_dir, "security") + File.write(fake_path, FAKE_COMMAND) + File.chmod(0o755, fake_path) + + @record_path = File.join(@tempdir, "record.json") + end + + def test_get_returns_stripped_secret_on_success + with_fake_env(stdout: "s3cr3t\n", exit: 0) do + assert_equal "s3cr3t", Gem::CredentialStore::MacOSBackend.get("rubygems", "example.org") + end + end + + def test_get_returns_nil_when_not_found + with_fake_env(stdout: "", exit: 44) do + assert_nil Gem::CredentialStore::MacOSBackend.get("rubygems", "example.org") + end + end + + def test_get_uses_expected_argv + with_fake_env(stdout: "s3cr3t\n", exit: 0) do + Gem::CredentialStore::MacOSBackend.get("rubygems", "example.org") + end + + record = read_record + assert_equal %w[find-generic-password -a example.org -s rubygems -w], record["argv"] + end + + def test_set_returns_true_on_success + with_fake_env(exit: 0) do + assert Gem::CredentialStore::MacOSBackend.set("rubygems", "example.org", "s3cr3t") + end + end + + def test_set_returns_false_on_failure + with_fake_env(exit: 1) do + refute Gem::CredentialStore::MacOSBackend.set("rubygems", "example.org", "s3cr3t") + end + end + + def test_set_passes_secret_via_stdin_not_argv + with_fake_env(exit: 0) do + Gem::CredentialStore::MacOSBackend.set("rubygems", "example.org", "s3cr3t") + end + + record = read_record + refute_includes record["argv"], "s3cr3t" + assert_includes record["stdin"], "s3cr3t" + end + + def test_set_escapes_quotes_and_backslashes_in_the_stdin_command + with_fake_env(exit: 0) do + Gem::CredentialStore::MacOSBackend.set("rubygems", "example.org", %(pa"ss\\word)) + end + + record = read_record + assert_equal %(add-generic-password -U -a "example.org" -s "rubygems" -w "pa\\"ss\\\\word"\n), record["stdin"] + end + + def test_set_rejects_secret_with_newline + assert_raise(ArgumentError) do + Gem::CredentialStore::MacOSBackend.set("rubygems", "example.org", "line1\nline2") + end + end + + def test_delete_returns_true_on_success + with_fake_env(exit: 0) do + assert Gem::CredentialStore::MacOSBackend.delete("rubygems", "example.org") + end + end + + def test_delete_returns_true_when_not_found + with_fake_env(exit: 44) do + assert Gem::CredentialStore::MacOSBackend.delete("rubygems", "example.org") + end + end + + def test_delete_returns_false_on_other_failure + with_fake_env(exit: 1) do + refute Gem::CredentialStore::MacOSBackend.delete("rubygems", "example.org") + end + end + + def test_get_raises_when_command_missing + empty_dir = File.join(@tempdir, "empty-bin") + FileUtils.mkdir_p(empty_dir) + + with_env(ENV.to_h.merge("PATH" => empty_dir)) do + assert_raise(Errno::ENOENT) do + Gem::CredentialStore::MacOSBackend.get("rubygems", "example.org") + end + end + end + + private + + def with_fake_env(stdout: "", stderr: "", exit: 0) + overrides = ENV.to_h.merge( + "PATH" => [@fake_bin_dir, ENV["PATH"]].join(File::PATH_SEPARATOR), + "RUBYGEMS_FAKE_CMD_STDOUT" => stdout, + "RUBYGEMS_FAKE_CMD_STDERR" => stderr, + "RUBYGEMS_FAKE_CMD_EXIT" => exit.to_s, + "RUBYGEMS_FAKE_CMD_RECORD" => @record_path + ) + with_env(overrides) { yield } + end + + def read_record + JSON.parse(File.read(@record_path)) + end +end From d72845bfaee06e6e7b13104457cd12de17cc2e10 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 10:22:12 +0900 Subject: [PATCH 03/23] Add the Linux Secret Service backend Shells out to secret-tool from libsecret (GNOME Keyring, KWallet, ...). Unlike security on macOS, secret-tool store reads the secret from stdin natively for both reads and writes, so no batch-command workaround is needed. #available? scans PATH directly with File.executable? rather than shelling out to `which`, since a headless session without a keyring daemon still has secret-tool installed but every call to it fails at runtime. Co-Authored-By: Claude Sonnet 5 --- Manifest.txt | 1 + lib/rubygems/credential_store.rb | 13 +- .../credential_store/linux_backend.rb | 55 ++++++++ ...test_gem_credential_store_linux_backend.rb | 127 ++++++++++++++++++ 4 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 lib/rubygems/credential_store/linux_backend.rb create mode 100644 test/rubygems/test_gem_credential_store_linux_backend.rb diff --git a/Manifest.txt b/Manifest.txt index b71940f165bf..7b708fa6db57 100644 --- a/Manifest.txt +++ b/Manifest.txt @@ -396,6 +396,7 @@ lib/rubygems/core_ext/kernel_require.rb lib/rubygems/core_ext/kernel_warn.rb lib/rubygems/core_ext/tcpsocket_init.rb lib/rubygems/credential_store.rb +lib/rubygems/credential_store/linux_backend.rb lib/rubygems/credential_store/macos_backend.rb lib/rubygems/defaults.rb lib/rubygems/dependency.rb diff --git a/lib/rubygems/credential_store.rb b/lib/rubygems/credential_store.rb index 259317084d69..d31ae2cdac61 100644 --- a/lib/rubygems/credential_store.rb +++ b/lib/rubygems/credential_store.rb @@ -6,9 +6,11 @@ # instead of a plain text file: # # * macOS: Keychain, via the +security+ command line tool. +# * Linux: the Secret Service API (GNOME Keyring, KWallet, ...), via +# +secret-tool+. # -# Linux and Windows backends register themselves with #default_backend as -# they land; see the credential_store/ directory. +# A Windows backend registers itself with #default_backend as it lands; +# see the credential_store/ directory. # # Every public method traps all errors and returns +nil+/+false+ instead of # raising, so that callers can transparently fall back to their existing @@ -53,13 +55,16 @@ def self.warn_once(message) end ## - # Linux and Windows support land in follow-up commits, each adding its - # own branch here and requiring only its own backend file. + # Windows support lands in a follow-up commit, adding its own branch + # here and requiring only its own backend file. def self.default_backend if RUBY_PLATFORM.include?("darwin") require_relative "credential_store/macos_backend" MacOSBackend + elsif RUBY_PLATFORM.include?("linux") + require_relative "credential_store/linux_backend" + LinuxBackend if LinuxBackend.available? end end diff --git a/lib/rubygems/credential_store/linux_backend.rb b/lib/rubygems/credential_store/linux_backend.rb new file mode 100644 index 000000000000..245fa34eb111 --- /dev/null +++ b/lib/rubygems/credential_store/linux_backend.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "open3" + +class Gem::CredentialStore; end unless defined?(Gem::CredentialStore) + +## +# Stores credentials in the Secret Service API (GNOME Keyring, KWallet, +# ...) via the +secret-tool+ command line tool from libsecret. + +class Gem::CredentialStore::LinuxBackend + def self.available? + return @available if defined?(@available) + + @available = ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? do |dir| + File.executable?(File.join(dir, "secret-tool")) + end + end + + ## + # Clears the memoized #available? result. Intended for tests only. + + def self.reset! + remove_instance_variable(:@available) if defined?(@available) + end + + def self.get(service, account) + out, status = Open3.capture2( + "secret-tool", "lookup", "service", service, "account", account, + err: File::NULL + ) + return nil unless status.success? + + secret = out.chomp + secret.empty? ? nil : secret + end + + def self.set(service, account, secret) + _out, status = Open3.capture2( + "secret-tool", "store", "--label=RubyGems", "service", service, "account", account, + stdin_data: secret + ) + status.success? + end + + def self.delete(service, account) + _out, err, status = Open3.capture3( + "secret-tool", "clear", "service", service, "account", account + ) + return true if status.success? + + # secret-tool clear exits 1 with no stderr when nothing matched. + status.exitstatus == 1 && err.to_s.strip.empty? + end +end diff --git a/test/rubygems/test_gem_credential_store_linux_backend.rb b/test/rubygems/test_gem_credential_store_linux_backend.rb new file mode 100644 index 000000000000..054ec3e46c17 --- /dev/null +++ b/test/rubygems/test_gem_credential_store_linux_backend.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require_relative "helper" +require "rubygems/credential_store/linux_backend" +require "json" + +class TestGemCredentialStoreLinuxBackend < Gem::TestCase + FAKE_COMMAND = <<~'RUBY' + #!/usr/bin/env ruby + require "json" + stdin_content = $stdin.read + if record_path = ENV["RUBYGEMS_FAKE_CMD_RECORD"] + File.write(record_path, {"argv" => ARGV, "stdin" => stdin_content}.to_json) + end + $stdout.write(ENV["RUBYGEMS_FAKE_CMD_STDOUT"].to_s) + $stderr.write(ENV["RUBYGEMS_FAKE_CMD_STDERR"].to_s) + exit(ENV["RUBYGEMS_FAKE_CMD_EXIT"].to_i) + RUBY + + def setup + super + pend "fake shebang executables aren't supported on native Windows" if Gem.win_platform? + + @fake_bin_dir = File.join(@tempdir, "fake-bin") + FileUtils.mkdir_p(@fake_bin_dir) + fake_path = File.join(@fake_bin_dir, "secret-tool") + File.write(fake_path, FAKE_COMMAND) + File.chmod(0o755, fake_path) + + @record_path = File.join(@tempdir, "record.json") + Gem::CredentialStore::LinuxBackend.reset! + end + + def teardown + Gem::CredentialStore::LinuxBackend.reset! + super + end + + def test_available_is_true_when_secret_tool_is_on_path + with_env(ENV.to_h.merge("PATH" => [@fake_bin_dir, ENV["PATH"]].join(File::PATH_SEPARATOR))) do + Gem::CredentialStore::LinuxBackend.reset! + assert Gem::CredentialStore::LinuxBackend.available? + end + end + + def test_available_is_false_when_secret_tool_is_missing + empty_dir = File.join(@tempdir, "empty-bin") + FileUtils.mkdir_p(empty_dir) + + with_env(ENV.to_h.merge("PATH" => empty_dir)) do + Gem::CredentialStore::LinuxBackend.reset! + refute Gem::CredentialStore::LinuxBackend.available? + end + end + + def test_get_returns_stripped_secret_on_success + with_fake_env(stdout: "s3cr3t\n", exit: 0) do + assert_equal "s3cr3t", Gem::CredentialStore::LinuxBackend.get("rubygems", "example.org") + end + end + + def test_get_returns_nil_when_not_found + with_fake_env(stdout: "", exit: 1) do + assert_nil Gem::CredentialStore::LinuxBackend.get("rubygems", "example.org") + end + end + + def test_get_uses_expected_argv + with_fake_env(stdout: "s3cr3t\n", exit: 0) do + Gem::CredentialStore::LinuxBackend.get("rubygems", "example.org") + end + + record = read_record + assert_equal %w[lookup service rubygems account example.org], record["argv"] + end + + def test_set_returns_true_on_success + with_fake_env(exit: 0) do + assert Gem::CredentialStore::LinuxBackend.set("rubygems", "example.org", "s3cr3t") + end + end + + def test_set_passes_secret_via_stdin_not_argv + with_fake_env(exit: 0) do + Gem::CredentialStore::LinuxBackend.set("rubygems", "example.org", "s3cr3t") + end + + record = read_record + refute_includes record["argv"], "s3cr3t" + assert_equal "s3cr3t", record["stdin"] + end + + def test_delete_returns_true_on_success + with_fake_env(exit: 0) do + assert Gem::CredentialStore::LinuxBackend.delete("rubygems", "example.org") + end + end + + def test_delete_returns_true_when_nothing_matched + with_fake_env(stderr: "", exit: 1) do + assert Gem::CredentialStore::LinuxBackend.delete("rubygems", "example.org") + end + end + + def test_delete_returns_false_on_other_failure + with_fake_env(stderr: "unexpected D-Bus error", exit: 1) do + refute Gem::CredentialStore::LinuxBackend.delete("rubygems", "example.org") + end + end + + private + + def with_fake_env(stdout: "", stderr: "", exit: 0) + overrides = ENV.to_h.merge( + "PATH" => [@fake_bin_dir, ENV["PATH"]].join(File::PATH_SEPARATOR), + "RUBYGEMS_FAKE_CMD_STDOUT" => stdout, + "RUBYGEMS_FAKE_CMD_STDERR" => stderr, + "RUBYGEMS_FAKE_CMD_EXIT" => exit.to_s, + "RUBYGEMS_FAKE_CMD_RECORD" => @record_path + ) + with_env(overrides) { yield } + end + + def read_record + JSON.parse(File.read(@record_path)) + end +end From 06ee0299352a61fbc0b4bb668000b51270db4490 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 10:23:15 +0900 Subject: [PATCH 04/23] Add the Windows Credential Manager backend Drives Windows.Security.Credentials.PasswordVault from PowerShell, using powershell.exe (Windows PowerShell 5.1) rather than pwsh since the WinRT projection this relies on isn't reliably available there. Account, service, and secret are passed as environment variables and read back with $env:RUBYGEMS_CRED_* inside the script, so nothing needs escaping and nothing can break out of the script through quoting. PasswordVault has no overwrite-in-place semantics, so #set removes any existing entry before adding the new one. Co-Authored-By: Claude Sonnet 5 --- Manifest.txt | 1 + lib/rubygems/credential_store.rb | 14 +- .../credential_store/windows_backend.rb | 81 +++++++++++ ...st_gem_credential_store_windows_backend.rb | 129 ++++++++++++++++++ 4 files changed, 217 insertions(+), 8 deletions(-) create mode 100644 lib/rubygems/credential_store/windows_backend.rb create mode 100644 test/rubygems/test_gem_credential_store_windows_backend.rb diff --git a/Manifest.txt b/Manifest.txt index 7b708fa6db57..4f8dca4ed1e4 100644 --- a/Manifest.txt +++ b/Manifest.txt @@ -398,6 +398,7 @@ lib/rubygems/core_ext/tcpsocket_init.rb lib/rubygems/credential_store.rb lib/rubygems/credential_store/linux_backend.rb lib/rubygems/credential_store/macos_backend.rb +lib/rubygems/credential_store/windows_backend.rb lib/rubygems/defaults.rb lib/rubygems/dependency.rb lib/rubygems/dependency_installer.rb diff --git a/lib/rubygems/credential_store.rb b/lib/rubygems/credential_store.rb index d31ae2cdac61..47c4255baa37 100644 --- a/lib/rubygems/credential_store.rb +++ b/lib/rubygems/credential_store.rb @@ -8,9 +8,8 @@ # * macOS: Keychain, via the +security+ command line tool. # * Linux: the Secret Service API (GNOME Keyring, KWallet, ...), via # +secret-tool+. -# -# A Windows backend registers itself with #default_backend as it lands; -# see the credential_store/ directory. +# * Windows: Credential Manager, via the +Windows.Security.Credentials.PasswordVault+ +# API from PowerShell. # # Every public method traps all errors and returns +nil+/+false+ instead of # raising, so that callers can transparently fall back to their existing @@ -54,12 +53,11 @@ def self.warn_once(message) Gem.ui.alert_warning message end - ## - # Windows support lands in a follow-up commit, adding its own branch - # here and requiring only its own backend file. - def self.default_backend - if RUBY_PLATFORM.include?("darwin") + if Gem.win_platform? + require_relative "credential_store/windows_backend" + WindowsBackend + elsif RUBY_PLATFORM.include?("darwin") require_relative "credential_store/macos_backend" MacOSBackend elsif RUBY_PLATFORM.include?("linux") diff --git a/lib/rubygems/credential_store/windows_backend.rb b/lib/rubygems/credential_store/windows_backend.rb new file mode 100644 index 000000000000..4fd78a4685e5 --- /dev/null +++ b/lib/rubygems/credential_store/windows_backend.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "open3" + +class Gem::CredentialStore; end unless defined?(Gem::CredentialStore) + +## +# Stores credentials in the Windows Credential Manager via the +# +Windows.Security.Credentials.PasswordVault+ WinRT API, driven from +# PowerShell. Account/service/secret values are passed as environment +# variables rather than interpolated into the script text, so no quoting +# scheme is needed and values cannot break out of the script. +# +# Windows PowerShell (+powershell.exe+) is used rather than PowerShell 7 +# (+pwsh+) because the WinRT projection used here is not reliably available +# under pwsh. + +class Gem::CredentialStore::WindowsBackend + LOAD_VAULT_TYPE = <<~POWERSHELL + $ErrorActionPreference = 'Stop' + [void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime] + POWERSHELL + private_constant :LOAD_VAULT_TYPE + + def self.get(service, account) + script = <<~POWERSHELL + #{LOAD_VAULT_TYPE} + $vault = New-Object Windows.Security.Credentials.PasswordVault + $credential = $vault.Retrieve($env:RUBYGEMS_CRED_SERVICE, $env:RUBYGEMS_CRED_ACCOUNT) + $credential.Password + POWERSHELL + + out, _err, status = run(script, service, account) + return nil unless status.success? + + secret = out.chomp + secret.empty? ? nil : secret + end + + def self.set(service, account, secret) + script = <<~POWERSHELL + #{LOAD_VAULT_TYPE} + $vault = New-Object Windows.Security.Credentials.PasswordVault + try { + $existing = $vault.Retrieve($env:RUBYGEMS_CRED_SERVICE, $env:RUBYGEMS_CRED_ACCOUNT) + $vault.Remove($existing) + } catch {} + $credential = New-Object Windows.Security.Credentials.PasswordCredential($env:RUBYGEMS_CRED_SERVICE, $env:RUBYGEMS_CRED_ACCOUNT, $env:RUBYGEMS_CRED_SECRET) + $vault.Add($credential) + POWERSHELL + + _out, _err, status = run(script, service, account, secret) + status.success? + end + + def self.delete(service, account) + script = <<~POWERSHELL + #{LOAD_VAULT_TYPE} + $vault = New-Object Windows.Security.Credentials.PasswordVault + $credential = $vault.Retrieve($env:RUBYGEMS_CRED_SERVICE, $env:RUBYGEMS_CRED_ACCOUNT) + $vault.Remove($credential) + POWERSHELL + + _out, err, status = run(script, service, account) + status.success? || missing_credential?(err) + end + + def self.run(script, service, account, secret = nil) + env = { "RUBYGEMS_CRED_SERVICE" => service, "RUBYGEMS_CRED_ACCOUNT" => account } + env["RUBYGEMS_CRED_SECRET"] = secret if secret + + Open3.capture3(env, "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", "-", stdin_data: script) + end + private_class_method :run + + def self.missing_credential?(message) + text = message.to_s.downcase + text.include?("element not found") || text.include?("0x80070490") || text.include?("could not be found") + end + private_class_method :missing_credential? +end diff --git a/test/rubygems/test_gem_credential_store_windows_backend.rb b/test/rubygems/test_gem_credential_store_windows_backend.rb new file mode 100644 index 000000000000..ca3fc72dc8cf --- /dev/null +++ b/test/rubygems/test_gem_credential_store_windows_backend.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require_relative "helper" +require "rubygems/credential_store/windows_backend" +require "json" + +class TestGemCredentialStoreWindowsBackend < Gem::TestCase + FAKE_COMMAND = <<~'RUBY' + #!/usr/bin/env ruby + require "json" + stdin_content = $stdin.read + if record_path = ENV["RUBYGEMS_FAKE_CMD_RECORD"] + record = { + "argv" => ARGV, + "stdin" => stdin_content, + "service_env" => ENV["RUBYGEMS_CRED_SERVICE"], + "account_env" => ENV["RUBYGEMS_CRED_ACCOUNT"], + "secret_env" => ENV["RUBYGEMS_CRED_SECRET"], + } + File.write(record_path, record.to_json) + end + $stdout.write(ENV["RUBYGEMS_FAKE_CMD_STDOUT"].to_s) + $stderr.write(ENV["RUBYGEMS_FAKE_CMD_STDERR"].to_s) + exit(ENV["RUBYGEMS_FAKE_CMD_EXIT"].to_i) + RUBY + + def setup + super + pend "fake shebang executables aren't supported on native Windows" if Gem.win_platform? + + @fake_bin_dir = File.join(@tempdir, "fake-bin") + FileUtils.mkdir_p(@fake_bin_dir) + fake_path = File.join(@fake_bin_dir, "powershell.exe") + File.write(fake_path, FAKE_COMMAND) + File.chmod(0o755, fake_path) + + @record_path = File.join(@tempdir, "record.json") + end + + def test_get_returns_stripped_secret_on_success + with_fake_env(stdout: "s3cr3t\n", exit: 0) do + assert_equal "s3cr3t", Gem::CredentialStore::WindowsBackend.get("rubygems", "example.org") + end + end + + def test_get_returns_nil_when_credential_missing + with_fake_env(stderr: "Element not found. (Exception from HRESULT: 0x80070490)", exit: 1) do + assert_nil Gem::CredentialStore::WindowsBackend.get("rubygems", "example.org") + end + end + + def test_get_returns_nil_on_any_other_failure + with_fake_env(stderr: "Access is denied.", exit: 1) do + assert_nil Gem::CredentialStore::WindowsBackend.get("rubygems", "example.org") + end + end + + def test_get_passes_service_and_account_via_environment_not_script + with_fake_env(stdout: "s3cr3t\n", exit: 0) do + Gem::CredentialStore::WindowsBackend.get("rubygems", "example.org") + end + + record = read_record + assert_equal "rubygems", record["service_env"] + assert_equal "example.org", record["account_env"] + end + + def test_set_passes_secret_via_environment_not_argv_or_script_text + with_fake_env(exit: 0) do + Gem::CredentialStore::WindowsBackend.set("rubygems", "example.org", "s3cr3t") + end + + record = read_record + assert_equal "s3cr3t", record["secret_env"] + refute_includes record["argv"].to_s, "s3cr3t" + end + + def test_set_returns_true_on_success + with_fake_env(exit: 0) do + assert Gem::CredentialStore::WindowsBackend.set("rubygems", "example.org", "s3cr3t") + end + end + + def test_delete_returns_true_on_success + with_fake_env(exit: 0) do + assert Gem::CredentialStore::WindowsBackend.delete("rubygems", "example.org") + end + end + + def test_delete_returns_true_when_credential_missing + with_fake_env(stderr: "Element not found. (Exception from HRESULT: 0x80070490)", exit: 1) do + assert Gem::CredentialStore::WindowsBackend.delete("rubygems", "example.org") + end + end + + def test_delete_returns_false_on_other_failure + with_fake_env(stderr: "Access is denied.", exit: 1) do + refute Gem::CredentialStore::WindowsBackend.delete("rubygems", "example.org") + end + end + + def test_uses_powershell_exe_not_pwsh + with_fake_env(stdout: "s3cr3t\n", exit: 0) do + Gem::CredentialStore::WindowsBackend.get("rubygems", "example.org") + end + + # No assertion beyond "this succeeded": the fake binary is only + # discoverable under the literal name powershell.exe, so a pass here + # proves the backend invokes that name specifically. + assert File.exist?(@record_path) + end + + private + + def with_fake_env(stdout: "", stderr: "", exit: 0) + overrides = ENV.to_h.merge( + "PATH" => [@fake_bin_dir, ENV["PATH"]].join(File::PATH_SEPARATOR), + "RUBYGEMS_FAKE_CMD_STDOUT" => stdout, + "RUBYGEMS_FAKE_CMD_STDERR" => stderr, + "RUBYGEMS_FAKE_CMD_EXIT" => exit.to_s, + "RUBYGEMS_FAKE_CMD_RECORD" => @record_path + ) + with_env(overrides) { yield } + end + + def read_record + JSON.parse(File.read(@record_path)) + end +end From 0c093cc5e3b765aaf0c335a237cc64396ad8764b Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 10:43:42 +0900 Subject: [PATCH 05/23] Store gem credentials through the credential store in Gem::ConfigFile Add a :credential_store: gemrc option (also RUBYGEMS_CREDENTIAL_STORE) that routes the API key reads, writes, and removal in ConfigFile to the OS credential store, falling back to ~/.gem/credentials whenever the store is disabled or fails. credential_store_signed_in? lets callers detect a key that lives only in the store, with no credentials file present. Co-Authored-By: Claude Sonnet 5 --- lib/rubygems/config_file.rb | 70 ++++++++++++++- test/rubygems/fake_credential_backend.rb | 26 ++++++ test/rubygems/helper.rb | 14 +++ test/rubygems/test_gem_config_file.rb | 109 +++++++++++++++++++++++ 4 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 test/rubygems/fake_credential_backend.rb diff --git a/lib/rubygems/config_file.rb b/lib/rubygems/config_file.rb index d5e9eb4e33ae..c21daa5abcd5 100644 --- a/lib/rubygems/config_file.rb +++ b/lib/rubygems/config_file.rb @@ -63,6 +63,14 @@ class Gem::ConfigFile DEFAULT_INSTALL_EXTENSION_IN_LIB = true DEFAULT_GLOBAL_GEM_CACHE = false DEFAULT_USE_PSYCH = false + DEFAULT_CREDENTIAL_STORE = false + + ## + # The account name under which the default RubyGems.org API key is + # stored in the credential store, mirroring the +:rubygems_api_key+ + # symbol used by the plain text credentials file. + + CREDENTIAL_STORE_DEFAULT_ACCOUNT = "rubygems_api_key" ## # For Ruby packagers to set configuration defaults. Set in @@ -185,6 +193,15 @@ class Gem::ConfigFile attr_reader :ssl_client_cert + ## + # == Experimental == + # Store and read push/authentication credentials in the operating + # system's native credential store (macOS Keychain, Linux Secret + # Service, Windows Credential Manager) instead of the plain text + # credentials file, when a native store is available on this platform. + + attr_accessor :credential_store + ## # Create the config file object. +args+ is the list of arguments # from the command line. @@ -219,6 +236,7 @@ def initialize(args) @ipv4_fallback_enabled = ENV["IPV4_FALLBACK_ENABLED"] == "true" || DEFAULT_IPV4_FALLBACK_ENABLED @global_gem_cache = ENV["RUBYGEMS_GLOBAL_GEM_CACHE"] == "true" || DEFAULT_GLOBAL_GEM_CACHE @use_psych = ENV["RUBYGEMS_USE_PSYCH"] == "true" || DEFAULT_USE_PSYCH + @credential_store = ENV["RUBYGEMS_CREDENTIAL_STORE"] == "true" || DEFAULT_CREDENTIAL_STORE operating_system_config = Marshal.load Marshal.dump(OPERATING_SYSTEM_DEFAULTS) platform_config = Marshal.load Marshal.dump(PLATFORM_DEFAULTS) @@ -241,7 +259,7 @@ def initialize(args) # gemhome and gempath are not working with symbol keys if %w[backtrace bulk_threshold verbose update_sources cert_expiration_length_days concurrent_downloads install_extension_in_lib ipv4_fallback_enabled - global_gem_cache use_psych sources + global_gem_cache use_psych credential_store sources disable_default_gem_server ssl_verify_mode ssl_ca_cert ssl_client_cert].include?(k) k.to_sym else @@ -260,6 +278,7 @@ def initialize(args) @ipv4_fallback_enabled = @hash[:ipv4_fallback_enabled] if @hash.key? :ipv4_fallback_enabled @global_gem_cache = @hash[:global_gem_cache] if @hash.key? :global_gem_cache @use_psych = @hash[:use_psych] if @hash.key? :use_psych + @credential_store = @hash[:credential_store] if @hash.key? :credential_store @home = @hash[:gemhome] if @hash.key? :gemhome @path = @hash[:gempath] if @hash.key? :gempath @@ -358,15 +377,51 @@ def rubygems_api_key # Sets the RubyGems.org API key to +api_key+ def rubygems_api_key=(api_key) + if credential_store && !api_key.to_s.empty? && (store = active_credential_store) && store.set(CREDENTIAL_STORE_DEFAULT_ACCOUNT, api_key) + @rubygems_api_key = api_key + return + end + set_api_key :rubygems_api_key, api_key @rubygems_api_key = api_key end + ## + # Looks up +host+'s API key from the credential store, when the + # #credential_store setting is on. Checks the host-specific account first, + # then the account used for the default RubyGems.org key, so a single call + # covers both the --host and default-host cases. Returns +nil+ when + # credential storage is disabled, unavailable, or has no matching entry. + + def credential_store_api_key_for(host) + return nil unless credential_store + return nil unless store = active_credential_store + + store.get(host.to_s) || store.get(CREDENTIAL_STORE_DEFAULT_ACCOUNT) + end + + ## + # True if the default RubyGems.org API key is present in the credential + # store. Used by the +signout+ command, whose file-based check + # (+credentials_path+ existence) misses a key that was only ever stored + # in the credential store. + + def credential_store_signed_in? + return false unless credential_store + return false unless store = active_credential_store + + !store.get(CREDENTIAL_STORE_DEFAULT_ACCOUNT).nil? + end + ## # Set a specific host's API key to +api_key+ def set_api_key(host, api_key) + if credential_store && host != :rubygems_api_key && !api_key.to_s.empty? && (store = active_credential_store) && store.set(host.to_s, api_key) + return + end + check_credentials_permissions config = load_file(credentials_path).merge(host => api_key) @@ -384,9 +439,13 @@ def set_api_key(host, api_key) end ## - # Remove the +~/.gem/credentials+ file to clear all the current sessions. + # Remove the +~/.gem/credentials+ file to clear all the current sessions, + # and the default RubyGems.org key from the credential store, when the + # #credential_store setting is on. def unset_api_key! + active_credential_store&.delete(CREDENTIAL_STORE_DEFAULT_ACCOUNT) + return false unless File.exist?(credentials_path) File.delete(credentials_path) @@ -634,6 +693,13 @@ def self.deep_transform_config_keys!(config) config end + def active_credential_store + return nil unless credential_store + + require_relative "credential_store" + Gem::CredentialStore.instance + end + def set_config_file_name(args) @config_file_name = ENV["GEMRC"] need_config_file_name = false diff --git a/test/rubygems/fake_credential_backend.rb b/test/rubygems/fake_credential_backend.rb new file mode 100644 index 000000000000..ab934f84adb3 --- /dev/null +++ b/test/rubygems/fake_credential_backend.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +## +# An in-memory Gem::CredentialStore backend for tests that need to exercise +# credential-store-enabled code paths without touching a real OS credential store. +# Inject it via Gem::CredentialStore.instance = Gem::CredentialStore.new(backend: Gem::FakeCredentialBackend.new). + +class Gem::FakeCredentialBackend + def initialize + @data = {} + end + + def get(service, account) + @data[[service, account]] + end + + def set(service, account, secret) + @data[[service, account]] = secret + true + end + + def delete(service, account) + @data.delete([service, account]) + true + end +end diff --git a/test/rubygems/helper.rb b/test/rubygems/helper.rb index 4c47d46835e3..2a4566a1a858 100644 --- a/test/rubygems/helper.rb +++ b/test/rubygems/helper.rb @@ -46,6 +46,7 @@ require "rubygems/vendor/uri/lib/uri" require "zlib" require_relative "mock_gem_ui" +require_relative "fake_credential_backend" # JRuby on Windows raises TypeError inside File.symlink (the wincode helper # trips on a nil path), so any test that exercises Gem::Installer's symlink @@ -582,6 +583,19 @@ def credential_teardown FileUtils.rm_rf @temp_cred end + ## + # Runs the block with Gem::CredentialStore.instance backed by an + # in-memory Gem::FakeCredentialBackend, so credential_store-enabled code paths + # can be exercised without touching a real OS credential store. + + def with_fake_credential_store + require "rubygems/credential_store" + Gem::CredentialStore.instance = Gem::CredentialStore.new(backend: Gem::FakeCredentialBackend.new) + yield Gem::CredentialStore.instance + ensure + Gem::CredentialStore.reset! + end + def common_installer_setup common_installer_teardown diff --git a/test/rubygems/test_gem_config_file.rb b/test/rubygems/test_gem_config_file.rb index 3c79cb0762d7..78c03444c893 100644 --- a/test/rubygems/test_gem_config_file.rb +++ b/test/rubygems/test_gem_config_file.rb @@ -457,6 +457,115 @@ def test_rubygems_api_key_equals_bad_permission assert_equal 0o644, stat.mode & 0o644 end + def test_credential_store_defaults_to_false + refute @cfg.credential_store + end + + def test_credential_store_from_gemrc + File.open @temp_conf, "w" do |fp| + fp.puts ":credential_store: true" + end + + util_config_file %W[--config-file=#{@temp_conf}] + + assert @cfg.credential_store + end + + def test_credential_store_from_environment_variable + with_env(ENV.to_h.merge("RUBYGEMS_CREDENTIAL_STORE" => "true")) do + util_config_file + end + + assert @cfg.credential_store + end + + def test_rubygems_api_key_equals_with_credential_store_writes_to_credential_store_not_file + @cfg.credential_store = true + + with_fake_credential_store do |store| + original_file_contents = load_yaml_file(@cfg.credentials_path) + + @cfg.rubygems_api_key = "x" + + assert_equal "x", @cfg.rubygems_api_key + assert_equal "x", store.get(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT) + assert_equal original_file_contents, load_yaml_file(@cfg.credentials_path) + end + end + + def test_set_api_key_with_credential_store_writes_to_credential_store_not_file + @cfg.credential_store = true + + with_fake_credential_store do |store| + original_file_contents = load_yaml_file(@cfg.credentials_path) + + @cfg.set_api_key "https://example.org", "x" + + assert_equal "x", store.get("https://example.org") + assert_equal original_file_contents, load_yaml_file(@cfg.credentials_path) + end + end + + def test_credential_store_api_key_for_checks_host_then_default_account + @cfg.credential_store = true + + with_fake_credential_store do |store| + assert_nil @cfg.credential_store_api_key_for("https://example.org") + + store.set(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT, "default-key") + assert_equal "default-key", @cfg.credential_store_api_key_for("https://example.org") + + store.set("https://example.org", "host-key") + assert_equal "host-key", @cfg.credential_store_api_key_for("https://example.org") + end + end + + def test_credential_store_api_key_for_returns_nil_when_credential_store_disabled + with_fake_credential_store do |store| + store.set(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT, "default-key") + + assert_nil @cfg.credential_store_api_key_for("https://example.org") + end + end + + def test_unset_api_key_bang_removes_from_credential_store + @cfg.credential_store = true + + with_fake_credential_store do |store| + @cfg.rubygems_api_key = "x" + assert_equal "x", store.get(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT) + + @cfg.unset_api_key! + + assert_nil store.get(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT) + end + end + + def test_credential_store_signed_in_reflects_credential_store_only_key + @cfg.credential_store = true + + with_fake_credential_store do + refute @cfg.credential_store_signed_in? + + @cfg.rubygems_api_key = "x" + + assert @cfg.credential_store_signed_in? + end + end + + def test_rubygems_api_key_equals_falls_back_to_file_when_credential_store_unavailable + @cfg.credential_store = true + + Gem::CredentialStore.instance = Gem::CredentialStore.new(backend: nil) + + @cfg.rubygems_api_key = "x" + + assert_equal "x", @cfg.rubygems_api_key + assert_equal({ rubygems_api_key: "x" }, load_yaml_file(@cfg.credentials_path)) + ensure + Gem::CredentialStore.reset! + end + def test_write @cfg.backtrace = false @cfg.update_sources = false From d922bc08c133c1c0ecf311341e375bdca0123a54 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 10:43:51 +0900 Subject: [PATCH 06/23] Read the gem API key from the credential store when enabled api_key now checks the credential store between the GEM_HOST_API_KEY environment variable and the credentials file, so gem push and friends authenticate with a stored key without any file present. Co-Authored-By: Claude Sonnet 5 --- lib/rubygems/gemcutter_utilities.rb | 2 + test/rubygems/test_gem_gemcutter_utilities.rb | 37 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/lib/rubygems/gemcutter_utilities.rb b/lib/rubygems/gemcutter_utilities.rb index 9c22c14fad5b..e91b56fa383f 100644 --- a/lib/rubygems/gemcutter_utilities.rb +++ b/lib/rubygems/gemcutter_utilities.rb @@ -48,6 +48,8 @@ def api_key ENV["GEM_HOST_API_KEY"] elsif options[:key] verify_api_key options[:key] + elsif credential_store_key = Gem.configuration.credential_store_api_key_for(host) + credential_store_key elsif Gem.configuration.api_keys.key?(host) Gem.configuration.api_keys[host] else diff --git a/test/rubygems/test_gem_gemcutter_utilities.rb b/test/rubygems/test_gem_gemcutter_utilities.rb index ca34c8d03dc7..ff3b4ba90fea 100644 --- a/test/rubygems/test_gem_gemcutter_utilities.rb +++ b/test/rubygems/test_gem_gemcutter_utilities.rb @@ -52,6 +52,43 @@ def test_alternate_key_alternate_host assert_equal "EYKEY", @cmd.api_key end + def test_api_key_from_credential_store_takes_precedence_over_file + Gem.configuration.credential_store = true + + with_fake_credential_store do |store| + keys = { rubygems_api_key: "FILE-KEY" } + + File.open Gem.configuration.credentials_path, "w" do |f| + f.write Gem::ConfigFile.dump_with_rubygems_yaml(keys) + end + + Gem.configuration.load_api_keys + store.set(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT, "CREDENTIAL_STORE-KEY") + + assert_equal "CREDENTIAL_STORE-KEY", @cmd.api_key + end + ensure + Gem.configuration.credential_store = false + end + + def test_api_key_falls_back_to_file_when_no_credential_store_entry + Gem.configuration.credential_store = true + + with_fake_credential_store do + keys = { rubygems_api_key: "FILE-KEY" } + + File.open Gem.configuration.credentials_path, "w" do |f| + f.write Gem::ConfigFile.dump_with_rubygems_yaml(keys) + end + + Gem.configuration.load_api_keys + + assert_equal "FILE-KEY", @cmd.api_key + end + ensure + Gem.configuration.credential_store = false + end + def test_api_key keys = { rubygems_api_key: "KEY" } From 376e013adfa38a1b9068980db8350048a11d006b Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 10:43:59 +0900 Subject: [PATCH 07/23] Clear the credential store on gem signout signout now removes the API key from the credential store as well as the credentials file, and recognizes a store-only session so it no longer reports "not currently signed in" when only the store holds the key. Co-Authored-By: Claude Sonnet 5 --- lib/rubygems/commands/signout_command.rb | 9 ++++++--- .../test_gem_commands_signout_command.rb | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/lib/rubygems/commands/signout_command.rb b/lib/rubygems/commands/signout_command.rb index bdd01e4393f5..3ea7efbca04c 100644 --- a/lib/rubygems/commands/signout_command.rb +++ b/lib/rubygems/commands/signout_command.rb @@ -9,7 +9,9 @@ def initialize def description # :nodoc: "The `signout` command is used to sign out from all current sessions,"\ - " allowing you to sign in using a different set of credentials." + " allowing you to sign in using a different set of credentials. If the"\ + " :credential_store: gemrc option is enabled, the API key is also removed from the"\ + " operating system's credential store." end def usage # :nodoc: @@ -18,10 +20,11 @@ def usage # :nodoc: def execute credentials_path = Gem.configuration.credentials_path + credentials_file_exists = File.exist?(credentials_path) - if !File.exist?(credentials_path) + if !credentials_file_exists && !Gem.configuration.credential_store_signed_in? alert_error "You are not currently signed in." - elsif !File.writable?(credentials_path) + elsif credentials_file_exists && !File.writable?(credentials_path) alert_error "File '#{Gem.configuration.credentials_path}' is read-only."\ " Please make sure it is writable." else diff --git a/test/rubygems/test_gem_commands_signout_command.rb b/test/rubygems/test_gem_commands_signout_command.rb index 999a14080f20..81443c22232d 100644 --- a/test/rubygems/test_gem_commands_signout_command.rb +++ b/test/rubygems/test_gem_commands_signout_command.rb @@ -27,4 +27,20 @@ def test_execute_when_not_signed_in # i.e. no credential file created assert_match(/You are not currently signed in/, @sign_out_ui.error) end + + def test_execute_when_signed_in_via_credential_store_only # no credentials file + Gem.configuration.credential_store = true + + with_fake_credential_store do |store| + store.set(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT, "CREDENTIAL_STORE-KEY") + + @sign_out_ui = Gem::MockGemUi.new + use_ui(@sign_out_ui) { @cmd.execute } + + assert_match(/You have successfully signed out/, @sign_out_ui.output) + assert_nil store.get(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT) + end + ensure + Gem.configuration.credential_store = false + end end From 42a0433dca8f6551df4973a1e298b430c726e2c1 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 10:44:25 +0900 Subject: [PATCH 08/23] Note credential store use in gem signin and push help Co-Authored-By: Claude Sonnet 5 --- lib/rubygems/commands/push_command.rb | 2 +- lib/rubygems/commands/signin_command.rb | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/rubygems/commands/push_command.rb b/lib/rubygems/commands/push_command.rb index 02931b30252a..6c844cbcfdb2 100644 --- a/lib/rubygems/commands/push_command.rb +++ b/lib/rubygems/commands/push_command.rb @@ -17,7 +17,7 @@ def description # :nodoc: The gem can be removed from the index and deleted from the server using the yank command. For further discussion see the help for the yank command. -The push command will use ~/.gem/credentials to authenticate to a server, but you can use the RubyGems environment variable GEM_HOST_API_KEY to set the api key to authenticate. +The push command will use ~/.gem/credentials to authenticate to a server, but you can use the RubyGems environment variable GEM_HOST_API_KEY to set the api key to authenticate. If the :credential_store: gemrc option (or RUBYGEMS_CREDENTIAL_STORE environment variable) is enabled, the API key is stored in and read from the operating system's credential store instead of ~/.gem/credentials. EOF end diff --git a/lib/rubygems/commands/signin_command.rb b/lib/rubygems/commands/signin_command.rb index 0f77908c5bfb..2dbd165fea54 100644 --- a/lib/rubygems/commands/signin_command.rb +++ b/lib/rubygems/commands/signin_command.rb @@ -21,7 +21,10 @@ def description # :nodoc: "The signin command executes host sign in for a push server (the default is"\ " https://rubygems.org). The host can be provided with the host flag or can"\ " be inferred from the provided gem. Host resolution matches the resolution"\ - " strategy for the push command." + " strategy for the push command. If the :credential_store: gemrc option (or"\ + " RUBYGEMS_CREDENTIAL_STORE environment variable) is enabled, the resulting API key is"\ + " stored in the operating system's credential store instead of"\ + " ~/.gem/credentials." end def usage # :nodoc: From 2e41685406fd130a61eddcfe3cc0ae8f0162cf29 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 10:44:35 +0900 Subject: [PATCH 09/23] Store bundler host credentials in the credential store when enabled With the credential_store setting on, bundle config set for a host credential writes user:password to the OS credential store instead of the plain text config file, credentials_for reads it back ahead of the file, and unset removes it. Non-credential keys and the gem.push_key path are untouched, and with the setting off credentials_for adds no subprocess so bundle exec stays unchanged. Co-Authored-By: Claude Sonnet 5 --- lib/bundler/settings.rb | 43 ++++++++++++- spec/bundler/settings_spec.rb | 83 +++++++++++++++++++++++++ spec/support/fake_credential_backend.rb | 23 +++++++ 3 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 spec/support/fake_credential_backend.rb diff --git a/lib/bundler/settings.rb b/lib/bundler/settings.rb index fd77c2f7fc76..9f959e1afb62 100644 --- a/lib/bundler/settings.rb +++ b/lib/bundler/settings.rb @@ -29,6 +29,7 @@ class Settings ignore_messages init_gems_rb inline + credential_store lockfile_checksums no_build_extension no_install @@ -185,7 +186,7 @@ def mirror_for(uri) end def credentials_for(uri) - self[uri.to_s] || self[uri.host] + credentials_from_store(uri) || self[uri.to_s] || self[uri.host] end def gem_mirrors @@ -389,6 +390,37 @@ def is_userinfo(value) value.include?(":") end + ## + # The Gem::CredentialStore instance to use when the `credential_store` + # setting is on, or nil when it is off. Guarded by a cheap boolean check + # so reading and writing settings costs nothing extra when the setting is + # disabled. + + def active_credential_store + return nil unless self[:credential_store] + + require "rubygems/credential_store" + Gem::CredentialStore.instance + end + + ## + # True for keys that aren't reserved for a known bool/number/array/ + # string setting (or the gem.push_key signing key path), i.e. keys + # that are eligible to be a host credential like the ones set via + # `bundle config set gems.example.com user:pass`. This mirrors the + # heuristic #printable_value already uses to decide whether to redact + # a value for display. + + def credential_store_key?(raw_key) + !(is_bool(raw_key) || is_num(raw_key) || is_array(raw_key) || is_string(raw_key) || is_credential(raw_key)) + end + + def credentials_from_store(uri) + return nil unless store = active_credential_store + + store.get(uri.to_s) || store.get(uri.host) + end + def to_array(value) return [] unless value value.tr(" ", ":").split(":").map(&:to_sym) @@ -402,6 +434,15 @@ def array_to_s(array) def set_key(raw_key, value, hash, file) raw_key = self.class.key_to_s(raw_key) + + if (store = active_credential_store) && credential_store_key?(raw_key) + if value.nil? + store.delete(raw_key) + elsif value.is_a?(String) && is_userinfo(value) && store.set(raw_key, value) + return + end + end + value = array_to_s(value) if is_array(raw_key) key = key_for(raw_key) diff --git a/spec/bundler/settings_spec.rb b/spec/bundler/settings_spec.rb index 5e1aaaa55511..f40a8db7777d 100644 --- a/spec/bundler/settings_spec.rb +++ b/spec/bundler/settings_spec.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true require "bundler/settings" +require "rubygems/credential_store" +require_relative "../support/fake_credential_backend" RSpec.describe Bundler::Settings do subject(:settings) { described_class.new(bundled_app) } @@ -268,6 +270,87 @@ expect(settings.credentials_for(uri)).to eq(credentials) end end + + context "with credential_store enabled" do + let(:fake_store) { Gem::CredentialStore.new(backend: FakeCredentialBackend.new) } + + before do + settings.set_local "credential_store", "true" + Gem::CredentialStore.instance = fake_store + end + + after { Gem::CredentialStore.reset! } + + it "returns nil when nothing is configured anywhere" do + expect(settings.credentials_for(uri)).to be_nil + end + + it "returns credentials stored under the full URL" do + fake_store.set(uri.to_s, credentials) + + expect(settings.credentials_for(uri)).to eq(credentials) + end + + it "returns credentials stored under the hostname" do + fake_store.set(uri.host, credentials) + + expect(settings.credentials_for(uri)).to eq(credentials) + end + + it "prefers the credential_store over a stale local config value" do + settings.set_local "gemserver.example.org", "stale:value" + fake_store.set(uri.host, credentials) + + expect(settings.credentials_for(uri)).to eq(credentials) + end + end + end + + describe "credential storage with credential_store enabled" do + let(:fake_store) { Gem::CredentialStore.new(backend: FakeCredentialBackend.new) } + + before do + settings.set_local "credential_store", "true" + Gem::CredentialStore.instance = fake_store + end + + after { Gem::CredentialStore.reset! } + + it "writes a host credential to the credential_store instead of the local config file" do + settings.set_local "gemserver.example.org", "username:password" + + expect(fake_store.get("gemserver.example.org")).to eq("username:password") + expect(settings.locations("gemserver.example.org")[:local]).to be_nil + end + + it "does not route non-credential-shaped values to the credential_store" do + settings.set_local "jobs", "4" + + expect(settings["jobs"]).to eq(4) + end + + it "does not route the gem.push_key signing key path to the credential_store" do + settings.set_local "gem.push_key", "/path/to/key.pem" + + expect(settings["gem.push_key"]).to eq("/path/to/key.pem") + end + + it "falls back to the local config file when the credential_store write fails" do + Gem::CredentialStore.instance = Gem::CredentialStore.new(backend: nil) + + settings.set_local "gemserver.example.org", "username:password" + + expect(settings["gemserver.example.org"]).to eq("username:password") + end + + it "removes a credential_store-stored credential on unset" do + settings.set_local "gemserver.example.org", "username:password" + expect(fake_store.get("gemserver.example.org")).to eq("username:password") + + settings.set_local "gemserver.example.org", nil + + expect(fake_store.get("gemserver.example.org")).to be_nil + end end describe "URI normalization" do diff --git a/spec/support/fake_credential_backend.rb b/spec/support/fake_credential_backend.rb new file mode 100644 index 000000000000..c2f1866b56ee --- /dev/null +++ b/spec/support/fake_credential_backend.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +# An in-memory Gem::CredentialStore backend for specs that exercise +# credential-store-enabled code paths without touching a real OS credential store. +class FakeCredentialBackend + def initialize + @data = {} + end + + def get(service, account) + @data[[service, account]] + end + + def set(service, account, secret) + @data[[service, account]] = secret + true + end + + def delete(service, account) + @data.delete([service, account]) + true + end +end From 4916753f5a3cc8b2de1a4a484baf3dee9d94184f Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 10:44:48 +0900 Subject: [PATCH 10/23] Document the credential_store setting in bundle-config(1) Co-Authored-By: Claude Sonnet 5 --- lib/bundler/man/bundle-config.1 | 2 ++ lib/bundler/man/bundle-config.1.ronn | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/lib/bundler/man/bundle-config.1 b/lib/bundler/man/bundle-config.1 index c055c8a415b0..924ee3e67b1f 100644 --- a/lib/bundler/man/bundle-config.1 +++ b/lib/bundler/man/bundle-config.1 @@ -101,6 +101,8 @@ The CLI flag and this setting apply uniformly to every source, including ones de .IP Cooldown filtering depends on the gem server providing a per\-version \fBcreated_at\fR timestamp in the v2 compact\-index format\. Versions without that metadata \- older gem servers, historical entries that predate the v2 cutover on \fBrubygems\.org\fR, or private registries that still emit the v1 format \- are treated as outside the cooldown window and remain resolvable\. If you rely on cooldown for supply\-chain protection, confirm that the gem server emits \fBcreated_at\fR in its \fB/info/\fR responses\. .IP "\(bu" 4 +\fBcredential_store\fR (\fBBUNDLE_CREDENTIAL_STORE\fR): Experimental: store and read host credentials (the values otherwise set via \fBbundle config set \fR) in the operating system's native credential store (macOS Keychain, Linux Secret Service, Windows Credential Manager) instead of the plain text config file, when a native store is available on this platform\. Falls back to the config file when the native store is unavailable or fails\. Defaults to false\. +.IP "\(bu" 4 \fBdefault_cli_command\fR (\fBBUNDLE_DEFAULT_CLI_COMMAND\fR): The command that running \fBbundle\fR without arguments should run\. Defaults to \fBcli_help\fR since Bundler 4, but can also be \fBinstall\fR which was the previous default\. .IP "\(bu" 4 \fBdeployment\fR (\fBBUNDLE_DEPLOYMENT\fR): Equivalent to setting \fBfrozen\fR to \fBtrue\fR and \fBpath\fR to \fBvendor/bundle\fR\. diff --git a/lib/bundler/man/bundle-config.1.ronn b/lib/bundler/man/bundle-config.1.ronn index 72f891b428d5..60a0455b9581 100644 --- a/lib/bundler/man/bundle-config.1.ronn +++ b/lib/bundler/man/bundle-config.1.ronn @@ -167,6 +167,13 @@ learn more about their operation in [bundle install(1)](bundle-install.1.html). window and remain resolvable. If you rely on cooldown for supply-chain protection, confirm that the gem server emits `created_at` in its `/info/` responses. +* `credential_store` (`BUNDLE_CREDENTIAL_STORE`): + Experimental: store and read host credentials (the values otherwise set + via `bundle config set `) in the operating system's + native credential store (macOS Keychain, Linux Secret Service, Windows + Credential Manager) instead of the plain text config file, when a + native store is available on this platform. Falls back to the config + file when the native store is unavailable or fails. Defaults to false. * `default_cli_command` (`BUNDLE_DEFAULT_CLI_COMMAND`): The command that running `bundle` without arguments should run. Defaults to `cli_help` since Bundler 4, but can also be `install` which was the previous From f29390ce02a3c242618a4231b06b26d70bf5c6a7 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 11:10:32 +0900 Subject: [PATCH 11/23] Support pluggable credential store backends by name Add a registry so the credential store can use a backend other than this platform's native one. Gem::CredentialStore.for accepts either true, still meaning the native backend, or a backend name such as "1password", which is resolved by requiring rubygems/credential_store/backends/ and reading what that file registered. A third party can therefore ship a backend as its own gem that RubyGems loads only when the name is actually selected. Names are restricted to a small charset and only ever feed a fixed require prefix, so the setting stays data and never becomes a path or a command. Co-Authored-By: Claude Opus 4.8 --- lib/rubygems/credential_store.rb | 89 +++++++++++++++++++--- test/rubygems/test_gem_credential_store.rb | 62 +++++++++++++++ 2 files changed, 139 insertions(+), 12 deletions(-) diff --git a/lib/rubygems/credential_store.rb b/lib/rubygems/credential_store.rb index 47c4255baa37..1ed38b6dd61b 100644 --- a/lib/rubygems/credential_store.rb +++ b/lib/rubygems/credential_store.rb @@ -11,6 +11,12 @@ # * Windows: Credential Manager, via the +Windows.Security.Credentials.PasswordVault+ # API from PowerShell. # +# A third party can add another backend (1Password, pass, HashiCorp Vault, +# ...) by shipping a gem that provides +# rubygems/credential_store/backends/ and calls +# .register_backend from it. Users then select it by name instead of +true+ +# (see .resolve_backend). +# # Every public method traps all errors and returns +nil+/+false+ instead of # raising, so that callers can transparently fall back to their existing # file-based storage when the native store is unavailable or fails (a @@ -21,29 +27,44 @@ class Gem::CredentialStore SERVICE_NAME = "rubygems" ## - # The single, process-wide store instance. Reusing one instance keeps the - # backend selection and the read cache shared across every caller in the - # process, which matters most on Windows where each PowerShell - # invocation costs hundreds of milliseconds. + # Returns the store to use for +spec+, or +nil+ when the credential store + # is off. +spec+ is either +true+ (use this platform's native backend) or + # the name of a registered backend such as "1password". The store is + # memoized per +spec+ for the life of the process, so the read cache and + # any expensive backend startup are shared across callers. A test may + # install a stand-in via #instance= that is returned here for any enabled + # +spec+. + + def self.for(spec) + return nil unless spec + return @override if defined?(@override) && @override + + (@instances ||= {})[spec] ||= new(backend: backend_for(spec)) + end + + ## + # The default-backed store for this platform, i.e. for(true). + # Kept for callers and tests that only care about the native backend. def self.instance - @instance ||= new + self.for(true) end ## - # Overrides the memoized #instance. Intended for tests that need to - # inject a store backed by a fake backend. + # Installs a stand-in store that .for returns for any enabled setting. + # Intended for tests that inject a fake backend. - def self.instance=(instance) - @instance = instance + def self.instance=(store) + @override = store end ## - # Resets the memoized #instance and the one-time warning flag. Intended - # for tests only. + # Clears the memoized stores, the injected override, and the one-time + # warning flag. Intended for tests only. def self.reset! - @instance = nil + @override = nil + @instances = nil @warned = false end @@ -53,6 +74,50 @@ def self.warn_once(message) Gem.ui.alert_warning message end + ## + # Registers +backend+ under +name+ so it can be selected with + # credential_store = . A third-party backend gem calls this + # from the file RubyGems loads for that name (see .resolve_backend). + + def self.register_backend(name, backend) + (@backends ||= {})[name.to_s] = backend + end + + BACKEND_NAME = /\A[a-z0-9_-]+\z/ + + ## + # Resolves a registered backend by +name+, requiring + # rubygems/credential_store/backends/ on first use so a + # backend shipped as its own gem loads only when actually selected. + # Returns +nil+ (warning once) when the name is malformed or no gem + # provides it, which makes callers fall back to file storage. The fixed + # require prefix and the restricted name charset keep the setting a piece + # of data, never a path or a command. + + def self.resolve_backend(name) + name = name.to_s + unless BACKEND_NAME.match?(name) + warn_once "Ignoring invalid credential store backend name #{name.inspect}." + return nil + end + + return @backends[name] if @backends&.key?(name) + + begin + require "rubygems/credential_store/backends/#{name}" + rescue LoadError + warn_once "Credential store backend #{name.inspect} is not installed; falling back to file storage." + return nil + end + + @backends && @backends[name] + end + + def self.backend_for(spec) + spec == true ? default_backend : resolve_backend(spec) + end + private_class_method :backend_for + def self.default_backend if Gem.win_platform? require_relative "credential_store/windows_backend" diff --git a/test/rubygems/test_gem_credential_store.rb b/test/rubygems/test_gem_credential_store.rb index c10cd2d90452..330c16dadd5e 100644 --- a/test/rubygems/test_gem_credential_store.rb +++ b/test/rubygems/test_gem_credential_store.rb @@ -162,4 +162,66 @@ def test_warning_is_emitted_only_once_per_process def test_instance_returns_the_same_object assert_same Gem::CredentialStore.instance, Gem::CredentialStore.instance end + + def test_for_returns_nil_when_disabled + assert_nil Gem::CredentialStore.for(false) + assert_nil Gem::CredentialStore.for(nil) + end + + def test_for_memoizes_per_spec + Gem::CredentialStore.register_backend("faux", FakeBackend.new) + + assert_same Gem::CredentialStore.for("faux"), Gem::CredentialStore.for("faux") + end + + def test_register_and_resolve_backend_roundtrip + backend = FakeBackend.new + Gem::CredentialStore.register_backend("faux", backend) + + assert_same backend, Gem::CredentialStore.resolve_backend("faux") + + store = Gem::CredentialStore.for("faux") + assert store.set("example.org", "s3cr3t") + assert_equal "s3cr3t", store.get("example.org") + assert_includes backend.calls, [:set, Gem::CredentialStore::SERVICE_NAME, "example.org", "s3cr3t"] + end + + def test_resolve_backend_rejects_invalid_name + use_ui(@ui) do + assert_nil Gem::CredentialStore.resolve_backend("../evil") + assert_nil Gem::CredentialStore.resolve_backend("Foo Bar") + end + + assert_match(/invalid credential store backend name/, @ui.errs.string) + end + + def test_resolve_backend_requires_convention_path_and_registers + backends_dir = File.join(@tempdir, "rubygems", "credential_store", "backends") + FileUtils.mkdir_p(backends_dir) + File.write(File.join(backends_dir, "faux_ext.rb"), <<~RUBY) + Gem::CredentialStore.register_backend("faux_ext", Object.new) + RUBY + + $LOAD_PATH.unshift(@tempdir) + + refute_nil Gem::CredentialStore.resolve_backend("faux_ext") + ensure + $LOAD_PATH.delete(@tempdir) + end + + def test_resolve_backend_unknown_name_returns_nil_and_warns + use_ui(@ui) do + assert_nil Gem::CredentialStore.resolve_backend("definitely_not_installed_xyz") + end + + assert_match(/is not installed/, @ui.errs.string) + end + + def test_instance_override_wins_for_any_enabled_spec + fake = Gem::CredentialStore.new(backend: FakeBackend.new) + Gem::CredentialStore.instance = fake + + assert_same fake, Gem::CredentialStore.for(true) + assert_same fake, Gem::CredentialStore.for("1password") + end end From dc94ee99bb577491433fd2423c5980ca79c4c978 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 11:12:32 +0900 Subject: [PATCH 12/23] Accept a credential store backend name in Gem::ConfigFile The :credential_store: gemrc option and RUBYGEMS_CREDENTIAL_STORE now take a backend name in addition to true and false, so gem push and signin can store their key in a third-party backend such as 1password rather than the native one. true still means the native backend and false still keeps the file. Co-Authored-By: Claude Opus 4.8 --- lib/rubygems/config_file.rb | 25 +++++++++++++----- test/rubygems/test_gem_config_file.rb | 37 +++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/lib/rubygems/config_file.rb b/lib/rubygems/config_file.rb index c21daa5abcd5..ca601f6b0115 100644 --- a/lib/rubygems/config_file.rb +++ b/lib/rubygems/config_file.rb @@ -195,10 +195,12 @@ class Gem::ConfigFile ## # == Experimental == - # Store and read push/authentication credentials in the operating - # system's native credential store (macOS Keychain, Linux Secret - # Service, Windows Credential Manager) instead of the plain text - # credentials file, when a native store is available on this platform. + # Store and read push/authentication credentials in a credential store + # instead of the plain text credentials file. +true+ selects the operating + # system's native store (macOS Keychain, Linux Secret Service, Windows + # Credential Manager) when one is available on this platform. A string + # selects a named backend registered by a third-party gem, such as + # +"1password"+. +false+ (the default) keeps using the credentials file. attr_accessor :credential_store @@ -236,7 +238,7 @@ def initialize(args) @ipv4_fallback_enabled = ENV["IPV4_FALLBACK_ENABLED"] == "true" || DEFAULT_IPV4_FALLBACK_ENABLED @global_gem_cache = ENV["RUBYGEMS_GLOBAL_GEM_CACHE"] == "true" || DEFAULT_GLOBAL_GEM_CACHE @use_psych = ENV["RUBYGEMS_USE_PSYCH"] == "true" || DEFAULT_USE_PSYCH - @credential_store = ENV["RUBYGEMS_CREDENTIAL_STORE"] == "true" || DEFAULT_CREDENTIAL_STORE + @credential_store = normalize_credential_store(ENV["RUBYGEMS_CREDENTIAL_STORE"], DEFAULT_CREDENTIAL_STORE) operating_system_config = Marshal.load Marshal.dump(OPERATING_SYSTEM_DEFAULTS) platform_config = Marshal.load Marshal.dump(PLATFORM_DEFAULTS) @@ -697,7 +699,18 @@ def active_credential_store return nil unless credential_store require_relative "credential_store" - Gem::CredentialStore.instance + Gem::CredentialStore.for(credential_store) + end + + # Interprets a +credential_store+ value from the environment: +"true"+ + # selects the native backend, +"false"+/blank selects +default+, and any + # other value is a backend name passed through as-is. + def normalize_credential_store(value, default) + case value + when nil, "", "false" then default + when "true" then true + else value + end end def set_config_file_name(args) diff --git a/test/rubygems/test_gem_config_file.rb b/test/rubygems/test_gem_config_file.rb index 78c03444c893..d36c9cf70443 100644 --- a/test/rubygems/test_gem_config_file.rb +++ b/test/rubygems/test_gem_config_file.rb @@ -479,6 +479,32 @@ def test_credential_store_from_environment_variable assert @cfg.credential_store end + def test_credential_store_backend_name_from_gemrc + File.open @temp_conf, "w" do |fp| + fp.puts ":credential_store: 1password" + end + + util_config_file %W[--config-file=#{@temp_conf}] + + assert_equal "1password", @cfg.credential_store + end + + def test_credential_store_backend_name_from_environment_variable + with_env(ENV.to_h.merge("RUBYGEMS_CREDENTIAL_STORE" => "1password")) do + util_config_file + end + + assert_equal "1password", @cfg.credential_store + end + + def test_credential_store_false_environment_variable_keeps_default + with_env(ENV.to_h.merge("RUBYGEMS_CREDENTIAL_STORE" => "false")) do + util_config_file + end + + refute @cfg.credential_store + end + def test_rubygems_api_key_equals_with_credential_store_writes_to_credential_store_not_file @cfg.credential_store = true @@ -506,6 +532,17 @@ def test_set_api_key_with_credential_store_writes_to_credential_store_not_file end end + def test_named_backend_routes_reads_and_writes_to_the_store + @cfg.credential_store = "1password" + + with_fake_credential_store do |store| + @cfg.rubygems_api_key = "x" + + assert_equal "x", store.get(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT) + assert_equal "x", @cfg.rubygems_api_key + end + end + def test_credential_store_api_key_for_checks_host_then_default_account @cfg.credential_store = true From 6448618a5d61491858c58adcd476c271128a201d Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 11:14:26 +0900 Subject: [PATCH 13/23] Accept a credential store backend name in bundler settings Move credential_store from the boolean keys to the string keys so its value survives as text, and interpret it as true for the native backend or a backend name such as 1password. This lets a bundle authenticate to a private source through a third-party backend the same way gem does. Co-Authored-By: Claude Opus 4.8 --- lib/bundler/settings.rb | 24 +++++++++++++++++------- spec/bundler/settings_spec.rb | 26 ++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/lib/bundler/settings.rb b/lib/bundler/settings.rb index 9f959e1afb62..f7d0750dd5c2 100644 --- a/lib/bundler/settings.rb +++ b/lib/bundler/settings.rb @@ -29,7 +29,6 @@ class Settings ignore_messages init_gems_rb inline - credential_store lockfile_checksums no_build_extension no_install @@ -63,6 +62,7 @@ class Settings bin cache_path console + credential_store default_cli_command gem.ci gem.github_username @@ -391,16 +391,26 @@ def is_userinfo(value) end ## - # The Gem::CredentialStore instance to use when the `credential_store` - # setting is on, or nil when it is off. Guarded by a cheap boolean check - # so reading and writing settings costs nothing extra when the setting is - # disabled. + # The Gem::CredentialStore instance to use, or nil when the + # `credential_store` setting is off. The value is `true`/`"true"` for this + # platform's native backend or a backend name such as `"1password"`. + # Guarded by a cheap lookup so reading and writing settings costs nothing + # extra when the setting is disabled. def active_credential_store - return nil unless self[:credential_store] + spec = credential_store_spec + return nil unless spec require "rubygems/credential_store" - Gem::CredentialStore.instance + Gem::CredentialStore.for(spec) + end + + def credential_store_spec + case value = self[:credential_store] + when nil, "", "false", false then nil + when "true", true then true + else value.to_s + end end ## diff --git a/spec/bundler/settings_spec.rb b/spec/bundler/settings_spec.rb index f40a8db7777d..6852f726b578 100644 --- a/spec/bundler/settings_spec.rb +++ b/spec/bundler/settings_spec.rb @@ -304,6 +304,32 @@ expect(settings.credentials_for(uri)).to eq(credentials) end end + + context "with a named credential_store backend" do + let(:fake_store) { Gem::CredentialStore.new(backend: FakeCredentialBackend.new) } + + before do + settings.set_local "credential_store", "1password" + Gem::CredentialStore.instance = fake_store + end + + after { Gem::CredentialStore.reset! } + + it "reads credentials from the selected backend" do + fake_store.set(uri.host, credentials) + + expect(settings.credentials_for(uri)).to eq(credentials) + end + end + + context "with credential_store set to false" do + before { settings.set_local "credential_store", "false" } + + it "does not consult a credential store" do + expect(Gem::CredentialStore).not_to receive(:for) + expect(settings.credentials_for(uri)).to be_nil + end + end end describe "credential storage with credential_store enabled" do From e837418529af467c3b47b5e871c7a60164e8666d Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 11:15:55 +0900 Subject: [PATCH 14/23] Document selecting a credential store backend by name Co-Authored-By: Claude Opus 4.8 --- lib/bundler/man/bundle-config.1 | 2 +- lib/bundler/man/bundle-config.1.ronn | 12 +++++++----- lib/rubygems/commands/push_command.rb | 2 +- lib/rubygems/commands/signin_command.rb | 5 ++--- lib/rubygems/commands/signout_command.rb | 4 ++-- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/lib/bundler/man/bundle-config.1 b/lib/bundler/man/bundle-config.1 index 924ee3e67b1f..02425c1d1b49 100644 --- a/lib/bundler/man/bundle-config.1 +++ b/lib/bundler/man/bundle-config.1 @@ -101,7 +101,7 @@ The CLI flag and this setting apply uniformly to every source, including ones de .IP Cooldown filtering depends on the gem server providing a per\-version \fBcreated_at\fR timestamp in the v2 compact\-index format\. Versions without that metadata \- older gem servers, historical entries that predate the v2 cutover on \fBrubygems\.org\fR, or private registries that still emit the v1 format \- are treated as outside the cooldown window and remain resolvable\. If you rely on cooldown for supply\-chain protection, confirm that the gem server emits \fBcreated_at\fR in its \fB/info/\fR responses\. .IP "\(bu" 4 -\fBcredential_store\fR (\fBBUNDLE_CREDENTIAL_STORE\fR): Experimental: store and read host credentials (the values otherwise set via \fBbundle config set \fR) in the operating system's native credential store (macOS Keychain, Linux Secret Service, Windows Credential Manager) instead of the plain text config file, when a native store is available on this platform\. Falls back to the config file when the native store is unavailable or fails\. Defaults to false\. +\fBcredential_store\fR (\fBBUNDLE_CREDENTIAL_STORE\fR): Experimental: store and read host credentials (the values otherwise set via \fBbundle config set \fR) in a credential store instead of the plain text config file\. Set it to \fBtrue\fR to use the operating system's native store (macOS Keychain, Linux Secret Service, Windows Credential Manager) when one is available on this platform, or to the name of a backend provided by a third\-party gem, such as \fB1password\fR\. Falls back to the config file when the store is unavailable or fails\. Defaults to false\. .IP "\(bu" 4 \fBdefault_cli_command\fR (\fBBUNDLE_DEFAULT_CLI_COMMAND\fR): The command that running \fBbundle\fR without arguments should run\. Defaults to \fBcli_help\fR since Bundler 4, but can also be \fBinstall\fR which was the previous default\. .IP "\(bu" 4 diff --git a/lib/bundler/man/bundle-config.1.ronn b/lib/bundler/man/bundle-config.1.ronn index 60a0455b9581..75b4083072b0 100644 --- a/lib/bundler/man/bundle-config.1.ronn +++ b/lib/bundler/man/bundle-config.1.ronn @@ -169,11 +169,13 @@ learn more about their operation in [bundle install(1)](bundle-install.1.html). `created_at` in its `/info/` responses. * `credential_store` (`BUNDLE_CREDENTIAL_STORE`): Experimental: store and read host credentials (the values otherwise set - via `bundle config set `) in the operating system's - native credential store (macOS Keychain, Linux Secret Service, Windows - Credential Manager) instead of the plain text config file, when a - native store is available on this platform. Falls back to the config - file when the native store is unavailable or fails. Defaults to false. + via `bundle config set `) in a credential store instead + of the plain text config file. Set it to `true` to use the operating + system's native store (macOS Keychain, Linux Secret Service, Windows + Credential Manager) when one is available on this platform, or to the name + of a backend provided by a third-party gem, such as `1password`. Falls + back to the config file when the store is unavailable or fails. Defaults to + false. * `default_cli_command` (`BUNDLE_DEFAULT_CLI_COMMAND`): The command that running `bundle` without arguments should run. Defaults to `cli_help` since Bundler 4, but can also be `install` which was the previous diff --git a/lib/rubygems/commands/push_command.rb b/lib/rubygems/commands/push_command.rb index 6c844cbcfdb2..74010070d16c 100644 --- a/lib/rubygems/commands/push_command.rb +++ b/lib/rubygems/commands/push_command.rb @@ -17,7 +17,7 @@ def description # :nodoc: The gem can be removed from the index and deleted from the server using the yank command. For further discussion see the help for the yank command. -The push command will use ~/.gem/credentials to authenticate to a server, but you can use the RubyGems environment variable GEM_HOST_API_KEY to set the api key to authenticate. If the :credential_store: gemrc option (or RUBYGEMS_CREDENTIAL_STORE environment variable) is enabled, the API key is stored in and read from the operating system's credential store instead of ~/.gem/credentials. +The push command will use ~/.gem/credentials to authenticate to a server, but you can use the RubyGems environment variable GEM_HOST_API_KEY to set the api key to authenticate. If the :credential_store: gemrc option (or RUBYGEMS_CREDENTIAL_STORE environment variable) is set, the API key is stored in and read from the credential store it selects instead of ~/.gem/credentials. EOF end diff --git a/lib/rubygems/commands/signin_command.rb b/lib/rubygems/commands/signin_command.rb index 2dbd165fea54..033884474747 100644 --- a/lib/rubygems/commands/signin_command.rb +++ b/lib/rubygems/commands/signin_command.rb @@ -22,9 +22,8 @@ def description # :nodoc: " https://rubygems.org). The host can be provided with the host flag or can"\ " be inferred from the provided gem. Host resolution matches the resolution"\ " strategy for the push command. If the :credential_store: gemrc option (or"\ - " RUBYGEMS_CREDENTIAL_STORE environment variable) is enabled, the resulting API key is"\ - " stored in the operating system's credential store instead of"\ - " ~/.gem/credentials." + " RUBYGEMS_CREDENTIAL_STORE environment variable) is set, the resulting API key is"\ + " stored in the credential store it selects instead of ~/.gem/credentials." end def usage # :nodoc: diff --git a/lib/rubygems/commands/signout_command.rb b/lib/rubygems/commands/signout_command.rb index 3ea7efbca04c..8bc725c189fc 100644 --- a/lib/rubygems/commands/signout_command.rb +++ b/lib/rubygems/commands/signout_command.rb @@ -10,8 +10,8 @@ def initialize def description # :nodoc: "The `signout` command is used to sign out from all current sessions,"\ " allowing you to sign in using a different set of credentials. If the"\ - " :credential_store: gemrc option is enabled, the API key is also removed from the"\ - " operating system's credential store." + " :credential_store: gemrc option is set, the API key is also removed from"\ + " the credential store it selects." end def usage # :nodoc: From 98f0bdae6a02f8ded63fccfe29d71b2c1a6f4a29 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 11:38:07 +0900 Subject: [PATCH 15/23] Move the native backends under credential_store/native Keeps the backends directory exclusively for third-party backends selected by name, so a credential_store value from configuration can only ever require a file under backends. RubyGems' own platform backends now live in native and are reached only through require_relative from default_backend, which no configuration string can redirect. The short native/macos names also mirror the backends/ layout instead of the odd macos_backend spelling. Co-Authored-By: Claude Opus 4.8 --- Manifest.txt | 6 +++--- lib/rubygems/credential_store.rb | 6 +++--- .../credential_store/{linux_backend.rb => native/linux.rb} | 0 .../credential_store/{macos_backend.rb => native/macos.rb} | 0 .../{windows_backend.rb => native/windows.rb} | 0 test/rubygems/test_gem_credential_store_linux_backend.rb | 2 +- test/rubygems/test_gem_credential_store_macos_backend.rb | 2 +- test/rubygems/test_gem_credential_store_windows_backend.rb | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) rename lib/rubygems/credential_store/{linux_backend.rb => native/linux.rb} (100%) rename lib/rubygems/credential_store/{macos_backend.rb => native/macos.rb} (100%) rename lib/rubygems/credential_store/{windows_backend.rb => native/windows.rb} (100%) diff --git a/Manifest.txt b/Manifest.txt index 4f8dca4ed1e4..08f7a7e3efcc 100644 --- a/Manifest.txt +++ b/Manifest.txt @@ -396,9 +396,9 @@ lib/rubygems/core_ext/kernel_require.rb lib/rubygems/core_ext/kernel_warn.rb lib/rubygems/core_ext/tcpsocket_init.rb lib/rubygems/credential_store.rb -lib/rubygems/credential_store/linux_backend.rb -lib/rubygems/credential_store/macos_backend.rb -lib/rubygems/credential_store/windows_backend.rb +lib/rubygems/credential_store/native/linux.rb +lib/rubygems/credential_store/native/macos.rb +lib/rubygems/credential_store/native/windows.rb lib/rubygems/defaults.rb lib/rubygems/dependency.rb lib/rubygems/dependency_installer.rb diff --git a/lib/rubygems/credential_store.rb b/lib/rubygems/credential_store.rb index 1ed38b6dd61b..904b075151c9 100644 --- a/lib/rubygems/credential_store.rb +++ b/lib/rubygems/credential_store.rb @@ -120,13 +120,13 @@ def self.backend_for(spec) def self.default_backend if Gem.win_platform? - require_relative "credential_store/windows_backend" + require_relative "credential_store/native/windows" WindowsBackend elsif RUBY_PLATFORM.include?("darwin") - require_relative "credential_store/macos_backend" + require_relative "credential_store/native/macos" MacOSBackend elsif RUBY_PLATFORM.include?("linux") - require_relative "credential_store/linux_backend" + require_relative "credential_store/native/linux" LinuxBackend if LinuxBackend.available? end end diff --git a/lib/rubygems/credential_store/linux_backend.rb b/lib/rubygems/credential_store/native/linux.rb similarity index 100% rename from lib/rubygems/credential_store/linux_backend.rb rename to lib/rubygems/credential_store/native/linux.rb diff --git a/lib/rubygems/credential_store/macos_backend.rb b/lib/rubygems/credential_store/native/macos.rb similarity index 100% rename from lib/rubygems/credential_store/macos_backend.rb rename to lib/rubygems/credential_store/native/macos.rb diff --git a/lib/rubygems/credential_store/windows_backend.rb b/lib/rubygems/credential_store/native/windows.rb similarity index 100% rename from lib/rubygems/credential_store/windows_backend.rb rename to lib/rubygems/credential_store/native/windows.rb diff --git a/test/rubygems/test_gem_credential_store_linux_backend.rb b/test/rubygems/test_gem_credential_store_linux_backend.rb index 054ec3e46c17..a58c58c1854e 100644 --- a/test/rubygems/test_gem_credential_store_linux_backend.rb +++ b/test/rubygems/test_gem_credential_store_linux_backend.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require_relative "helper" -require "rubygems/credential_store/linux_backend" +require "rubygems/credential_store/native/linux" require "json" class TestGemCredentialStoreLinuxBackend < Gem::TestCase diff --git a/test/rubygems/test_gem_credential_store_macos_backend.rb b/test/rubygems/test_gem_credential_store_macos_backend.rb index fafcb4edb855..401080b08ba0 100644 --- a/test/rubygems/test_gem_credential_store_macos_backend.rb +++ b/test/rubygems/test_gem_credential_store_macos_backend.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require_relative "helper" -require "rubygems/credential_store/macos_backend" +require "rubygems/credential_store/native/macos" require "json" class TestGemCredentialStoreMacosBackend < Gem::TestCase diff --git a/test/rubygems/test_gem_credential_store_windows_backend.rb b/test/rubygems/test_gem_credential_store_windows_backend.rb index ca3fc72dc8cf..54942f9c61ff 100644 --- a/test/rubygems/test_gem_credential_store_windows_backend.rb +++ b/test/rubygems/test_gem_credential_store_windows_backend.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require_relative "helper" -require "rubygems/credential_store/windows_backend" +require "rubygems/credential_store/native/windows" require "json" class TestGemCredentialStoreWindowsBackend < Gem::TestCase From e422eb96e06856bff1361b4bffba06d83670c19b Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 11:51:58 +0900 Subject: [PATCH 16/23] Normalize credential_store keys like the bundler config file The credential_store path stored and looked up host credentials under the raw key, so a value set as "https://host/" was not found when the source URI was "https://host", even though the config file path matches them. Run both the store write and the read through key_for, the same normalization self[] already applies, so keychain lookups behave identically to file lookups. Co-Authored-By: Claude Opus 4.8 --- lib/bundler/settings.rb | 12 ++++++----- spec/bundler/settings_spec.rb | 38 ++++++++++++++++++++++++++--------- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/lib/bundler/settings.rb b/lib/bundler/settings.rb index f7d0750dd5c2..e580acb21a5c 100644 --- a/lib/bundler/settings.rb +++ b/lib/bundler/settings.rb @@ -428,7 +428,10 @@ def credential_store_key?(raw_key) def credentials_from_store(uri) return nil unless store = active_credential_store - store.get(uri.to_s) || store.get(uri.host) + # Normalize with key_for so a value stored under, say, + # "https://host/" is found when the source URI is "https://host", + # exactly as the config-file path already matches via self[]. + store.get(key_for(uri.to_s)) || store.get(key_for(uri.host)) end def to_array(value) @@ -444,19 +447,18 @@ def array_to_s(array) def set_key(raw_key, value, hash, file) raw_key = self.class.key_to_s(raw_key) + key = key_for(raw_key) if (store = active_credential_store) && credential_store_key?(raw_key) if value.nil? - store.delete(raw_key) - elsif value.is_a?(String) && is_userinfo(value) && store.set(raw_key, value) + store.delete(key) + elsif value.is_a?(String) && is_userinfo(value) && store.set(key, value) return end end value = array_to_s(value) if is_array(raw_key) - key = key_for(raw_key) - return if hash[key] == value hash[key] = value diff --git a/spec/bundler/settings_spec.rb b/spec/bundler/settings_spec.rb index 6852f726b578..89744107183c 100644 --- a/spec/bundler/settings_spec.rb +++ b/spec/bundler/settings_spec.rb @@ -285,21 +285,38 @@ expect(settings.credentials_for(uri)).to be_nil end - it "returns credentials stored under the full URL" do - fake_store.set(uri.to_s, credentials) + it "round-trips credentials set under the full URL" do + settings.set_local "https://gemserver.example.org/", credentials expect(settings.credentials_for(uri)).to eq(credentials) end - it "returns credentials stored under the hostname" do - fake_store.set(uri.host, credentials) + it "round-trips credentials set under the hostname" do + settings.set_local "gemserver.example.org", credentials expect(settings.credentials_for(uri)).to eq(credentials) end + it "matches a URL key regardless of a trailing slash, like the config file does" do + settings.set_local "https://gemserver.example.org", credentials + + expect(settings.credentials_for(Gem::URI("https://gemserver.example.org/"))).to eq(credentials) + end + + it "does not write the secret to the local config file" do + settings.set_local "gemserver.example.org", credentials + + expect(settings.locations("gemserver.example.org")[:local]).to be_nil + end + it "prefers the credential_store over a stale local config value" do + # A plain-text credential left in the config file before the store + # was enabled, simulated by routing this one write to the file. + allow(settings).to receive(:active_credential_store).and_return(nil) settings.set_local "gemserver.example.org", "stale:value" - fake_store.set(uri.host, credentials) + allow(settings).to receive(:active_credential_store).and_call_original + + settings.set_local "gemserver.example.org", credentials expect(settings.credentials_for(uri)).to eq(credentials) end @@ -315,8 +332,8 @@ after { Gem::CredentialStore.reset! } - it "reads credentials from the selected backend" do - fake_store.set(uri.host, credentials) + it "round-trips credentials through the selected backend" do + settings.set_local "gemserver.example.org", credentials expect(settings.credentials_for(uri)).to eq(credentials) end @@ -345,7 +362,7 @@ it "writes a host credential to the credential_store instead of the local config file" do settings.set_local "gemserver.example.org", "username:password" - expect(fake_store.get("gemserver.example.org")).to eq("username:password") + expect(fake_store.get(Bundler::Settings.key_for("gemserver.example.org"))).to eq("username:password") expect(settings.locations("gemserver.example.org")[:local]).to be_nil end @@ -370,12 +387,13 @@ end it "removes a credential_store-stored credential on unset" do + account = Bundler::Settings.key_for("gemserver.example.org") settings.set_local "gemserver.example.org", "username:password" - expect(fake_store.get("gemserver.example.org")).to eq("username:password") + expect(fake_store.get(account)).to eq("username:password") settings.set_local "gemserver.example.org", nil - expect(fake_store.get("gemserver.example.org")).to be_nil + expect(fake_store.get(account)).to be_nil end end From 0525b262853482f2a4d2846f916840f7a567762d Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 12:30:40 +0900 Subject: [PATCH 17/23] Fall back when the paired RubyGems has no credential store Bundler can run on an older RubyGems than it shipped with, one that does not provide rubygems/credential_store. Requiring it unconditionally made the credential_store setting raise on those pairs. Guard the require so an unsupported RubyGems warns once and keeps reading and writing the config file as before. Co-Authored-By: Claude Opus 4.8 --- lib/bundler/settings.rb | 28 ++++++++++++++++++++++++++-- spec/bundler/settings_spec.rb | 17 +++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/lib/bundler/settings.rb b/lib/bundler/settings.rb index e580acb21a5c..4f1a11203d19 100644 --- a/lib/bundler/settings.rb +++ b/lib/bundler/settings.rb @@ -401,8 +401,10 @@ def active_credential_store spec = credential_store_spec return nil unless spec - require "rubygems/credential_store" - Gem::CredentialStore.for(spec) + store_class = credential_store_class + return nil unless store_class + + store_class.for(spec) end def credential_store_spec @@ -413,6 +415,28 @@ def credential_store_spec end end + # The Gem::CredentialStore class, or nil when the paired RubyGems is too + # old to ship one. In that case the setting is honored as a no-op with a + # one-time warning so a bundle keeps using the config file instead of + # raising. Bundler can run on an older RubyGems than it was released with. + def credential_store_class + return @credential_store_class if defined?(@credential_store_class) + + @credential_store_class = + begin + require "rubygems/credential_store" + Gem::CredentialStore if Gem::CredentialStore.respond_to?(:for) + rescue LoadError + nil + end + + if @credential_store_class.nil? + Bundler.ui.warn "The `credential_store` setting is set but this RubyGems does not provide a credential store. Falling back to the Bundler config file." + end + + @credential_store_class + end + ## # True for keys that aren't reserved for a known bool/number/array/ # string setting (or the gem.push_key signing key path), i.e. keys diff --git a/spec/bundler/settings_spec.rb b/spec/bundler/settings_spec.rb index 89744107183c..b1b928f55988 100644 --- a/spec/bundler/settings_spec.rb +++ b/spec/bundler/settings_spec.rb @@ -347,6 +347,23 @@ expect(settings.credentials_for(uri)).to be_nil end end + + context "when the paired RubyGems has no credential store" do + before do + settings.set_local "credential_store", "true" + allow(settings).to receive(:require).and_call_original + allow(settings).to receive(:require).with("rubygems/credential_store").and_raise(LoadError) + end + + it "warns once and falls back to the config file without raising" do + allow(Bundler.ui).to receive(:warn) + + expect { settings.set_local "gemserver.example.org", "username:password" }.not_to raise_error + expect(settings.credentials_for(uri)).to eq("username:password") + + expect(Bundler.ui).to have_received(:warn).once + end + end end describe "credential storage with credential_store enabled" do From 6bb372e042da40845917438ba965200959fc58a5 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 13:05:36 +0900 Subject: [PATCH 18/23] Spell out what gem signout removes from the credential store signout deletes the credentials file and the default RubyGems.org key, but a key saved for another host with `gem signin --host` stays in the store because the native backends expose no way to enumerate entries. Say so in the help instead of implying every session is cleared. Co-Authored-By: Claude Fable 5 --- lib/rubygems/commands/signout_command.rb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/rubygems/commands/signout_command.rb b/lib/rubygems/commands/signout_command.rb index 8bc725c189fc..c7ac2403417d 100644 --- a/lib/rubygems/commands/signout_command.rb +++ b/lib/rubygems/commands/signout_command.rb @@ -9,9 +9,11 @@ def initialize def description # :nodoc: "The `signout` command is used to sign out from all current sessions,"\ - " allowing you to sign in using a different set of credentials. If the"\ - " :credential_store: gemrc option is set, the API key is also removed from"\ - " the credential store it selects." + " allowing you to sign in using a different set of credentials. It removes"\ + " the ~/.gem/credentials file. If the :credential_store: gemrc option is"\ + " set, it also removes the default RubyGems.org key from the credential"\ + " store, but a key saved for another host with `gem signin --host` stays in"\ + " the store and must be removed with your platform's credential manager." end def usage # :nodoc: From 8a6a58ce47f77a3ca3fb439848ddd072eb2749a7 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 13:23:05 +0900 Subject: [PATCH 19/23] Reject macOS keychain secrets the security CLI cannot round-trip security find-generic-password -w prints any non-printable byte back as a hex string, so a non-ASCII secret would be read back corrupted, and a newline in the batched security -i command would start a second command. Verified against a throwaway keychain that printable ASCII secrets with spaces, quotes, backslashes, and shell metacharacters round-trip intact while a non-ASCII secret comes back as hex. Limit the secret to printable ASCII and refuse a newline in the account or service so #set fails cleanly and the caller falls back to file storage instead of storing an unreadable value. Co-Authored-By: Claude Fable 5 --- lib/rubygems/credential_store/native/macos.rb | 17 ++++++++++++++--- .../test_gem_credential_store_macos_backend.rb | 15 +++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/lib/rubygems/credential_store/native/macos.rb b/lib/rubygems/credential_store/native/macos.rb index acc41ec6e86a..2041e6c3267c 100644 --- a/lib/rubygems/credential_store/native/macos.rb +++ b/lib/rubygems/credential_store/native/macos.rb @@ -9,11 +9,20 @@ class Gem::CredentialStore; end unless defined?(Gem::CredentialStore) # tool. +security+ has no way to read a password from stdin as raw bytes # for +add-generic-password+, so #set uses +security -i+ (batch/interactive # mode, one tokenized command per stdin line) to keep the secret off argv -# and out of +ps+ output. This means a secret containing a literal newline -# cannot be stored; that is a documented limitation, not a bug. +# and out of +ps+ output. +# +# The secret is limited to printable ASCII. A newline would start a second +# command in the +security -i+ batch, and +security find-generic-password +# -w+ prints any non-printable byte back as a hex string rather than the +# original value, so a non-ASCII secret would round-trip corrupted. #set +# rejects such secrets so the caller falls back to file storage instead of +# silently storing something it cannot read back. The account and service +# are likewise refused a newline to keep them from injecting a second batch +# command. class Gem::CredentialStore::MacOSBackend NOT_FOUND_STATUS = 44 + PRINTABLE_ASCII = /\A[\x20-\x7e]*\z/ def self.get(service, account) out, status = Open3.capture2( @@ -27,7 +36,9 @@ def self.get(service, account) end def self.set(service, account, secret) - raise ArgumentError, "credential secret must not contain a newline" if secret.include?("\n") + raise ArgumentError, "credential secret must be printable ASCII for the macOS keychain" unless secret.match?(PRINTABLE_ASCII) + raise ArgumentError, "credential account must not contain a newline" if account.include?("\n") + raise ArgumentError, "credential service must not contain a newline" if service.include?("\n") command = "add-generic-password -U -a #{quote(account)} -s #{quote(service)} -w #{quote(secret)}\n" _out, status = Open3.capture2("security", "-i", stdin_data: command, err: File::NULL) diff --git a/test/rubygems/test_gem_credential_store_macos_backend.rb b/test/rubygems/test_gem_credential_store_macos_backend.rb index 401080b08ba0..ac003c83477d 100644 --- a/test/rubygems/test_gem_credential_store_macos_backend.rb +++ b/test/rubygems/test_gem_credential_store_macos_backend.rb @@ -88,6 +88,21 @@ def test_set_rejects_secret_with_newline end end + def test_set_rejects_non_ascii_secret + # security find-generic-password -w returns non-printable bytes as a hex + # string, so a non-ASCII secret would round-trip corrupted. Reject it so + # the caller falls back to file storage instead. + assert_raise(ArgumentError) do + Gem::CredentialStore::MacOSBackend.set("rubygems", "example.org", "péあ") + end + end + + def test_set_rejects_account_with_newline + assert_raise(ArgumentError) do + Gem::CredentialStore::MacOSBackend.set("rubygems", "acct\nadd-generic-password", "secret") + end + end + def test_delete_returns_true_on_success with_fake_env(exit: 0) do assert Gem::CredentialStore::MacOSBackend.delete("rubygems", "example.org") From baa0dd1d614d74814112eb0aee06c6310a0a1b03 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 13:30:07 +0900 Subject: [PATCH 20/23] Keep the config file and the credential store consistent on write When a credential moves into the store, its plaintext copy is now removed from the config file (both the bundler config and ~/.gem/credentials) instead of being left behind, so enabling the store actually gets the secret off disk. When the store write does not take, the credential still falls back to the file as before, but now with a warning so the user knows it landed in plain text rather than the store. Co-Authored-By: Claude Fable 5 --- lib/bundler/settings.rb | 10 ++++- lib/rubygems/config_file.rb | 61 +++++++++++++++++++++------ spec/bundler/settings_spec.rb | 17 +++++++- test/rubygems/test_gem_config_file.rb | 33 +++++++++++---- 4 files changed, 96 insertions(+), 25 deletions(-) diff --git a/lib/bundler/settings.rb b/lib/bundler/settings.rb index 4f1a11203d19..f7915e607ad5 100644 --- a/lib/bundler/settings.rb +++ b/lib/bundler/settings.rb @@ -476,8 +476,14 @@ def set_key(raw_key, value, hash, file) if (store = active_credential_store) && credential_store_key?(raw_key) if value.nil? store.delete(key) - elsif value.is_a?(String) && is_userinfo(value) && store.set(key, value) - return + elsif value.is_a?(String) && is_userinfo(value) + if store.set(key, value) + # Stored in the credential store, so drop any plaintext copy left + # in this config file by falling through with a nil value. + value = nil + else + Bundler.ui.warn "Could not write the credential to the credential store, so it was written to the Bundler config file in plain text." + end end end diff --git a/lib/rubygems/config_file.rb b/lib/rubygems/config_file.rb index ca601f6b0115..c7118e35a8a9 100644 --- a/lib/rubygems/config_file.rb +++ b/lib/rubygems/config_file.rb @@ -379,9 +379,14 @@ def rubygems_api_key # Sets the RubyGems.org API key to +api_key+ def rubygems_api_key=(api_key) - if credential_store && !api_key.to_s.empty? && (store = active_credential_store) && store.set(CREDENTIAL_STORE_DEFAULT_ACCOUNT, api_key) - @rubygems_api_key = api_key - return + if credential_store && !api_key.to_s.empty? + store = active_credential_store + if store&.set(CREDENTIAL_STORE_DEFAULT_ACCOUNT, api_key) + remove_api_key_from_file(:rubygems_api_key) + @rubygems_api_key = api_key + return + end + warn_credential_store_fallback end set_api_key :rubygems_api_key, api_key @@ -420,22 +425,20 @@ def credential_store_signed_in? # Set a specific host's API key to +api_key+ def set_api_key(host, api_key) - if credential_store && host != :rubygems_api_key && !api_key.to_s.empty? && (store = active_credential_store) && store.set(host.to_s, api_key) - return + if credential_store && host != :rubygems_api_key && !api_key.to_s.empty? + store = active_credential_store + if store&.set(host.to_s, api_key) + remove_api_key_from_file(host) + return + end + warn_credential_store_fallback end check_credentials_permissions config = load_file(credentials_path).merge(host => api_key) - dirname = File.dirname credentials_path - require "fileutils" - FileUtils.mkdir_p(dirname) - - permissions = 0o600 & ~File.umask - File.open(credentials_path, "w", permissions) do |f| - f.write self.class.dump_with_rubygems_yaml(config) - end + write_credentials(config) load_api_keys # reload end @@ -702,6 +705,38 @@ def active_credential_store Gem::CredentialStore.for(credential_store) end + # Writes +config+ (a host => key hash) to the credentials file with 0600 + # permissions, creating the directory if needed. + def write_credentials(config) + dirname = File.dirname credentials_path + require "fileutils" + FileUtils.mkdir_p(dirname) + + permissions = 0o600 & ~File.umask + File.open(credentials_path, "w", permissions) do |f| + f.write self.class.dump_with_rubygems_yaml(config) + end + end + + # Drops +host+'s plaintext key from the credentials file once it has moved + # into the credential store, so the secret does not linger on disk. Best + # effort: skips silently when the file is absent, unwritable, or lacks the + # key, since the authoritative copy is already in the store. + def remove_api_key_from_file(host) + return unless File.exist?(credentials_path) && File.writable?(credentials_path) + + config = load_file(credentials_path) + return unless config.key?(host) + + config.delete(host) + write_credentials(config) + load_api_keys + end + + def warn_credential_store_fallback + alert_warning "Could not write the API key to the credential store, so it was written to #{credentials_path} in plain text." + end + # Interprets a +credential_store+ value from the environment: +"true"+ # selects the native backend, +"false"+/blank selects +default+, and any # other value is a backend name passed through as-is. diff --git a/spec/bundler/settings_spec.rb b/spec/bundler/settings_spec.rb index b1b928f55988..8d677e1421e5 100644 --- a/spec/bundler/settings_spec.rb +++ b/spec/bundler/settings_spec.rb @@ -395,12 +395,27 @@ expect(settings["gem.push_key"]).to eq("/path/to/key.pem") end - it "falls back to the local config file when the credential_store write fails" do + it "falls back to the local config file and warns when the credential_store write fails" do Gem::CredentialStore.instance = Gem::CredentialStore.new(backend: nil) + allow(Bundler.ui).to receive(:warn) settings.set_local "gemserver.example.org", "username:password" expect(settings["gemserver.example.org"]).to eq("username:password") + expect(Bundler.ui).to have_received(:warn).once + end + + it "removes a stale plaintext credential from the config file once it moves to the store" do + # written to the config file before the store took over + allow(settings).to receive(:active_credential_store).and_return(nil) + settings.set_local "gemserver.example.org", "old:secret" + expect(settings.locations("gemserver.example.org")[:local]).to eq("old:secret") + allow(settings).to receive(:active_credential_store).and_call_original + + settings.set_local "gemserver.example.org", "new:secret" + + expect(fake_store.get(Bundler::Settings.key_for("gemserver.example.org"))).to eq("new:secret") + expect(settings.locations("gemserver.example.org")[:local]).to be_nil end it "removes a credential_store-stored credential on unset" do diff --git a/test/rubygems/test_gem_config_file.rb b/test/rubygems/test_gem_config_file.rb index d36c9cf70443..539dd1ee4a62 100644 --- a/test/rubygems/test_gem_config_file.rb +++ b/test/rubygems/test_gem_config_file.rb @@ -505,31 +505,46 @@ def test_credential_store_false_environment_variable_keeps_default refute @cfg.credential_store end - def test_rubygems_api_key_equals_with_credential_store_writes_to_credential_store_not_file + def test_rubygems_api_key_equals_with_credential_store_writes_to_store_and_clears_file @cfg.credential_store = true with_fake_credential_store do |store| - original_file_contents = load_yaml_file(@cfg.credentials_path) - @cfg.rubygems_api_key = "x" assert_equal "x", @cfg.rubygems_api_key assert_equal "x", store.get(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT) - assert_equal original_file_contents, load_yaml_file(@cfg.credentials_path) + # The plaintext key from credential_setup is removed once it is stored. + refute_includes load_yaml_file(@cfg.credentials_path).keys, :rubygems_api_key end end - def test_set_api_key_with_credential_store_writes_to_credential_store_not_file + def test_set_api_key_with_credential_store_writes_to_store_and_removes_plaintext + # A plaintext host key written before the store was enabled. + @cfg.set_api_key "https://example.org", "old" + assert_equal "old", load_yaml_file(@cfg.credentials_path)["https://example.org"] + @cfg.credential_store = true with_fake_credential_store do |store| - original_file_contents = load_yaml_file(@cfg.credentials_path) + @cfg.set_api_key "https://example.org", "new" + + assert_equal "new", store.get("https://example.org") + refute_includes load_yaml_file(@cfg.credentials_path).keys, "https://example.org" + end + end - @cfg.set_api_key "https://example.org", "x" + def test_rubygems_api_key_equals_warns_and_uses_file_when_store_write_fails + @cfg.credential_store = true + Gem::CredentialStore.instance = Gem::CredentialStore.new(backend: nil) - assert_equal "x", store.get("https://example.org") - assert_equal original_file_contents, load_yaml_file(@cfg.credentials_path) + use_ui @ui do + @cfg.rubygems_api_key = "x" end + + assert_match(/plain text/, @ui.error) + assert_equal "x", load_yaml_file(@cfg.credentials_path)[:rubygems_api_key] + ensure + Gem::CredentialStore.reset! end def test_named_backend_routes_reads_and_writes_to_the_store From b307d4cae94763585c120c06c85cd5fe870d673d Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 13:31:58 +0900 Subject: [PATCH 21/23] Explain credential store selection, migration, and API key precedence Point the missing-backend warning at the gem that must be installed to provide it. Spell out in gem push help the order the API key is resolved from. Note in bundle-config(1) that existing plaintext credentials are not migrated automatically and that the experimental setting may still change. Co-Authored-By: Claude Fable 5 --- lib/bundler/man/bundle-config.1 | 2 +- lib/bundler/man/bundle-config.1.ronn | 8 ++++++-- lib/rubygems/commands/push_command.rb | 2 ++ lib/rubygems/credential_store.rb | 4 +++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/bundler/man/bundle-config.1 b/lib/bundler/man/bundle-config.1 index 02425c1d1b49..8f4f0ddd359d 100644 --- a/lib/bundler/man/bundle-config.1 +++ b/lib/bundler/man/bundle-config.1 @@ -101,7 +101,7 @@ The CLI flag and this setting apply uniformly to every source, including ones de .IP Cooldown filtering depends on the gem server providing a per\-version \fBcreated_at\fR timestamp in the v2 compact\-index format\. Versions without that metadata \- older gem servers, historical entries that predate the v2 cutover on \fBrubygems\.org\fR, or private registries that still emit the v1 format \- are treated as outside the cooldown window and remain resolvable\. If you rely on cooldown for supply\-chain protection, confirm that the gem server emits \fBcreated_at\fR in its \fB/info/\fR responses\. .IP "\(bu" 4 -\fBcredential_store\fR (\fBBUNDLE_CREDENTIAL_STORE\fR): Experimental: store and read host credentials (the values otherwise set via \fBbundle config set \fR) in a credential store instead of the plain text config file\. Set it to \fBtrue\fR to use the operating system's native store (macOS Keychain, Linux Secret Service, Windows Credential Manager) when one is available on this platform, or to the name of a backend provided by a third\-party gem, such as \fB1password\fR\. Falls back to the config file when the store is unavailable or fails\. Defaults to false\. +\fBcredential_store\fR (\fBBUNDLE_CREDENTIAL_STORE\fR): Experimental: store and read host credentials (the values otherwise set via \fBbundle config set \fR) in a credential store instead of the plain text config file\. Set it to \fBtrue\fR to use the operating system's native store (macOS Keychain, Linux Secret Service, Windows Credential Manager) when one is available on this platform, or to the name of a backend provided by a third\-party gem, such as \fB1password\fR\. Falls back to the config file when the store is unavailable or fails, warning that the credential was written in plain text\. Defaults to false\. Credentials already written to the config file are not migrated automatically; re\-run \fBbundle config set \fR with the setting enabled to move each one into the store\. Being experimental, the name and behavior of this setting may change in a future release\. .IP "\(bu" 4 \fBdefault_cli_command\fR (\fBBUNDLE_DEFAULT_CLI_COMMAND\fR): The command that running \fBbundle\fR without arguments should run\. Defaults to \fBcli_help\fR since Bundler 4, but can also be \fBinstall\fR which was the previous default\. .IP "\(bu" 4 diff --git a/lib/bundler/man/bundle-config.1.ronn b/lib/bundler/man/bundle-config.1.ronn index 75b4083072b0..989ef7178f27 100644 --- a/lib/bundler/man/bundle-config.1.ronn +++ b/lib/bundler/man/bundle-config.1.ronn @@ -174,8 +174,12 @@ learn more about their operation in [bundle install(1)](bundle-install.1.html). system's native store (macOS Keychain, Linux Secret Service, Windows Credential Manager) when one is available on this platform, or to the name of a backend provided by a third-party gem, such as `1password`. Falls - back to the config file when the store is unavailable or fails. Defaults to - false. + back to the config file when the store is unavailable or fails, warning + that the credential was written in plain text. Defaults to false. + Credentials already written to the config file are not migrated + automatically; re-run `bundle config set ` with the + setting enabled to move each one into the store. Being experimental, the + name and behavior of this setting may change in a future release. * `default_cli_command` (`BUNDLE_DEFAULT_CLI_COMMAND`): The command that running `bundle` without arguments should run. Defaults to `cli_help` since Bundler 4, but can also be `install` which was the previous diff --git a/lib/rubygems/commands/push_command.rb b/lib/rubygems/commands/push_command.rb index 74010070d16c..15523c0b3083 100644 --- a/lib/rubygems/commands/push_command.rb +++ b/lib/rubygems/commands/push_command.rb @@ -18,6 +18,8 @@ def description # :nodoc: command. For further discussion see the help for the yank command. The push command will use ~/.gem/credentials to authenticate to a server, but you can use the RubyGems environment variable GEM_HOST_API_KEY to set the api key to authenticate. If the :credential_store: gemrc option (or RUBYGEMS_CREDENTIAL_STORE environment variable) is set, the API key is stored in and read from the credential store it selects instead of ~/.gem/credentials. + +The API key to send is resolved in this order: the GEM_HOST_API_KEY environment variable, the --key option, the credential store (when :credential_store: is set), then ~/.gem/credentials. The first one found is used. EOF end diff --git a/lib/rubygems/credential_store.rb b/lib/rubygems/credential_store.rb index 904b075151c9..df73fb862d43 100644 --- a/lib/rubygems/credential_store.rb +++ b/lib/rubygems/credential_store.rb @@ -106,7 +106,9 @@ def self.resolve_backend(name) begin require "rubygems/credential_store/backends/#{name}" rescue LoadError - warn_once "Credential store backend #{name.inspect} is not installed; falling back to file storage." + warn_once "Credential store backend #{name.inspect} is not installed. " \ + "Install a gem that provides rubygems/credential_store/backends/#{name}, " \ + "or unset the credential_store setting. Falling back to file storage." return nil end From 0c0b1b134979165576ecaf85c6f64b7b935c8f95 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 14:02:08 +0900 Subject: [PATCH 22/23] Clear every registry from the credential store on gem signout gem signin --host stores a per-host key, but the store is shared with Bundler under one service name, so signout could not just wipe everything without taking Bundler's credentials with it. Give Bundler its own service namespace, add delete_all to remove one service's entries (a delete-by- service loop on macOS, secret-tool clear on Linux, FindAllByResource on Windows), and have signout clear the whole RubyGems service instead of only the default account. It now reports signing out of every registry, including RubyGems.org, so a user who used --host is not left with a key they cannot see. Co-Authored-By: Claude Fable 5 --- lib/bundler/settings.rb | 7 ++- lib/rubygems/commands/signout_command.rb | 9 ++-- lib/rubygems/config_file.rb | 20 ++----- lib/rubygems/credential_store.rb | 53 +++++++++++++++---- lib/rubygems/credential_store/native/linux.rb | 12 +++++ lib/rubygems/credential_store/native/macos.rb | 14 +++++ .../credential_store/native/windows.rb | 18 +++++++ spec/support/fake_credential_backend.rb | 5 ++ test/rubygems/fake_credential_backend.rb | 5 ++ .../test_gem_commands_signout_command.rb | 8 +-- test/rubygems/test_gem_config_file.rb | 14 +++-- test/rubygems/test_gem_credential_store.rb | 43 +++++++++++++++ ...test_gem_credential_store_linux_backend.rb | 21 ++++++++ ...test_gem_credential_store_macos_backend.rb | 26 +++++++++ ...st_gem_credential_store_windows_backend.rb | 20 +++++++ 15 files changed, 234 insertions(+), 41 deletions(-) diff --git a/lib/bundler/settings.rb b/lib/bundler/settings.rb index f7915e607ad5..b2ef51574517 100644 --- a/lib/bundler/settings.rb +++ b/lib/bundler/settings.rb @@ -397,6 +397,11 @@ def is_userinfo(value) # Guarded by a cheap lookup so reading and writing settings costs nothing # extra when the setting is disabled. + # The account namespace Bundler uses in the shared native store, kept + # separate from RubyGems so that gem signout does not remove Bundler's + # host credentials. + CREDENTIAL_STORE_SERVICE = "bundler" + def active_credential_store spec = credential_store_spec return nil unless spec @@ -404,7 +409,7 @@ def active_credential_store store_class = credential_store_class return nil unless store_class - store_class.for(spec) + store_class.for(spec, service: CREDENTIAL_STORE_SERVICE) end def credential_store_spec diff --git a/lib/rubygems/commands/signout_command.rb b/lib/rubygems/commands/signout_command.rb index c7ac2403417d..fcc2428dad5a 100644 --- a/lib/rubygems/commands/signout_command.rb +++ b/lib/rubygems/commands/signout_command.rb @@ -11,9 +11,8 @@ def description # :nodoc: "The `signout` command is used to sign out from all current sessions,"\ " allowing you to sign in using a different set of credentials. It removes"\ " the ~/.gem/credentials file. If the :credential_store: gemrc option is"\ - " set, it also removes the default RubyGems.org key from the credential"\ - " store, but a key saved for another host with `gem signin --host` stays in"\ - " the store and must be removed with your platform's credential manager." + " set, it also removes every RubyGems key from the credential store,"\ + " including keys saved for other hosts with `gem signin --host`." end def usage # :nodoc: @@ -24,14 +23,14 @@ def execute credentials_path = Gem.configuration.credentials_path credentials_file_exists = File.exist?(credentials_path) - if !credentials_file_exists && !Gem.configuration.credential_store_signed_in? + if !credentials_file_exists && !Gem.configuration.credential_store alert_error "You are not currently signed in." elsif credentials_file_exists && !File.writable?(credentials_path) alert_error "File '#{Gem.configuration.credentials_path}' is read-only."\ " Please make sure it is writable." else Gem.configuration.unset_api_key! - say "You have successfully signed out from all sessions." + say "You have successfully signed out of every registry, including RubyGems.org." end end end diff --git a/lib/rubygems/config_file.rb b/lib/rubygems/config_file.rb index c7118e35a8a9..2999c105ec39 100644 --- a/lib/rubygems/config_file.rb +++ b/lib/rubygems/config_file.rb @@ -408,19 +408,6 @@ def credential_store_api_key_for(host) store.get(host.to_s) || store.get(CREDENTIAL_STORE_DEFAULT_ACCOUNT) end - ## - # True if the default RubyGems.org API key is present in the credential - # store. Used by the +signout+ command, whose file-based check - # (+credentials_path+ existence) misses a key that was only ever stored - # in the credential store. - - def credential_store_signed_in? - return false unless credential_store - return false unless store = active_credential_store - - !store.get(CREDENTIAL_STORE_DEFAULT_ACCOUNT).nil? - end - ## # Set a specific host's API key to +api_key+ @@ -445,11 +432,12 @@ def set_api_key(host, api_key) ## # Remove the +~/.gem/credentials+ file to clear all the current sessions, - # and the default RubyGems.org key from the credential store, when the - # #credential_store setting is on. + # and every RubyGems key from the credential store when the + # #credential_store setting is on, including keys saved for other hosts + # with gem signin --host. def unset_api_key! - active_credential_store&.delete(CREDENTIAL_STORE_DEFAULT_ACCOUNT) + active_credential_store&.delete_all return false unless File.exist?(credentials_path) diff --git a/lib/rubygems/credential_store.rb b/lib/rubygems/credential_store.rb index df73fb862d43..4ff1ef02b16c 100644 --- a/lib/rubygems/credential_store.rb +++ b/lib/rubygems/credential_store.rb @@ -29,17 +29,20 @@ class Gem::CredentialStore ## # Returns the store to use for +spec+, or +nil+ when the credential store # is off. +spec+ is either +true+ (use this platform's native backend) or - # the name of a registered backend such as "1password". The store is - # memoized per +spec+ for the life of the process, so the read cache and + # the name of a registered backend such as "1password". +service+ names + # the account namespace within the backend, so RubyGems and Bundler keep + # separate credentials in one native store. The store is memoized per + # +spec+ and +service+ for the life of the process, so the read cache and # any expensive backend startup are shared across callers. A test may # install a stand-in via #instance= that is returned here for any enabled - # +spec+. + # +spec+, or inject a shared backend via #backend=. - def self.for(spec) + def self.for(spec, service: SERVICE_NAME) return nil unless spec return @override if defined?(@override) && @override - (@instances ||= {})[spec] ||= new(backend: backend_for(spec)) + backend = defined?(@override_backend) && @override_backend ? @override_backend : backend_for(spec) + (@instances ||= {})[[spec, service]] ||= new(backend: backend, service: service) end ## @@ -59,11 +62,21 @@ def self.instance=(store) end ## - # Clears the memoized stores, the injected override, and the one-time + # Installs a shared backend that .for wraps for every spec and service. + # Intended for tests that need RubyGems and Bundler credentials to land in + # one backend under their own service names. + + def self.backend=(backend) + @override_backend = backend + end + + ## + # Clears the memoized stores, the injected overrides, and the one-time # warning flag. Intended for tests only. def self.reset! @override = nil + @override_backend = nil @instances = nil @warned = false end @@ -135,10 +148,12 @@ def self.default_backend ## # +backend+ is only used by tests to inject a fake backend regardless of - # the platform the test suite happens to run on. + # the platform the test suite happens to run on. +service+ is the account + # namespace this store reads and writes under. - def initialize(backend: self.class.default_backend) + def initialize(backend: self.class.default_backend, service: SERVICE_NAME) @backend = backend + @service = service @cache = {} end @@ -157,7 +172,7 @@ def get(account) return nil unless @backend return @cache[account] if @cache.key?(account) - @cache[account] = @backend.get(SERVICE_NAME, account) + @cache[account] = @backend.get(@service, account) rescue StandardError => e warn_failure(e) nil @@ -169,7 +184,7 @@ def get(account) def set(account, secret) return false unless @backend - if @backend.set(SERVICE_NAME, account, secret) + if @backend.set(@service, account, secret) @cache[account] = secret true else @@ -187,7 +202,7 @@ def set(account, secret) def delete(account) return false unless @backend - result = @backend.delete(SERVICE_NAME, account) + result = @backend.delete(@service, account) @cache.delete(account) result rescue StandardError => e @@ -195,6 +210,22 @@ def delete(account) false end + ## + # Removes every entry this store owns (all accounts under its service). + # Returns +true+ when the store is now clear. Used by +gem signout+ to end + # every session at once, mirroring deletion of the whole credentials file. + + def delete_all + return false unless @backend + + result = @backend.delete_all(@service) + @cache.clear + result + rescue StandardError => e + warn_failure(e) + false + end + private def warn_failure(error) diff --git a/lib/rubygems/credential_store/native/linux.rb b/lib/rubygems/credential_store/native/linux.rb index 245fa34eb111..f36571f961c1 100644 --- a/lib/rubygems/credential_store/native/linux.rb +++ b/lib/rubygems/credential_store/native/linux.rb @@ -52,4 +52,16 @@ def self.delete(service, account) # secret-tool clear exits 1 with no stderr when nothing matched. status.exitstatus == 1 && err.to_s.strip.empty? end + + # secret-tool clear removes every item matching the attributes, so a clear + # keyed on the service alone empties just that service. + def self.delete_all(service) + _out, err, status = Open3.capture3( + "secret-tool", "clear", "service", service + ) + return true if status.success? + + # Exits 1 with no stderr when there was nothing to clear. + status.exitstatus == 1 && err.to_s.strip.empty? + end end diff --git a/lib/rubygems/credential_store/native/macos.rb b/lib/rubygems/credential_store/native/macos.rb index 2041e6c3267c..81127f7de374 100644 --- a/lib/rubygems/credential_store/native/macos.rb +++ b/lib/rubygems/credential_store/native/macos.rb @@ -53,6 +53,20 @@ def self.delete(service, account) status.success? || status.exitstatus == NOT_FOUND_STATUS end + # security deletes one entry per call, so keep deleting the given service + # until it reports there is nothing left (exit 44). This only touches the + # given service and leaves entries for other services intact. + def self.delete_all(service) + loop do + _out, status = Open3.capture2( + "security", "delete-generic-password", "-s", service, + err: File::NULL + ) + return true if status.exitstatus == NOT_FOUND_STATUS + return false unless status.success? + end + end + def self.quote(value) %("#{value.gsub("\\", "\\\\\\\\").gsub('"', '\\"')}") end diff --git a/lib/rubygems/credential_store/native/windows.rb b/lib/rubygems/credential_store/native/windows.rb index 4fd78a4685e5..f28043634605 100644 --- a/lib/rubygems/credential_store/native/windows.rb +++ b/lib/rubygems/credential_store/native/windows.rb @@ -65,6 +65,24 @@ def self.delete(service, account) status.success? || missing_credential?(err) end + # Removes every credential stored under the given resource (service), + # leaving other resources untouched. FindAllByResource raises when the + # resource has no entries, which is treated as an empty, successful clear. + def self.delete_all(service) + script = <<~POWERSHELL + #{LOAD_VAULT_TYPE} + $vault = New-Object Windows.Security.Credentials.PasswordVault + try { + $vault.FindAllByResource($env:RUBYGEMS_CRED_SERVICE) | ForEach-Object { $vault.Remove($_) } + } catch { + if (-not ($_.Exception.Message -match 'not found|0x80070490')) { throw } + } + POWERSHELL + + _out, err, status = run(script, service, nil) + status.success? || missing_credential?(err) + end + def self.run(script, service, account, secret = nil) env = { "RUBYGEMS_CRED_SERVICE" => service, "RUBYGEMS_CRED_ACCOUNT" => account } env["RUBYGEMS_CRED_SECRET"] = secret if secret diff --git a/spec/support/fake_credential_backend.rb b/spec/support/fake_credential_backend.rb index c2f1866b56ee..a95cea4ebdc9 100644 --- a/spec/support/fake_credential_backend.rb +++ b/spec/support/fake_credential_backend.rb @@ -20,4 +20,9 @@ def delete(service, account) @data.delete([service, account]) true end + + def delete_all(service) + @data.reject! {|(entry_service, _account), _secret| entry_service == service } + true + end end diff --git a/test/rubygems/fake_credential_backend.rb b/test/rubygems/fake_credential_backend.rb index ab934f84adb3..315aaeca0cbb 100644 --- a/test/rubygems/fake_credential_backend.rb +++ b/test/rubygems/fake_credential_backend.rb @@ -23,4 +23,9 @@ def delete(service, account) @data.delete([service, account]) true end + + def delete_all(service) + @data.reject! {|(entry_service, _account), _secret| entry_service == service } + true + end end diff --git a/test/rubygems/test_gem_commands_signout_command.rb b/test/rubygems/test_gem_commands_signout_command.rb index 81443c22232d..03498df58473 100644 --- a/test/rubygems/test_gem_commands_signout_command.rb +++ b/test/rubygems/test_gem_commands_signout_command.rb @@ -28,17 +28,19 @@ def test_execute_when_not_signed_in # i.e. no credential file created assert_match(/You are not currently signed in/, @sign_out_ui.error) end - def test_execute_when_signed_in_via_credential_store_only # no credentials file + def test_execute_signs_out_of_every_registry_via_credential_store # no credentials file Gem.configuration.credential_store = true with_fake_credential_store do |store| - store.set(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT, "CREDENTIAL_STORE-KEY") + store.set(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT, "rubygems-key") + store.set("https://other.example", "other-key") @sign_out_ui = Gem::MockGemUi.new use_ui(@sign_out_ui) { @cmd.execute } - assert_match(/You have successfully signed out/, @sign_out_ui.output) + assert_match(/signed out of every registry, including RubyGems\.org/, @sign_out_ui.output) assert_nil store.get(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT) + assert_nil store.get("https://other.example") end ensure Gem.configuration.credential_store = false diff --git a/test/rubygems/test_gem_config_file.rb b/test/rubygems/test_gem_config_file.rb index 539dd1ee4a62..63389a813bc2 100644 --- a/test/rubygems/test_gem_config_file.rb +++ b/test/rubygems/test_gem_config_file.rb @@ -593,15 +593,19 @@ def test_unset_api_key_bang_removes_from_credential_store end end - def test_credential_store_signed_in_reflects_credential_store_only_key + def test_unset_api_key_bang_removes_every_host_from_credential_store @cfg.credential_store = true - with_fake_credential_store do - refute @cfg.credential_store_signed_in? - + with_fake_credential_store do |store| @cfg.rubygems_api_key = "x" + @cfg.set_api_key "https://other.example", "y" + assert_equal "x", store.get(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT) + assert_equal "y", store.get("https://other.example") - assert @cfg.credential_store_signed_in? + @cfg.unset_api_key! + + assert_nil store.get(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT) + assert_nil store.get("https://other.example") end end diff --git a/test/rubygems/test_gem_credential_store.rb b/test/rubygems/test_gem_credential_store.rb index 330c16dadd5e..9382041f51a8 100644 --- a/test/rubygems/test_gem_credential_store.rb +++ b/test/rubygems/test_gem_credential_store.rb @@ -31,6 +31,12 @@ def delete(service, account) true end + def delete_all(service) + @calls << [:delete_all, service] + @data.reject! {|(entry_service, _account), _secret| entry_service == service } + true + end + def get_call_count @get_calls end @@ -48,6 +54,10 @@ def set(_service, _account, _secret) def delete(_service, _account) raise Errno::ENOENT, "security" end + + def delete_all(_service) + raise Errno::ENOENT, "security" + end end def setup @@ -163,6 +173,39 @@ def test_instance_returns_the_same_object assert_same Gem::CredentialStore.instance, Gem::CredentialStore.instance end + def test_delete_all_clears_the_service + backend = FakeBackend.new + backend.set(Gem::CredentialStore::SERVICE_NAME, "acct", "s") + store = Gem::CredentialStore.new(backend: backend) + + assert store.delete_all + assert_nil backend.get(Gem::CredentialStore::SERVICE_NAME, "acct") + end + + def test_delete_all_without_backend_is_safe + store = Gem::CredentialStore.new(backend: nil) + refute store.delete_all + end + + def test_delete_all_swallows_backend_errors + store = Gem::CredentialStore.new(backend: RaisingBackend.new) + refute store.delete_all + end + + def test_delete_all_only_removes_its_own_service + backend = FakeBackend.new + Gem::CredentialStore.backend = backend + gem_store = Gem::CredentialStore.for(true, service: "rubygems") + bundler_store = Gem::CredentialStore.for(true, service: "bundler") + gem_store.set("acct", "gem-key") + bundler_store.set("gems.example.com", "user:pass") + + gem_store.delete_all + + assert_nil gem_store.get("acct") + assert_equal "user:pass", bundler_store.get("gems.example.com") + end + def test_for_returns_nil_when_disabled assert_nil Gem::CredentialStore.for(false) assert_nil Gem::CredentialStore.for(nil) diff --git a/test/rubygems/test_gem_credential_store_linux_backend.rb b/test/rubygems/test_gem_credential_store_linux_backend.rb index a58c58c1854e..cc995d053b06 100644 --- a/test/rubygems/test_gem_credential_store_linux_backend.rb +++ b/test/rubygems/test_gem_credential_store_linux_backend.rb @@ -108,6 +108,27 @@ def test_delete_returns_false_on_other_failure end end + def test_delete_all_clears_by_service_only + with_fake_env(exit: 0) do + assert Gem::CredentialStore::LinuxBackend.delete_all("rubygems") + end + + record = read_record + assert_equal %w[clear service rubygems], record["argv"] + end + + def test_delete_all_returns_true_when_nothing_matched + with_fake_env(stderr: "", exit: 1) do + assert Gem::CredentialStore::LinuxBackend.delete_all("rubygems") + end + end + + def test_delete_all_returns_false_on_other_failure + with_fake_env(stderr: "unexpected D-Bus error", exit: 1) do + refute Gem::CredentialStore::LinuxBackend.delete_all("rubygems") + end + end + private def with_fake_env(stdout: "", stderr: "", exit: 0) diff --git a/test/rubygems/test_gem_credential_store_macos_backend.rb b/test/rubygems/test_gem_credential_store_macos_backend.rb index ac003c83477d..eb7459991110 100644 --- a/test/rubygems/test_gem_credential_store_macos_backend.rb +++ b/test/rubygems/test_gem_credential_store_macos_backend.rb @@ -121,6 +121,32 @@ def test_delete_returns_false_on_other_failure end end + def test_delete_all_loops_until_not_found + # security deletes one entry per call; stub exits 0 twice, then 44. + counter = File.join(@tempdir, "counter") + File.write(counter, "0") + script = <<~RUBY + #!/usr/bin/env ruby + c = File.read(#{counter.inspect}).to_i + File.write(#{counter.inspect}, (c + 1).to_s) + exit(c < 2 ? 0 : 44) + RUBY + File.write(File.join(@fake_bin_dir, "security"), script) + File.chmod(0o755, File.join(@fake_bin_dir, "security")) + + with_env(ENV.to_h.merge("PATH" => [@fake_bin_dir, ENV["PATH"]].join(File::PATH_SEPARATOR))) do + assert Gem::CredentialStore::MacOSBackend.delete_all("rubygems") + end + + assert_equal 3, File.read(counter).to_i + end + + def test_delete_all_returns_false_on_error + with_fake_env(exit: 1) do + refute Gem::CredentialStore::MacOSBackend.delete_all("rubygems") + end + end + def test_get_raises_when_command_missing empty_dir = File.join(@tempdir, "empty-bin") FileUtils.mkdir_p(empty_dir) diff --git a/test/rubygems/test_gem_credential_store_windows_backend.rb b/test/rubygems/test_gem_credential_store_windows_backend.rb index 54942f9c61ff..77721587df44 100644 --- a/test/rubygems/test_gem_credential_store_windows_backend.rb +++ b/test/rubygems/test_gem_credential_store_windows_backend.rb @@ -99,6 +99,26 @@ def test_delete_returns_false_on_other_failure end end + def test_delete_all_passes_service_and_succeeds + with_fake_env(exit: 0) do + assert Gem::CredentialStore::WindowsBackend.delete_all("rubygems") + end + + assert_equal "rubygems", read_record["service_env"] + end + + def test_delete_all_returns_true_when_resource_missing + with_fake_env(stderr: "Element not found. (Exception from HRESULT: 0x80070490)", exit: 1) do + assert Gem::CredentialStore::WindowsBackend.delete_all("rubygems") + end + end + + def test_delete_all_returns_false_on_other_failure + with_fake_env(stderr: "Access is denied.", exit: 1) do + refute Gem::CredentialStore::WindowsBackend.delete_all("rubygems") + end + end + def test_uses_powershell_exe_not_pwsh with_fake_env(stdout: "s3cr3t\n", exit: 0) do Gem::CredentialStore::WindowsBackend.get("rubygems", "example.org") From 481355ec9423efa2e46436fb569d004efabf15fd Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 14:34:27 +0900 Subject: [PATCH 23/23] Fix credential store tests under ruby-core and JRuby The config file test referenced Gem::CredentialStore before anything required it, which only worked when another test loaded the constant first, so it failed under ruby-core's test-all where the load order differs. Require it directly. The macOS backend test expected Open3 to raise Errno::ENOENT for a missing security binary, but JRuby reports a failure status instead of raising, so assert only that no credential comes back either way. Co-Authored-By: Claude Fable 5 --- test/rubygems/test_gem_config_file.rb | 1 + .../test_gem_credential_store_macos_backend.rb | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/test/rubygems/test_gem_config_file.rb b/test/rubygems/test_gem_config_file.rb index 63389a813bc2..dbf1ca543539 100644 --- a/test/rubygems/test_gem_config_file.rb +++ b/test/rubygems/test_gem_config_file.rb @@ -2,6 +2,7 @@ require_relative "helper" require "rubygems/config_file" +require "rubygems/credential_store" class TestGemConfigFile < Gem::TestCase def setup diff --git a/test/rubygems/test_gem_credential_store_macos_backend.rb b/test/rubygems/test_gem_credential_store_macos_backend.rb index eb7459991110..7aa0ce967100 100644 --- a/test/rubygems/test_gem_credential_store_macos_backend.rb +++ b/test/rubygems/test_gem_credential_store_macos_backend.rb @@ -147,14 +147,24 @@ def test_delete_all_returns_false_on_error end end - def test_get_raises_when_command_missing + def test_get_returns_no_credential_when_command_missing empty_dir = File.join(@tempdir, "empty-bin") FileUtils.mkdir_p(empty_dir) + # A missing security binary must not yield a credential. MRI raises + # Errno::ENOENT from Open3; other implementations (JRuby) report a + # failure status instead of raising, so accept either and assert only + # that nothing is returned. Gem::CredentialStore#get traps the error + # class either way. with_env(ENV.to_h.merge("PATH" => empty_dir)) do - assert_raise(Errno::ENOENT) do - Gem::CredentialStore::MacOSBackend.get("rubygems", "example.org") - end + result = + begin + Gem::CredentialStore::MacOSBackend.get("rubygems", "example.org") + rescue StandardError + nil + end + + assert_nil result end end