Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/bundler/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,10 @@ def exec(*args)
require_relative "cli/config"
subcommand "config", Config

desc "credential", "Manage credential helper trust"
require_relative "cli/credential"
subcommand "credential", Credential

desc "open GEM", "Opens the source directory of the given bundled gem"
method_option "path", type: :string, lazy_default: "", banner: "Open relative path of the gem source."
def open(name)
Expand Down
29 changes: 29 additions & 0 deletions lib/bundler/cli/credential.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# frozen_string_literal: true

require_relative "../vendored_thor"

module Bundler
class CLI::Credential < Thor
desc "trust HOST", "Trust the configured credential helper for a registry host"
def trust(host)
require_relative "../credential_helper"
record = Bundler::CredentialHelper.trust(host)
Bundler.ui.info <<~MESSAGE
Trusted credential helper:
Host: #{record["host"]}
Path: #{record["path"]}
SHA-256: #{record["sha256"]}
MESSAGE
end

desc "untrust HOST", "Remove trust for a registry host"
def untrust(host)
require_relative "../credential_helper"
if Bundler::CredentialHelper.untrust(host)
Bundler.ui.info "Removed credential helper trust for #{host.downcase}"
else
Bundler.ui.info "No credential helper is trusted for #{host.downcase}"
end
end
end
end
168 changes: 168 additions & 0 deletions lib/bundler/credential_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# frozen_string_literal: true

require_relative "vendored_uri"

module Bundler
class CredentialHelper
TRUST_FILE = "credential_helpers"

class << self
def fetch(host, configured_path)
new(host, configured_path).fetch
end

def trust(host)
host = normalized_host(host)
configured_path = Bundler.settings["credential.helper.#{host}"]
raise InvalidOption, "No credential helper is configured for #{host}" unless configured_path

helper = new(host, configured_path)
record = helper.record
store = load_store
store[host] = record
write_store(store)
record
end

def untrust(host)
host = normalized_host(host)
store = load_store
removed = store.delete(host)
write_store(store) if removed
removed
end

private

def normalized_host(host)
host = host.to_s.downcase
raise Gem::URI::InvalidComponentError if host.empty?

Gem::URI::Generic.build(host: host).host
rescue Gem::URI::InvalidComponentError
raise InvalidOption, "Invalid registry host: #{host.inspect}"
end

def trust_file
Bundler.user_bundle_path.join(TRUST_FILE)
end

def load_store
file = trust_file
return {} unless file.file?

require "rubygems/yaml_serializer"
store = Gem::YAMLSerializer.load(file.read) || {}
validate_store!(store)
store
rescue InvalidOption
raise
rescue StandardError
raise InvalidOption, "Could not read credential helper trust file #{file}"
end

def validate_store!(store)
valid = store.is_a?(Hash) && store.all? do |host, record|
record.is_a?(Hash) && record["host"] == host &&
record["path"].is_a?(String) && record["sha256"].match?(/\A[0-9a-f]{64}\z/)
end
raise InvalidOption, "Invalid credential helper trust file #{trust_file}" unless valid
end

def write_store(store)
file = trust_file
SharedHelpers.filesystem_access(file.dirname, :create) do |dir|
FileUtils.mkdir_p(dir, mode: 0o700)
end

require "rubygems/yaml_serializer"
require "rubygems/util/atomic_file_writer"
SharedHelpers.filesystem_access(file, :write) do
Gem::AtomicFileWriter.open(file) do |io|
io.chmod(0o600) unless Gem.win_platform?
io.write(Gem::YAMLSerializer.dump(store))
io.flush
begin
io.fsync
rescue NotImplementedError, SystemCallError
nil
end
end
end
end
end

def initialize(host, configured_path)
@host = self.class.send(:normalized_host, host)
@configured_path = configured_path.to_s
end

def fetch
trusted = self.class.send(:load_store)[@host]
current = record
unless trusted
warn_untrusted(current["path"])
return
end

unless trusted == current
Bundler.ui.warn "Credential helper for #{@host} at #{current["path"]} has changed; run `bundle credential trust #{@host}` again"
return
end

output = Bundler.with_unbundled_env do
IO.popen([current["path"]], err: File::NULL, &:read)
end
status = Process.last_status
unless status&.success?
Bundler.ui.warn "Credential helper for #{@host} at #{current["path"]} failed with exit status #{status&.exitstatus}"
return
end

