diff --git a/Manifest.txt b/Manifest.txt index 8d7b9a0f0961..08f7a7e3efcc 100644 --- a/Manifest.txt +++ b/Manifest.txt @@ -395,6 +395,10 @@ 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/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/bundler/man/bundle-config.1 b/lib/bundler/man/bundle-config.1 index c055c8a415b0..8f4f0ddd359d 100644 --- a/lib/bundler/man/bundle-config.1 +++ b/lib/bundler/man/bundle-config.1 @@ -101,6 +101,8 @@ The CLI flag and this setting apply uniformly to every source, including ones de .IP Cooldown filtering depends on the gem server providing a per\-version \fBcreated_at\fR timestamp in the v2 compact\-index format\. Versions without that metadata \- older gem servers, historical entries that predate the v2 cutover on \fBrubygems\.org\fR, or private registries that still emit the v1 format \- are treated as outside the cooldown window and remain resolvable\. If you rely on cooldown for supply\-chain protection, confirm that the gem server emits \fBcreated_at\fR in its \fB/info/\fR responses\. .IP "\(bu" 4 +\fBcredential_store\fR (\fBBUNDLE_CREDENTIAL_STORE\fR): Experimental: store and read host credentials (the values otherwise set via \fBbundle config set \fR) in 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 \fBdeployment\fR (\fBBUNDLE_DEPLOYMENT\fR): Equivalent to setting \fBfrozen\fR to \fBtrue\fR and \fBpath\fR to \fBvendor/bundle\fR\. diff --git a/lib/bundler/man/bundle-config.1.ronn b/lib/bundler/man/bundle-config.1.ronn index 72f891b428d5..989ef7178f27 100644 --- a/lib/bundler/man/bundle-config.1.ronn +++ b/lib/bundler/man/bundle-config.1.ronn @@ -167,6 +167,19 @@ learn more about their operation in [bundle install(1)](bundle-install.1.html). window and remain resolvable. If you rely on cooldown for supply-chain protection, confirm that the gem server emits `created_at` in its `/info/` responses. +* `credential_store` (`BUNDLE_CREDENTIAL_STORE`): + Experimental: store and read host credentials (the values otherwise set + via `bundle config set `) in 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, 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/bundler/settings.rb b/lib/bundler/settings.rb index fd77c2f7fc76..b2ef51574517 100644 --- a/lib/bundler/settings.rb +++ b/lib/bundler/settings.rb @@ -62,6 +62,7 @@ class Settings bin cache_path console + credential_store default_cli_command gem.ci gem.github_username @@ -185,7 +186,7 @@ def mirror_for(uri) end def credentials_for(uri) - self[uri.to_s] || self[uri.host] + credentials_from_store(uri) || self[uri.to_s] || self[uri.host] end def gem_mirrors @@ -389,6 +390,79 @@ def is_userinfo(value) value.include?(":") end + ## + # 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. + + # 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 + + store_class = credential_store_class + return nil unless store_class + + store_class.for(spec, service: CREDENTIAL_STORE_SERVICE) + 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 + + # 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 + # 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 + + # 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) return [] unless value value.tr(" ", ":").split(":").map(&:to_sym) @@ -402,10 +476,24 @@ def array_to_s(array) def set_key(raw_key, value, hash, file) raw_key = self.class.key_to_s(raw_key) - value = array_to_s(value) if is_array(raw_key) - key = key_for(raw_key) + if (store = active_credential_store) && credential_store_key?(raw_key) + if value.nil? + store.delete(key) + 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 + + value = array_to_s(value) if is_array(raw_key) + return if hash[key] == value hash[key] = value diff --git a/lib/rubygems/commands/push_command.rb b/lib/rubygems/commands/push_command.rb index 02931b30252a..15523c0b3083 100644 --- a/lib/rubygems/commands/push_command.rb +++ b/lib/rubygems/commands/push_command.rb @@ -17,7 +17,9 @@ 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 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/commands/signin_command.rb b/lib/rubygems/commands/signin_command.rb index 0f77908c5bfb..033884474747 100644 --- a/lib/rubygems/commands/signin_command.rb +++ b/lib/rubygems/commands/signin_command.rb @@ -21,7 +21,9 @@ 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 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 bdd01e4393f5..fcc2428dad5a 100644 --- a/lib/rubygems/commands/signout_command.rb +++ b/lib/rubygems/commands/signout_command.rb @@ -9,7 +9,10 @@ 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. It removes"\ + " the ~/.gem/credentials file. If the :credential_store: gemrc option is"\ + " 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: @@ -18,15 +21,16 @@ 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 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 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 d5e9eb4e33ae..2999c105ec39 100644 --- a/lib/rubygems/config_file.rb +++ b/lib/rubygems/config_file.rb @@ -63,6 +63,14 @@ class Gem::ConfigFile DEFAULT_INSTALL_EXTENSION_IN_LIB = true DEFAULT_GLOBAL_GEM_CACHE = false DEFAULT_USE_PSYCH = false + DEFAULT_CREDENTIAL_STORE = false + + ## + # The account name under which the default RubyGems.org API key is + # stored in the credential store, mirroring the +:rubygems_api_key+ + # symbol used by the plain text credentials file. + + CREDENTIAL_STORE_DEFAULT_ACCOUNT = "rubygems_api_key" ## # For Ruby packagers to set configuration defaults. Set in @@ -185,6 +193,17 @@ class Gem::ConfigFile attr_reader :ssl_client_cert + ## + # == Experimental == + # 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 + ## # Create the config file object. +args+ is the list of arguments # from the command line. @@ -219,6 +238,7 @@ def initialize(args) @ipv4_fallback_enabled = ENV["IPV4_FALLBACK_ENABLED"] == "true" || DEFAULT_IPV4_FALLBACK_ENABLED @global_gem_cache = ENV["RUBYGEMS_GLOBAL_GEM_CACHE"] == "true" || DEFAULT_GLOBAL_GEM_CACHE @use_psych = ENV["RUBYGEMS_USE_PSYCH"] == "true" || DEFAULT_USE_PSYCH + @credential_store = 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) @@ -241,7 +261,7 @@ def initialize(args) # gemhome and gempath are not working with symbol keys if %w[backtrace bulk_threshold verbose update_sources cert_expiration_length_days concurrent_downloads install_extension_in_lib ipv4_fallback_enabled - global_gem_cache use_psych sources + global_gem_cache use_psych credential_store sources disable_default_gem_server ssl_verify_mode ssl_ca_cert ssl_client_cert].include?(k) k.to_sym else @@ -260,6 +280,7 @@ def initialize(args) @ipv4_fallback_enabled = @hash[:ipv4_fallback_enabled] if @hash.key? :ipv4_fallback_enabled @global_gem_cache = @hash[:global_gem_cache] if @hash.key? :global_gem_cache @use_psych = @hash[:use_psych] if @hash.key? :use_psych + @credential_store = @hash[:credential_store] if @hash.key? :credential_store @home = @hash[:gemhome] if @hash.key? :gemhome @path = @hash[:gempath] if @hash.key? :gempath @@ -358,35 +379,66 @@ 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 + 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 @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 + ## # 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 + 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 ## - # Remove the +~/.gem/credentials+ file to clear all the current sessions. + # Remove the +~/.gem/credentials+ file to clear all the current sessions, + # 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_all + return false unless File.exist?(credentials_path) File.delete(credentials_path) @@ -634,6 +686,56 @@ def self.deep_transform_config_keys!(config) config end + def active_credential_store + return nil unless credential_store + + require_relative "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. + 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) @config_file_name = ENV["GEMRC"] need_config_file_name = false diff --git a/lib/rubygems/credential_store.rb b/lib/rubygems/credential_store.rb new file mode 100644 index 000000000000..4ff1ef02b16c --- /dev/null +++ b/lib/rubygems/credential_store.rb @@ -0,0 +1,234 @@ +# 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: +# +# * macOS: Keychain, via the +security+ command line tool. +# * Linux: the Secret Service API (GNOME Keyring, KWallet, ...), via +# +secret-tool+. +# * 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 +# locked keychain over SSH, a headless Linux session without a keyring +# daemon, ...). + +class Gem::CredentialStore + SERVICE_NAME = "rubygems" + + ## + # 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". +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+, or inject a shared backend via #backend=. + + def self.for(spec, service: SERVICE_NAME) + return nil unless spec + return @override if defined?(@override) && @override + + backend = defined?(@override_backend) && @override_backend ? @override_backend : backend_for(spec) + (@instances ||= {})[[spec, service]] ||= new(backend: backend, service: service) + 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 + self.for(true) + end + + ## + # Installs a stand-in store that .for returns for any enabled setting. + # Intended for tests that inject a fake backend. + + def self.instance=(store) + @override = store + end + + ## + # 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 + + def self.warn_once(message) + return if @warned + @warned = true + 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. " \ + "Install a gem that provides rubygems/credential_store/backends/#{name}, " \ + "or unset the credential_store setting. 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/native/windows" + WindowsBackend + elsif RUBY_PLATFORM.include?("darwin") + require_relative "credential_store/native/macos" + MacOSBackend + elsif RUBY_PLATFORM.include?("linux") + require_relative "credential_store/native/linux" + LinuxBackend if LinuxBackend.available? + end + end + + ## + # +backend+ is only used by tests to inject a fake backend regardless of + # 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, service: SERVICE_NAME) + @backend = backend + @service = service + @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, 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, 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, account) + @cache.delete(account) + result + rescue StandardError => e + warn_failure(e) + 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) + self.class.warn_once "Credential store unavailable (#{error.class}: #{error.message}); falling back to file storage." + end +end diff --git a/lib/rubygems/credential_store/native/linux.rb b/lib/rubygems/credential_store/native/linux.rb new file mode 100644 index 000000000000..f36571f961c1 --- /dev/null +++ b/lib/rubygems/credential_store/native/linux.rb @@ -0,0 +1,67 @@ +# 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 + + # 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 new file mode 100644 index 000000000000..81127f7de374 --- /dev/null +++ b/lib/rubygems/credential_store/native/macos.rb @@ -0,0 +1,74 @@ +# 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. +# +# 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( + "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 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) + 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 + + # 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 + private_class_method :quote +end diff --git a/lib/rubygems/credential_store/native/windows.rb b/lib/rubygems/credential_store/native/windows.rb new file mode 100644 index 000000000000..f28043634605 --- /dev/null +++ b/lib/rubygems/credential_store/native/windows.rb @@ -0,0 +1,99 @@ +# 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 + + # 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 + + 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/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/spec/bundler/settings_spec.rb b/spec/bundler/settings_spec.rb index 5e1aaaa55511..8d677e1421e5 100644 --- a/spec/bundler/settings_spec.rb +++ b/spec/bundler/settings_spec.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true require "bundler/settings" +require "rubygems/credential_store" +require_relative "../support/fake_credential_backend" RSpec.describe Bundler::Settings do subject(:settings) { described_class.new(bundled_app) } @@ -268,6 +270,163 @@ 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 "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 "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" + 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 + 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 "round-trips credentials through the selected backend" do + settings.set_local "gemserver.example.org", 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 + + 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 + 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(Bundler::Settings.key_for("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 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 + account = Bundler::Settings.key_for("gemserver.example.org") + settings.set_local "gemserver.example.org", "username:password" + expect(fake_store.get(account)).to eq("username:password") + + settings.set_local "gemserver.example.org", nil + + expect(fake_store.get(account)).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..a95cea4ebdc9 --- /dev/null +++ b/spec/support/fake_credential_backend.rb @@ -0,0 +1,28 @@ +# 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 + + 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 new file mode 100644 index 000000000000..315aaeca0cbb --- /dev/null +++ b/test/rubygems/fake_credential_backend.rb @@ -0,0 +1,31 @@ +# 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 + + def delete_all(service) + @data.reject! {|(entry_service, _account), _secret| entry_service == service } + true + end +end diff --git a/test/rubygems/helper.rb b/test/rubygems/helper.rb index 4c47d46835e3..2a4566a1a858 100644 --- a/test/rubygems/helper.rb +++ b/test/rubygems/helper.rb @@ -46,6 +46,7 @@ require "rubygems/vendor/uri/lib/uri" require "zlib" require_relative "mock_gem_ui" +require_relative "fake_credential_backend" # JRuby on Windows raises TypeError inside File.symlink (the wincode helper # trips on a nil path), so any test that exercises Gem::Installer's symlink @@ -582,6 +583,19 @@ def credential_teardown FileUtils.rm_rf @temp_cred end + ## + # Runs the block with Gem::CredentialStore.instance backed by an + # in-memory Gem::FakeCredentialBackend, so credential_store-enabled code paths + # can be exercised without touching a real OS credential store. + + def with_fake_credential_store + require "rubygems/credential_store" + Gem::CredentialStore.instance = Gem::CredentialStore.new(backend: Gem::FakeCredentialBackend.new) + yield Gem::CredentialStore.instance + ensure + Gem::CredentialStore.reset! + end + def common_installer_setup common_installer_teardown diff --git a/test/rubygems/test_gem_commands_signout_command.rb b/test/rubygems/test_gem_commands_signout_command.rb index 999a14080f20..03498df58473 100644 --- a/test/rubygems/test_gem_commands_signout_command.rb +++ b/test/rubygems/test_gem_commands_signout_command.rb @@ -27,4 +27,22 @@ 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_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, "rubygems-key") + store.set("https://other.example", "other-key") + + @sign_out_ui = Gem::MockGemUi.new + use_ui(@sign_out_ui) { @cmd.execute } + + 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 + end end diff --git a/test/rubygems/test_gem_config_file.rb b/test/rubygems/test_gem_config_file.rb index 3c79cb0762d7..dbf1ca543539 100644 --- a/test/rubygems/test_gem_config_file.rb +++ b/test/rubygems/test_gem_config_file.rb @@ -2,6 +2,7 @@ require_relative "helper" require "rubygems/config_file" +require "rubygems/credential_store" class TestGemConfigFile < Gem::TestCase def setup @@ -457,6 +458,171 @@ 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_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_store_and_clears_file + @cfg.credential_store = true + + with_fake_credential_store do |store| + @cfg.rubygems_api_key = "x" + + assert_equal "x", @cfg.rubygems_api_key + assert_equal "x", store.get(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT) + # 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_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| + @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 + + 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) + + 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 + @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 + + 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_unset_api_key_bang_removes_every_host_from_credential_store + @cfg.credential_store = true + + 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") + + @cfg.unset_api_key! + + assert_nil store.get(Gem::ConfigFile::CREDENTIAL_STORE_DEFAULT_ACCOUNT) + assert_nil store.get("https://other.example") + 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 diff --git a/test/rubygems/test_gem_credential_store.rb b/test/rubygems/test_gem_credential_store.rb new file mode 100644 index 000000000000..9382041f51a8 --- /dev/null +++ b/test/rubygems/test_gem_credential_store.rb @@ -0,0 +1,270 @@ +# 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 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 + 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 + + def delete_all(_service) + 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 + + 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) + 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 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..cc995d053b06 --- /dev/null +++ b/test/rubygems/test_gem_credential_store_linux_backend.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +require_relative "helper" +require "rubygems/credential_store/native/linux" +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 + + 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) + 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 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..7aa0ce967100 --- /dev/null +++ b/test/rubygems/test_gem_credential_store_macos_backend.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require_relative "helper" +require "rubygems/credential_store/native/macos" +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_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") + 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_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_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 + result = + begin + Gem::CredentialStore::MacOSBackend.get("rubygems", "example.org") + rescue StandardError + nil + end + + assert_nil result + 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 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..77721587df44 --- /dev/null +++ b/test/rubygems/test_gem_credential_store_windows_backend.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +require_relative "helper" +require "rubygems/credential_store/native/windows" +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_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") + 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 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" }