From d11fbc533d42cae3a90c681c2e9d131fed938faa Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 10:20:27 +0900 Subject: [PATCH 01/25] 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 f2ddb0fb2653..3761a2df559a 100644 --- a/Manifest.txt +++ b/Manifest.txt @@ -363,6 +363,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 cd7d4337dff50a11f48224797ba0456b28f3b86b Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 10:21:17 +0900 Subject: [PATCH 02/25] 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 3761a2df559a..953d3acc53bc 100644 --- a/Manifest.txt +++ b/Manifest.txt @@ -364,6 +364,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 325b71a465ee3d2c72cf23a4c0df9da9c4ebaf24 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 10:22:12 +0900 Subject: [PATCH 03/25] 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 953d3acc53bc..7c86906ae0a6 100644 --- a/Manifest.txt +++ b/Manifest.txt @@ -364,6 +364,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 a9b11c4aac6baeaf2a8fe6c011f512e76efc7a92 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 10:23:15 +0900 Subject: [PATCH 04/25] 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 7c86906ae0a6..0de992612604 100644 --- a/Manifest.txt +++ b/Manifest.txt @@ -366,6 +366,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 25f421c920318cc1f3c5536eb2f1059d3bc48e34 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 10:43:42 +0900 Subject: [PATCH 05/25] 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 101e9f89ecb0..432bc06d3704 100644 --- a/lib/rubygems/config_file.rb +++ b/lib/rubygems/config_file.rb @@ -65,6 +65,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 @@ -196,6 +204,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. @@ -231,6 +248,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) @@ -253,7 +271,7 @@ def initialize(args) # gemhome and gempath are not working with symbol keys if %w[backtrace bulk_threshold cooldown 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 @@ -273,6 +291,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 @@ -371,15 +390,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) @@ -397,9 +452,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) @@ -647,6 +706,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 73360ced5c84..6a3fd82ad527 100644 --- a/test/rubygems/helper.rb +++ b/test/rubygems/helper.rb @@ -47,6 +47,7 @@ require "zlib" require_relative "mock_gem_ui" require_relative "pem_utilities" +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 @@ -594,6 +595,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 0ca05e7203ae..17aa5bab625e 100644 --- a/test/rubygems/test_gem_config_file.rb +++ b/test/rubygems/test_gem_config_file.rb @@ -460,6 +460,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 acf31bdb02a15f6aaf53da5dc8ca8f4dcd8ef877 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 10:43:51 +0900 Subject: [PATCH 06/25] 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 3891defb74cb39fba85501fbb31adbdd229c12a7 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 10:43:59 +0900 Subject: [PATCH 07/25] 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 a09c85b7209eb6fad7a3cfcb7efaf581216fd985 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 10:44:25 +0900 Subject: [PATCH 08/25] 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 78fb844eb963..c96f4031ced0 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 eb79efd1b5d5fc7da7a33b3755052744b93626b1 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 10:44:35 +0900 Subject: [PATCH 09/25] 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 c1f8ecf824e1..afcd8afb704d 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 @@ -385,6 +386,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) @@ -398,6 +430,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 9ac3603caf11..b4dfcf965655 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) } @@ -276,6 +278,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 01381e0eb54091edc941b850305ca069d3a9807d Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 10:44:48 +0900 Subject: [PATCH 10/25] 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 0ae7c8b4510a..a7f979ab48a1 100644 --- a/lib/bundler/man/bundle-config.1 +++ b/lib/bundler/man/bundle-config.1 @@ -105,6 +105,8 @@ Cooldown filtering depends on the gem server providing a per\-version \fBcreated .IP A \fBcreated_at\fR timestamp is read as UTC when it carries no time zone offset\. \fBrubygems\.org\fR always sends one, but a third\-party server that omits it would otherwise shift the cooldown window by the offset of whatever machine runs bundler\. .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 f01f43d709c2..db0addb428f6 100644 --- a/lib/bundler/man/bundle-config.1.ronn +++ b/lib/bundler/man/bundle-config.1.ronn @@ -179,6 +179,13 @@ learn more about their operation in [bundle install(1)](bundle-install.1.html). offset. `rubygems.org` always sends one, but a third-party server that omits it would otherwise shift the cooldown window by the offset of whatever machine runs bundler. +* `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 9d09445505df609d21f762b290ad26bcdd1fa25c Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 11:10:32 +0900 Subject: [PATCH 11/25] 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 14c04f90a7adacdeb354e5697e61bf48f12dba0a Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 11:12:32 +0900 Subject: [PATCH 12/25] 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 432bc06d3704..89d75d0e74d6 100644 --- a/lib/rubygems/config_file.rb +++ b/lib/rubygems/config_file.rb @@ -206,10 +206,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 @@ -248,7 +250,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) @@ -710,7 +712,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 17aa5bab625e..881000d1a708 100644 --- a/test/rubygems/test_gem_config_file.rb +++ b/test/rubygems/test_gem_config_file.rb @@ -482,6 +482,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 @@ -509,6 +535,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 aa22e6c0041cb4152390c91f032bbd21c39ced81 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 11:14:26 +0900 Subject: [PATCH 13/25] 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 afcd8afb704d..af0bd80415b6 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 @@ -387,16 +387,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 b4dfcf965655..635b354aedfc 100644 --- a/spec/bundler/settings_spec.rb +++ b/spec/bundler/settings_spec.rb @@ -312,6 +312,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 9ee2fee3ee7f02088e5f42ad356500d7fdecb00f Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 11:15:55 +0900 Subject: [PATCH 14/25] 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 a7f979ab48a1..1d22678daa0a 100644 --- a/lib/bundler/man/bundle-config.1 +++ b/lib/bundler/man/bundle-config.1 @@ -105,7 +105,7 @@ Cooldown filtering depends on the gem server providing a per\-version \fBcreated .IP A \fBcreated_at\fR timestamp is read as UTC when it carries no time zone offset\. \fBrubygems\.org\fR always sends one, but a third\-party server that omits it would otherwise shift the cooldown window by the offset of whatever machine runs bundler\. .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 db0addb428f6..b91c60c510dd 100644 --- a/lib/bundler/man/bundle-config.1.ronn +++ b/lib/bundler/man/bundle-config.1.ronn @@ -181,11 +181,13 @@ learn more about their operation in [bundle install(1)](bundle-install.1.html). offset of whatever machine runs bundler. * `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 c96f4031ced0..1f1ae6eecc2b 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 ae5ecd044f4ac94a50b93c1a382f9b1803f1de04 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 11:38:07 +0900 Subject: [PATCH 15/25] 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 0de992612604..e20b0809a3d9 100644 --- a/Manifest.txt +++ b/Manifest.txt @@ -364,9 +364,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 20a4bd5b5f5cecc7f1f0fc31abb43763ba9721d4 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 11:51:58 +0900 Subject: [PATCH 16/25] 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 af0bd80415b6..16c347cdc686 100644 --- a/lib/bundler/settings.rb +++ b/lib/bundler/settings.rb @@ -424,7 +424,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) @@ -440,19 +443,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 635b354aedfc..bf7453277e86 100644 --- a/spec/bundler/settings_spec.rb +++ b/spec/bundler/settings_spec.rb @@ -293,21 +293,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 @@ -323,8 +340,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 @@ -353,7 +370,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 @@ -378,12 +395,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 18ae9c7868ba1650f861c90940e2ea258842548e Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 12:30:40 +0900 Subject: [PATCH 17/25] 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 16c347cdc686..e82e339cb0a0 100644 --- a/lib/bundler/settings.rb +++ b/lib/bundler/settings.rb @@ -397,8 +397,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 @@ -409,6 +411,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 bf7453277e86..c9ea63544c7b 100644 --- a/spec/bundler/settings_spec.rb +++ b/spec/bundler/settings_spec.rb @@ -355,6 +355,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 581dbbc9804ebcb1d043801e9843498883b73643 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 13:05:36 +0900 Subject: [PATCH 18/25] 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 54016d2641c34d65f0309242c1983f2cea984498 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 13:23:05 +0900 Subject: [PATCH 19/25] 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 4b8c38f11684830928e3295f0a3a0cbd3a0e3596 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 13:30:07 +0900 Subject: [PATCH 20/25] 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 e82e339cb0a0..64cb4f33b45a 100644 --- a/lib/bundler/settings.rb +++ b/lib/bundler/settings.rb @@ -472,8 +472,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 89d75d0e74d6..770689260ae8 100644 --- a/lib/rubygems/config_file.rb +++ b/lib/rubygems/config_file.rb @@ -392,9 +392,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 @@ -433,22 +438,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 @@ -715,6 +718,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 c9ea63544c7b..ed58ddfe43f8 100644 --- a/spec/bundler/settings_spec.rb +++ b/spec/bundler/settings_spec.rb @@ -403,12 +403,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 881000d1a708..ef7cbed6364b 100644 --- a/test/rubygems/test_gem_config_file.rb +++ b/test/rubygems/test_gem_config_file.rb @@ -508,31 +508,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 b4dff4bcfea9f28d7cbce961062ffb497943fcc2 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 13:31:58 +0900 Subject: [PATCH 21/25] 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 1d22678daa0a..fd07c9a8b172 100644 --- a/lib/bundler/man/bundle-config.1 +++ b/lib/bundler/man/bundle-config.1 @@ -105,7 +105,7 @@ Cooldown filtering depends on the gem server providing a per\-version \fBcreated .IP A \fBcreated_at\fR timestamp is read as UTC when it carries no time zone offset\. \fBrubygems\.org\fR always sends one, but a third\-party server that omits it would otherwise shift the cooldown window by the offset of whatever machine runs bundler\. .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 b91c60c510dd..b067b86e592d 100644 --- a/lib/bundler/man/bundle-config.1.ronn +++ b/lib/bundler/man/bundle-config.1.ronn @@ -186,8 +186,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 1f1ae6eecc2b..63452bef73e8 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 abcd8a6cc2c18516a52d515d5405a1b41e61be10 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 14:02:08 +0900 Subject: [PATCH 22/25] 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 64cb4f33b45a..002c688d23da 100644 --- a/lib/bundler/settings.rb +++ b/lib/bundler/settings.rb @@ -393,6 +393,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 @@ -400,7 +405,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 770689260ae8..2614b603083e 100644 --- a/lib/rubygems/config_file.rb +++ b/lib/rubygems/config_file.rb @@ -421,19 +421,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+ @@ -458,11 +445,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 ef7cbed6364b..bfb021819049 100644 --- a/test/rubygems/test_gem_config_file.rb +++ b/test/rubygems/test_gem_config_file.rb @@ -596,15 +596,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 c743eb198aefdff8a0b9a37e03f917167c13a4b7 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 2 Jul 2026 14:34:27 +0900 Subject: [PATCH 23/25] 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 bfb021819049..fb10b0862958 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 From 40a6d0be5696b62dcb3d93026e4e7052935e6f8b Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 24 Aug 2026 20:35:10 +0900 Subject: [PATCH 24/25] Select the bundler credential store per host A single global backend cannot serve a Gemfile whose sources need different credential providers, and a published backend gem cannot know which fallback its users prefer. A credential_store. setting now overrides the global credential_store for that host only, with no chaining between backends, and false keeps a host on the config file. Co-Authored-By: Claude Fable 5 --- lib/bundler/settings.rb | 35 +++++++++++++++++----- spec/bundler/settings_spec.rb | 55 +++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 7 deletions(-) diff --git a/lib/bundler/settings.rb b/lib/bundler/settings.rb index 002c688d23da..48ac9c7c2bfe 100644 --- a/lib/bundler/settings.rb +++ b/lib/bundler/settings.rb @@ -356,7 +356,7 @@ def is_bool(name) def is_string(name) name = self.class.key_to_s(name) - STRING_KEYS.include?(name) || name.start_with?("local.") || name.start_with?("mirror.") || name.start_with?("build.") + STRING_KEYS.include?(name) || name.start_with?("local.") || name.start_with?("mirror.") || name.start_with?("build.") || name.start_with?("credential_store.") end def to_bool(value) @@ -398,8 +398,8 @@ def is_userinfo(value) # host credentials. CREDENTIAL_STORE_SERVICE = "bundler" - def active_credential_store - spec = credential_store_spec + def active_credential_store(host = nil) + spec = credential_store_spec(host) return nil unless spec store_class = credential_store_class @@ -408,8 +408,17 @@ def active_credential_store store_class.for(spec, service: CREDENTIAL_STORE_SERVICE) end - def credential_store_spec - case value = self[:credential_store] + # A `credential_store.` setting overrides the global + # `credential_store` for that host only, so one source can use a + # dedicated backend (say, a CodeArtifact token issuer) while every other + # host keeps the global choice. There is no chain between backends: an + # explicit `false` for a host keeps that host on the config file even + # when a global store is enabled. + def credential_store_spec(host = nil) + value = self["credential_store.#{host}"] if host + value = self[:credential_store] if value.nil? + + case value when nil, "", "false", false then nil when "true", true then true else value.to_s @@ -450,8 +459,20 @@ 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 + # A credential key is either a bare host ("gems.example.com") or a full + # URL ("https://gems.example.com/"); per-host store selection wants the + # host either way. + def credential_host(raw_key) + return raw_key unless raw_key.start_with?("http://", "https://") + + require_relative "vendored_uri" + Gem::URI(raw_key).host || raw_key + rescue Gem::URI::Error + raw_key + end + def credentials_from_store(uri) - return nil unless store = active_credential_store + return nil unless store = active_credential_store(uri.host) # Normalize with key_for so a value stored under, say, # "https://host/" is found when the source URI is "https://host", @@ -474,7 +495,7 @@ 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 credential_store_key?(raw_key) && (store = active_credential_store(credential_host(raw_key))) if value.nil? store.delete(key) elsif value.is_a?(String) && is_userinfo(value) diff --git a/spec/bundler/settings_spec.rb b/spec/bundler/settings_spec.rb index ed58ddfe43f8..1d8ec4d34245 100644 --- a/spec/bundler/settings_spec.rb +++ b/spec/bundler/settings_spec.rb @@ -347,6 +347,61 @@ end end + context "with a per-host credential_store backend" do + let(:host_backend) { FakeCredentialBackend.new } + let(:global_backend) { FakeCredentialBackend.new } + let(:service) { Bundler::Settings::CREDENTIAL_STORE_SERVICE } + + before do + Gem::CredentialStore.register_backend("fake-host", host_backend) + Gem::CredentialStore.register_backend("fake-global", global_backend) + settings.set_local "credential_store", "fake-global" + settings.set_local "credential_store.gemserver.example.org", "fake-host" + end + + after { Gem::CredentialStore.reset! } + + it "reads the host's credentials from the backend selected for that host" do + host_backend.set(service, Bundler::Settings.key_for("gemserver.example.org"), credentials) + + expect(settings.credentials_for(uri)).to eq(credentials) + end + + it "does not chain to the global backend when the host's backend misses" do + global_backend.set(service, Bundler::Settings.key_for("gemserver.example.org"), credentials) + + expect(settings.credentials_for(uri)).to be_nil + end + + it "keeps other hosts on the globally selected backend" do + global_backend.set(service, Bundler::Settings.key_for("other.example.org"), credentials) + + expect(settings.credentials_for(Gem::URI("https://other.example.org/"))).to eq(credentials) + end + + it "writes a host credential to the backend selected for that host" do + settings.set_local "gemserver.example.org", credentials + + expect(host_backend.get(service, Bundler::Settings.key_for("gemserver.example.org"))).to eq(credentials) + expect(global_backend.get(service, Bundler::Settings.key_for("gemserver.example.org"))).to be_nil + end + + it "writes a URL-keyed credential to the backend selected for its host" do + settings.set_local "https://gemserver.example.org/", credentials + + expect(host_backend.get(service, Bundler::Settings.key_for("https://gemserver.example.org/"))).to eq(credentials) + end + + it "keeps a host on the config file when its store is set to false" do + settings.set_local "credential_store.gemserver.example.org", "false" + + settings.set_local "gemserver.example.org", credentials + + expect(settings.locations("gemserver.example.org")[:local]).to eq(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" } From bc8f204418329486ad8d475bfdeaf0b45d4c64e3 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 24 Aug 2026 20:35:15 +0900 Subject: [PATCH 25/25] Document per-host credential_store selection in bundle-config(1) Co-Authored-By: Claude Fable 5 --- lib/bundler/man/bundle-config.1 | 2 ++ lib/bundler/man/bundle-config.1.ronn | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/lib/bundler/man/bundle-config.1 b/lib/bundler/man/bundle-config.1 index fd07c9a8b172..acd5e9b1c076 100644 --- a/lib/bundler/man/bundle-config.1 +++ b/lib/bundler/man/bundle-config.1 @@ -106,6 +106,8 @@ Cooldown filtering depends on the gem server providing a per\-version \fBcreated A \fBcreated_at\fR timestamp is read as UTC when it carries no time zone offset\. \fBrubygems\.org\fR always sends one, but a third\-party server that omits it would otherwise shift the cooldown window by the offset of whatever machine runs bundler\. .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, 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 +The store can also be selected per host with \fBcredential_store\.\fR (\fBBUNDLE_CREDENTIAL_STORE__\fR), for example \fBbundle config set credential_store\.gems\.example\.com code_artifact\fR\. That host then uses only the named backend, with no chaining to the global one, while every other host keeps following the global setting\. Setting a host's value to \fBfalse\fR keeps that one host on the config file even when a global store is enabled\. .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 b067b86e592d..dc9895ea16cb 100644 --- a/lib/bundler/man/bundle-config.1.ronn +++ b/lib/bundler/man/bundle-config.1.ronn @@ -192,6 +192,14 @@ learn more about their operation in [bundle install(1)](bundle-install.1.html). 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. + + The store can also be selected per host with `credential_store.` + (`BUNDLE_CREDENTIAL_STORE__`), for example `bundle config set + credential_store.gems.example.com code_artifact`. That host then uses + only the named backend, with no chaining to the global one, while every + other host keeps following the global setting. Setting a host's value + to `false` keeps that one host on the config file even when a global + store is enabled. * `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