output = output.to_s.strip
if output.empty?
Bundler.ui.warn "Credential helper for #{@host} at #{current["path"]} returned no credentials"
return
end
output
rescue InvalidOption => e
Bundler.ui.warn e.message
nil
rescue StandardError
Bundler.ui.warn "Credential helper for #{@host} at #{@configured_path} failed"
nil
end

def record
path = Pathname.new(@configured_path)
unless path.absolute?
raise InvalidOption, "Credential helper for #{@host} must be an absolute path: #{@configured_path}"
end

real_path = File.realpath(path)
unless File.file?(real_path)
raise InvalidOption, "Credential helper for #{@host} is not a regular file: #{real_path}"
end
unless File.executable?(real_path)
raise InvalidOption, "Credential helper for #{@host} is not executable: #{real_path}"
end

require "digest/sha2"
{
"host" => @host,
"path" => real_path,
"sha256" => ::Digest::SHA256.file(real_path).hexdigest,
}
rescue Errno::ENOENT, Errno::ENOTDIR
raise InvalidOption, "Credential helper for #{@host} does not exist: #{@configured_path}"
rescue Errno::EACCES
raise InvalidOption, "Credential helper for #{@host} cannot be accessed: #{@configured_path}"
end

private

def warn_untrusted(path)
Bundler.ui.warn "Credential helper for #{@host} at #{path} is not trusted; run `bundle credential trust #{@host}`"
end
end
end
2 changes: 2 additions & 0 deletions lib/bundler/man/bundle-config.1
Original file line number Diff line number Diff line change
Expand Up @@ -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/<gem>\fR responses\.
.IP "\(bu" 4
\fBcredential\.helper\fR (\fBBUNDLE_CREDENTIAL__HELPER\fR): The absolute path to a credential helper\. Append the registry host to the key, for example \fBcredential\.helper\.gems\.example\.com\fR\. Arguments, relative paths, shell expansion, and \fBPATH\fR lookup are not supported\. The helper is used only after \fBbundle credential trust HOST\fR records explicit trust\.
.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\.
Expand Down
5 changes: 5 additions & 0 deletions lib/bundler/man/bundle-config.1.ronn
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,11 @@ 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/<gem>` responses.
* `credential.helper` (`BUNDLE_CREDENTIAL__HELPER`):
The absolute path to a credential helper. Append the registry host to the
key, for example `credential.helper.gems.example.com`. Arguments, relative
paths, shell expansion, and `PATH` lookup are not supported. The helper is
used only after `bundle credential trust HOST` records explicit trust.
* `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
Expand Down
28 changes: 28 additions & 0 deletions lib/bundler/man/bundle-credential.1
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
.\" generated with Ronn-NG/v0.10.1
.\" http://github.com/apjanke/ronn-ng/tree/0.10.1
.TH "BUNDLE\-CREDENTIAL" "1" "May 2026" ""
.SH "NAME"
\fBbundle\-credential\fR \- Manage credential helper trust
.SH "SYNOPSIS"
\fBbundle credential\fR trust HOST
.br
\fBbundle credential\fR untrust HOST
.br
\fBbundle credential\fR help [COMMAND]
.SH "DESCRIPTION"
Credential helpers return credentials for private gem registries\. Configure an absolute helper path for a host, then explicitly trust its resolved path and SHA\-256 digest:
.IP "" 4
.nf
bundle config set \-\-local credential\.helper\.gems\.example\.com /usr/local/bin/example\-credential\-helper
bundle credential trust gems\.example\.com
.fi
.IP "" 0
.P
Trust is stored in the user's Bundler home, not in project configuration\. Before each execution, Bundler verifies the host, resolved path, and SHA\-256 digest\. A changed helper must be trusted again\. Helper failures fall back to configured credentials\.
.SH "SUB\-COMMANDS"
.SS "trust HOST"
Trust the configured helper for HOST\. Bundler displays the saved host, resolved path, and SHA\-256 digest\.
.SS "untrust HOST"
Remove helper trust for HOST\.
.SS "help"
Describe subcommands or one specific subcommand\.
37 changes: 37 additions & 0 deletions lib/bundler/man/bundle-credential.1.ronn
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
bundle-credential(1) -- Manage credential helper trust
=======================================================

## SYNOPSIS

`bundle credential` trust HOST<br>
`bundle credential` untrust HOST<br>
`bundle credential` help [COMMAND]

## DESCRIPTION

Credential helpers return credentials for private gem registries. Configure an
absolute helper path for a host, then explicitly trust its resolved path and
SHA-256 digest:

