class Gem::CredentialStore::MacOSBackend

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.

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. Those are the limits Gem::CredentialStore#set enforces for every backend, so the caller falls back to file storage rather than storing something that cannot be read back.

Constants

NOT_FOUND_STATUS

Public Class Methods

# File lib/rubygems/credential_store/native/macos.rb, line 63
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 going until it reports there is nothing left (exit 44). Other services are untouched.

# File lib/rubygems/credential_store/native/macos.rb, line 73
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
# File lib/rubygems/credential_store/native/macos.rb, line 24
def self.get(service, account)
  out, err, status = Open3.capture3(
    "security", "find-generic-password", "-a", account, "-s", service, "-w"
  )
  # A locked keychain and an absent entry both yield no secret, but only the
  # second one is ordinary.
  unless status.success?
    return nil if status.exitstatus == NOT_FOUND_STATUS

    raise "security exited with #{status.exitstatus}: #{err.strip}"
  end

  secret = out.chomp
  secret.empty? ? nil : secret
end

security has no “list by service” subcommand, so this reads the dump, which never reports the secrets.

# File lib/rubygems/credential_store/native/macos.rb, line 52
def self.list(service)
  out, status = Open3.capture2("security", "dump-keychain", err: File::NULL)
  return [] unless status.success?

  out.split(/^keychain: /).filter_map do |entry|
    next unless entry[/^\s*"svce"<blob>="(.*)"$/, 1] == service

    entry[/^\s*"acct"<blob>="(.*)"$/, 1]
  end.uniq
end
# File lib/rubygems/credential_store/native/macos.rb, line 40
def self.set(service, account, secret)
  command = "add-generic-password -U -a #{quote(account)} -s #{quote(service)} -w #{quote(secret)}\n"
  _out, err, status = Open3.capture3("security", "-i", stdin_data: command)
  return true if status.success?

  # Raise rather than return false so the reason reaches the user. The
  # wrapper turns it back into false after reporting it.
  raise "security exited with #{status.exitstatus}: #{err.strip}"
end