Class: Gem::CredentialStore
| Relationships & Source Files | |
| Namespace Children | |
|
Classes:
| |
| Inherits: | Object |
| Defined in: | 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 |
Overview
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
securitycommand line tool. - Linux: the Secret Service API (GNOME Keyring, KWallet, ...), via
secret-tool. - Windows: Credential Manager, via the
Windows.Security.Credentials.PasswordVaultAPI from PowerShell.
A third party can add another backend (1Password, pass, HashiCorp Vault,
...) by shipping a gem that provides
rubygems/credential_store/backends/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, ...).
Constant Summary
-
BACKEND_NAME =
# File 'lib/rubygems/credential_store.rb', line 126/\A[a-z0-9_-]+\z/ -
OUTCOMES =
# File 'lib/rubygems/credential_store.rb', line 313
What happens after a failure depends on the operation, so the warning has to say the right thing for each.
{ read: "any copy left in the config file will be used instead", write: "falling back to file storage", remove: "the credential is still in the store", list: "stored credentials will not be listed", }.freeze -
PRINTABLE_ASCII =
# File 'lib/rubygems/credential_store.rb', line 309
The macOS keychain hands non-printable bytes back as hex through the only read-back its CLI offers. Applied to every backend so the same value is stored, or refused for the same reason, everywhere.
/\A[\x20-\x7e]*\z/ -
SERVICE_NAME =
# File 'lib/rubygems/credential_store.rb', line 31"rubygems"
Class Attribute Summary
-
.backend=(backend)
writeonly
Installs a shared backend that .for wraps for every spec and service.
-
.instance
rw
The default-backed store for this platform, i.e. for(true).
-
.instance=(store)
rw
Installs a stand-in store that .for returns for any enabled setting.
-
.warn_handler=(handler)
writeonly
Sends warnings to
handler(anything responding to#call) instead of ui.
Class Method Summary
- .default_backend
-
.for(spec, service: SERVICE_NAME)
Returns the store to use for
spec, ornilwhen the credential store is off. -
.new(backend: self.class.default_backend, service: SERVICE_NAME) ⇒ CredentialStore
constructor
backendis only used by tests to inject a fake backend regardless of the platform the test suite happens to run on. -
.register_backend(name, backend)
Registers
backendundernameso it can be selected with credential_store =. -
.reset!
Clears the memoized stores, the injected overrides, and the warned messages.
-
.resolve_backend(name)
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. -
.warn_once(message)
Warns once per distinct message.
- .backend_for(spec) private
Instance Attribute Summary
-
#available? ⇒ Boolean
readonly
True if a native credential backend is usable on this platform.
Instance Method Summary
-
#delete(account)
Removes the secret stored for
account. -
#delete_all
Removes every entry this store owns (all accounts under its service).
-
#get(account)
Returns the secret stored for
account, ornilif there is none or the backend is unavailable/fails. -
#list
The accounts this store holds, or
nilwhen the backend cannot enumerate them. -
#read_failed?(account) ⇒ Boolean
True when #get returned
nilforaccountbecause the backend could not answer, rather than because nothing is stored under it. -
#set(account, secret)
Stores
secretforaccount. -
#invalidate_list
private
The listing is memoized, so a write has to drop it.
-
#validate_credential(account, secret)
private
A newline in an account would start a second command in the macOS batch input.
- #warn_failure(operation, error) private
Constructor Details
.new(backend: self.class.default_backend, service: SERVICE_NAME) ⇒ CredentialStore
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.
# File 'lib/rubygems/credential_store.rb', line 183
def initialize(backend: self.class.default_backend, service: SERVICE_NAME) @backend = backend @service = service @cache = {} end
Class Attribute Details
.backend=(backend) (writeonly)
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.
# File 'lib/rubygems/credential_store.rb', line 73
def self.backend=(backend) @override_backend = backend end
.instance (rw)
The default-backed store for this platform, i.e. for(true). Kept for callers and tests that only care about the native backend.
# File 'lib/rubygems/credential_store.rb', line 56
def self.instance self.for(true) end
.instance=(store) (rw)
Installs a stand-in store that .for returns for any enabled setting. Intended for tests that inject a fake backend.
# File 'lib/rubygems/credential_store.rb', line 64
def self.instance=(store) @override = store end
.warn_handler=(handler) (writeonly)
# File 'lib/rubygems/credential_store.rb', line 113
def self.warn_handler=(handler) @warn_handler = handler end
Class Method Details
.backend_for(spec) (private)
[ GitHub ]# File 'lib/rubygems/credential_store.rb', line 160
def self.backend_for(spec) spec == true ? default_backend : resolve_backend(spec) end
.default_backend
[ GitHub ]# File 'lib/rubygems/credential_store.rb', line 165
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
.for(spec, service: SERVICE_NAME)
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=.
# File 'lib/rubygems/credential_store.rb', line 44
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
.register_backend(name, backend)
Registers backend under name so it can be selected with
credential_store =
# File 'lib/rubygems/credential_store.rb', line 122
def self.register_backend(name, backend) (@backends ||= {})[name.to_s] = backend end
.reset!
Clears the memoized stores, the injected overrides, and the warned messages. Intended for tests only.
# File 'lib/rubygems/credential_store.rb', line 81
def self.reset! @override = nil @override_backend = nil @instances = nil @warned = nil @warn_handler = nil end
.resolve_backend(name)
Resolves a registered backend by name, requiring
rubygems/credential_store/backends/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.
# File 'lib/rubygems/credential_store.rb', line 137
def self.resolve_backend(name) # The setting can carry bytes Regexp#match? would reject. A name that gets # past the match is ASCII only, so the require path it builds stays sound. name = name.to_s.b 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
.warn_once(message)
Warns once per distinct message. A single flag for every message would let an early warning about, say, a misspelled backend name suppress the later warning that a secret was written in plain text.
Instance Attribute Details
#available? ⇒ Boolean (readonly)
True if a native credential backend is usable on this platform.
# File 'lib/rubygems/credential_store.rb', line 192
def available? !@backend.nil? end
Instance Method Details
#delete(account)
Removes the secret stored for account. Returns true if the entry is
gone, whether or not it existed beforehand.
# File 'lib/rubygems/credential_store.rb', line 250
def delete(account) return false unless @backend result = @backend.delete(@service, account) @cache.delete(account) @failed&.delete(account) invalidate_list result rescue StandardError => e warn_failure(:remove, e) false end
#delete_all
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.
# File 'lib/rubygems/credential_store.rb', line 286
def delete_all return false unless @backend result = @backend.delete_all(@service) @cache.clear @failed = nil invalidate_list result rescue StandardError => e warn_failure(:remove, e) false end
#get(account)
Returns the secret stored for account, or nil if there is none or
the backend is unavailable/fails.
# File 'lib/rubygems/credential_store.rb', line 200
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(:read, e) # Retrying means another subprocess and, on some platforms, another # authorization prompt. #read_failed? keeps this apart from an absent one. (@failed ||= {})[account] = true @cache[account] = nil end
#invalidate_list (private)
The listing is memoized, so a write has to drop it.
# File 'lib/rubygems/credential_store.rb', line 302
def invalidate_list remove_instance_variable(:@list) if defined?(@list) end
#list
The accounts this store holds, or nil when the backend cannot
enumerate them. Listing is optional in the backend protocol: the native
backends implement it, but a third-party backend that only resolves
credentials on demand has nothing to enumerate. Callers must treat nil
as "unknown", not as "empty". Secrets are never returned.
# File 'lib/rubygems/credential_store.rb', line 270
def list return nil unless @backend.respond_to?(:list) return @list if defined?(@list) @list = @backend.list(@service) rescue StandardError => e warn_failure(:list, e) # Remembered for the same reason #get remembers a failed read. @list = nil end
#read_failed?(account) ⇒ Boolean
True when #get returned nil for account because the backend could not
answer, rather than because nothing is stored under it. Callers that would
otherwise fall back to a different credential need the difference: a
missing entry means "use something else", an unreadable one does not.
# File 'lib/rubygems/credential_store.rb', line 219
def read_failed?(account) return false unless defined?(@failed) && @failed @failed.key?(account) end
#set(account, secret)
Stores secret for account. Returns true on success.
# File 'lib/rubygems/credential_store.rb', line 228
def set(account, secret) return false unless @backend validate_credential(account, secret) if @backend.set(@service, account, secret) @cache[account] = secret @failed&.delete(account) invalidate_list true else false end rescue StandardError => e warn_failure(:write, e) false end
#validate_credential(account, secret) (private)
A newline in an account would start a second command in the macOS batch input. #set turns the raise back into a warning and a false.
# File 'lib/rubygems/credential_store.rb', line 322
def validate_credential(account, secret) raise ArgumentError, "credential secret must be printable ASCII" unless secret.to_s.b.match?(PRINTABLE_ASCII) raise ArgumentError, "credential account must not contain a newline" if account.to_s.include?("\n") raise ArgumentError, "credential service must not contain a newline" if @service.to_s.include?("\n") end