bundle config set --local credential.helper.gems.example.com /usr/local/bin/example-credential-helper
bundle credential trust gems.example.com

Trust is stored in the user's Bundler home, not in project configuration. Before
each execution, Bundler verifies the host, resolved path, and SHA-256 digest. A
changed helper must be trusted again. Helper failures fall back to configured
credentials.

## SUB-COMMANDS

### trust HOST

Trust the configured helper for HOST. Bundler displays the saved host, resolved
path, and SHA-256 digest.

### untrust HOST

Remove helper trust for HOST.

### help

Describe subcommands or one specific subcommand.
3 changes: 3 additions & 0 deletions lib/bundler/man/bundle.1
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ Execute a script in the current bundle
\fBbundle config(1)\fR \fIbundle\-config\.1\.html\fR
Specify and read configuration options for Bundler
.TP
\fBbundle credential(1)\fR \fIbundle\-credential\.1\.html\fR
Manage credential helper trust
.TP
\fBbundle help(1)\fR \fIbundle\-help\.1\.html\fR
Display detailed help for each subcommand
.SH "UTILITIES"
Expand Down
3 changes: 3 additions & 0 deletions lib/bundler/man/bundle.1.ronn
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ We divide `bundle` subcommands into primary commands and utilities:
* [`bundle config(1)`](bundle-config.1.html):
Specify and read configuration options for Bundler

* [`bundle credential(1)`](bundle-credential.1.html):
Manage credential helper trust

* [`bundle help(1)`](bundle-help.1.html):
Display detailed help for each subcommand

Expand Down
63 changes: 32 additions & 31 deletions lib/bundler/man/index.txt
Original file line number Diff line number Diff line change
@@ -1,31 +1,32 @@
Gemfile(5) gemfile.5
bundle(1) bundle.1
bundle-add(1) bundle-add.1
bundle-binstubs(1) bundle-binstubs.1
bundle-cache(1) bundle-cache.1
bundle-check(1) bundle-check.1
bundle-clean(1) bundle-clean.1
bundle-config(1) bundle-config.1
bundle-console(1) bundle-console.1
bundle-doctor(1) bundle-doctor.1
bundle-env(1) bundle-env.1
bundle-exec(1) bundle-exec.1
bundle-fund(1) bundle-fund.1
bundle-gem(1) bundle-gem.1
bundle-help(1) bundle-help.1
bundle-info(1) bundle-info.1
bundle-init(1) bundle-init.1
bundle-install(1) bundle-install.1
bundle-issue(1) bundle-issue.1
bundle-licenses(1) bundle-licenses.1
bundle-list(1) bundle-list.1
bundle-lock(1) bundle-lock.1
bundle-open(1) bundle-open.1
bundle-outdated(1) bundle-outdated.1
bundle-platform(1) bundle-platform.1
bundle-plugin(1) bundle-plugin.1
bundle-pristine(1) bundle-pristine.1
bundle-remove(1) bundle-remove.1
bundle-show(1) bundle-show.1
bundle-update(1) bundle-update.1
bundle-version(1) bundle-version.1
Gemfile(5) gemfile.5
bundle(1) bundle.1
bundle-add(1) bundle-add.1
bundle-binstubs(1) bundle-binstubs.1
bundle-cache(1) bundle-cache.1
bundle-check(1) bundle-check.1
bundle-clean(1) bundle-clean.1
bundle-config(1) bundle-config.1
bundle-console(1) bundle-console.1
bundle-credential(1) bundle-credential.1
bundle-doctor(1) bundle-doctor.1
bundle-env(1) bundle-env.1
bundle-exec(1) bundle-exec.1
bundle-fund(1) bundle-fund.1
bundle-gem(1) bundle-gem.1
bundle-help(1) bundle-help.1
bundle-info(1) bundle-info.1
bundle-init(1) bundle-init.1
bundle-install(1) bundle-install.1
bundle-issue(1) bundle-issue.1
bundle-licenses(1) bundle-licenses.1
bundle-list(1) bundle-list.1
bundle-lock(1) bundle-lock.1
bundle-open(1) bundle-open.1
bundle-outdated(1) bundle-outdated.1
bundle-platform(1) bundle-platform.1
bundle-plugin(1) bundle-plugin.1
bundle-pristine(1) bundle-pristine.1
bundle-remove(1) bundle-remove.1
bundle-show(1) bundle-show.1
bundle-update(1) bundle-update.1
bundle-version(1) bundle-version.1
Loading