diff --git a/lib/bundler/cli.rb b/lib/bundler/cli.rb index a1d3bc02c503..ecf31d46a18b 100644 --- a/lib/bundler/cli.rb +++ b/lib/bundler/cli.rb @@ -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) diff --git a/lib/bundler/cli/credential.rb b/lib/bundler/cli/credential.rb new file mode 100644 index 000000000000..12980c449152 --- /dev/null +++ b/lib/bundler/cli/credential.rb @@ -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 diff --git a/lib/bundler/credential_helper.rb b/lib/bundler/credential_helper.rb new file mode 100644 index 000000000000..9dbc113160f4 --- /dev/null +++ b/lib/bundler/credential_helper.rb @@ -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 diff --git a/lib/bundler/man/bundle-config.1 b/lib/bundler/man/bundle-config.1 index 32291e048c1f..3b8b30b526d4 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\.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\. diff --git a/lib/bundler/man/bundle-config.1.ronn b/lib/bundler/man/bundle-config.1.ronn index 6b52288783b5..6e0e48f3d7b9 100644 --- a/lib/bundler/man/bundle-config.1.ronn +++ b/lib/bundler/man/bundle-config.1.ronn @@ -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/` 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 diff --git a/lib/bundler/man/bundle-credential.1 b/lib/bundler/man/bundle-credential.1 new file mode 100644 index 000000000000..e93132fcf0f0 --- /dev/null +++ b/lib/bundler/man/bundle-credential.1 @@ -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\. diff --git a/lib/bundler/man/bundle-credential.1.ronn b/lib/bundler/man/bundle-credential.1.ronn new file mode 100644 index 000000000000..39daa8419fd1 --- /dev/null +++ b/lib/bundler/man/bundle-credential.1.ronn @@ -0,0 +1,37 @@ +bundle-credential(1) -- Manage credential helper trust +======================================================= + +## SYNOPSIS + +`bundle credential` trust HOST
+`bundle credential` untrust HOST
+`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. diff --git a/lib/bundler/man/bundle.1 b/lib/bundler/man/bundle.1 index be5d3960ad09..76af840c1813 100644 --- a/lib/bundler/man/bundle.1 +++ b/lib/bundler/man/bundle.1 @@ -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" diff --git a/lib/bundler/man/bundle.1.ronn b/lib/bundler/man/bundle.1.ronn index bf322f79410d..1f3e1e003ec0 100644 --- a/lib/bundler/man/bundle.1.ronn +++ b/lib/bundler/man/bundle.1.ronn @@ -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 diff --git a/lib/bundler/man/index.txt b/lib/bundler/man/index.txt index f610ba852a6d..26a85cfb9af5 100644 --- a/lib/bundler/man/index.txt +++ b/lib/bundler/man/index.txt @@ -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 diff --git a/lib/bundler/settings.rb b/lib/bundler/settings.rb index c1f8ecf824e1..dc7d08e6a940 100644 --- a/lib/bundler/settings.rb +++ b/lib/bundler/settings.rb @@ -62,6 +62,7 @@ class Settings bin cache_path console + credential.helper 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_helper(uri) || self[uri.to_s] || self[uri.host] end def gem_mirrors @@ -355,7 +356,7 @@ def is_bool(name) def is_string(name) name = self.class.key_to_s(name) - STRING_KEYS.include?(name) || name.start_with?("local.") || name.start_with?("mirror.") || name.start_with?("build.") + STRING_KEYS.include?(name) || name.start_with?("local.", "mirror.", "build.", "credential.helper.") end def to_bool(value) @@ -510,6 +511,14 @@ def serializer_class Gem::YAMLSerializer end + def credentials_from_helper(uri) + helper_path = self["credential.helper.#{uri.host}"] + return unless helper_path + + require_relative "credential_helper" + CredentialHelper.fetch(uri.host, helper_path) + end + FALLBACK_TIMEOUT_URI_OPTION = "fallback_timeout" NORMALIZE_URI_OPTIONS_PATTERN = diff --git a/spec/bundler/settings_spec.rb b/spec/bundler/settings_spec.rb index 5e1aaaa55511..e54ce3949a5e 100644 --- a/spec/bundler/settings_spec.rb +++ b/spec/bundler/settings_spec.rb @@ -268,6 +268,27 @@ expect(settings.credentials_for(uri)).to eq(credentials) end end + + context "with a credential helper configured" do + let(:helper_path) { "/path/to/helper" } + + before { settings.set_local "credential.helper.gemserver.example.org", helper_path } + + it "uses credentials returned by the helper" do + require "bundler/credential_helper" + expect(Bundler::CredentialHelper).to receive(:fetch).with(uri.host, helper_path).and_return("helper:password") + + expect(settings.credentials_for(uri)).to eq("helper:password") + end + + it "falls back to configured credentials" do + require "bundler/credential_helper" + allow(Bundler::CredentialHelper).to receive(:fetch).and_return(nil) + settings.set_local uri.host, credentials + + expect(settings.credentials_for(uri)).to eq(credentials) + end + end end describe "URI normalization" do diff --git a/spec/commands/credential_helper_spec.rb b/spec/commands/credential_helper_spec.rb new file mode 100644 index 000000000000..5648c9a3d048 --- /dev/null +++ b/spec/commands/credential_helper_spec.rb @@ -0,0 +1,242 @@ +# frozen_string_literal: true + +RSpec.describe "bundle credential" do + let(:host) { "gems.example.com" } + let(:helper) { tmp("credential-helper") } + let(:marker) { tmp("credential-helper-ran") } + + before do + gemfile "source \"https://rubygems.org\"" + end + + def write_helper(body, executable: true) + create_file helper, <<~RUBY + #!#{Gem.ruby} + #{body} + RUBY + FileUtils.chmod(executable ? 0o755 : 0o644, helper) + end + + def configure_helper(configured_helper = helper, configured_host = host) + bundle "config set --local credential.helper.#{configured_host} #{configured_helper}" + end + + def credentials_for(configured_host = host) + ruby <<~RUBY + require "bundler" + require "bundler/vendored_uri" + credentials = Bundler.settings.credentials_for(Gem::URI("https://#{configured_host}")) + print credentials if credentials + RUBY + end + + it "gets credentials from a trusted helper" do + write_helper 'print "user:password"' + configure_helper + + bundle "credential trust #{host}" + expect(out).to include("Host: #{host}", "Path: #{helper.realpath}", "SHA-256:") + + credentials_for + expect(out).to eq("user:password") + end + + it "does not execute an untrusted helper" do + write_helper "File.write(#{marker.to_s.dump}, \"ran\")\nprint \"user:password\"" + configure_helper + + credentials_for + expect(marker).not_to exist + expect(err).to include(host, helper.to_s, "not trusted") + end + + it "does not execute a helper after its hash changes" do + write_helper 'print "user:password"' + configure_helper + bundle "credential trust #{host}" + + write_helper "File.write(#{marker.to_s.dump}, \"ran\")\nprint \"attacker:password\"" + credentials_for + + expect(marker).not_to exist + expect(err).to include(host, helper.to_s, "has changed") + end + + it "does not share trust between hosts" do + write_helper "File.write(#{marker.to_s.dump}, \"ran\")\nprint \"user:password\"" + configure_helper + configure_helper(helper, "other.example.com") + bundle "credential trust #{host}" + + credentials_for("other.example.com") + + expect(marker).not_to exist + expect(err).to include("other.example.com", helper.to_s, "not trusted") + end + + it "rejects invalid hosts" do + ["gems.example.com/path", "gems.example.com:443", "user@gems.example.com", "[invalid]"].each do |invalid_host| + bundle "credential trust #{invalid_host}", raise_on_error: false + + expect(last_command).to be_failure + expect(err).to include("Invalid registry host: #{invalid_host.inspect}") + end + end + + it "normalizes an IPv6 host" do + write_helper 'print "user:password"' + configure_helper(helper, "[::1]") + + bundle "credential trust ::1" + + expect(out).to include("Host: [::1]") + end + + it "rejects a relative path" do + configure_helper("relative-helper") + + bundle "credential trust #{host}", raise_on_error: false + + expect(last_command).to be_failure + expect(err).to include(host, "must be an absolute path") + end + + it "does not expand home or environment variables" do + ["~/credential-helper", "$HOME/credential-helper"].each do |configured_helper| + configure_helper(configured_helper) + bundle "credential trust #{host}", raise_on_error: false + + expect(last_command).to be_failure + expect(err).to include(host, "must be an absolute path") + end + end + + it "rejects a configured path with arguments" do + write_helper 'print "user:password"' + configure_helper("#{helper} --token") + + bundle "credential trust #{host}", raise_on_error: false + + expect(last_command).to be_failure + expect(err).to include(host, "does not exist") + end + + it "does not interpret shell metacharacters" do + write_helper 'print "user:password"' + configure_helper("#{helper};touch #{marker}") + + bundle "credential trust #{host}", raise_on_error: false + + expect(last_command).to be_failure + expect(marker).not_to exist + end + + it "trusts and executes the real path of a symlink" do + skip "symlink execution is platform dependent" if Gem.win_platform? + + write_helper 'print "user:password"' + symlink = tmp("credential-helper-link") + File.symlink(helper, symlink) + configure_helper(symlink) + + bundle "credential trust #{host}" + expect(out).to include("Path: #{helper.realpath}") + + credentials_for + expect(out).to eq("user:password") + end + + it "falls back to configured credentials when the helper fails" do + write_helper "exit 2" + configure_helper + bundle "config set --local #{host} fallback:password" + bundle "credential trust #{host}" + + credentials_for + + expect(out).to eq("fallback:password") + expect(err).to include(host, helper.to_s, "exit status 2") + end + + it "trusts and untrusts a helper" do + write_helper 'print "user:password"' + configure_helper + + bundle "credential trust #{host}" + trust_file = home(".bundle/credential_helpers") + expect(trust_file).to exist + expect(trust_file.stat.mode & 0o777).to eq(0o600) unless Gem.win_platform? + + bundle "credential untrust #{host}" + expect(out).to eq("Removed credential helper trust for #{host}") + + credentials_for + expect(err).to include("not trusted") + end + + it "does not load trust from local config storage" do + write_helper "File.write(#{marker.to_s.dump}, \"ran\")\nprint \"user:password\"" + configure_helper + require "digest/sha2" + create_file bundled_app(".bundle/credential_helpers"), <<~YAML + --- + #{host}: + host: #{host} + path: #{helper.realpath} + sha256: #{Digest::SHA256.file(helper).hexdigest} + YAML + + credentials_for + + expect(marker).not_to exist + expect(err).to include("not trusted") + end + + it "does not expose helper output when the helper fails" do + credential = "secret-user:secret-password" + write_helper "print #{credential.dump}\nwarn #{credential.dump}\nexit 1" + configure_helper + bundle "config set --local #{host} fallback:password" + bundle "credential trust #{host}" + + credentials_for + + expect(out).to eq("fallback:password") + expect(out).not_to include(credential) + expect(err).not_to include(credential) + end + + it "falls back for empty output" do + write_helper "exit 0" + configure_helper + bundle "config set --local #{host} fallback:password" + bundle "credential trust #{host}" + + credentials_for + + expect(out).to eq("fallback:password") + expect(err).to include(host, helper.to_s, "returned no credentials") + end + + it "rejects missing and non-executable helpers" do + configure_helper + bundle "credential trust #{host}", raise_on_error: false + expect(err).to include("does not exist") + + write_helper('print "user:password"', executable: false) + bundle "credential trust #{host}", raise_on_error: false + expect(err).to include("not executable") + end + + it "does not execute a helper that loses executable permission after trust" do + write_helper 'print "user:password"' + configure_helper + bundle "credential trust #{host}" + FileUtils.chmod(0o644, helper) + + credentials_for + + expect(out).to be_empty + expect(err).to include(host, helper.to_s, "not executable") + end +end diff --git a/spec/support/shards.rb b/spec/support/shards.rb index 4cc96860b634..67bd0664a687 100644 --- a/spec/support/shards.rb +++ b/spec/support/shards.rb @@ -144,6 +144,7 @@ module Shards "spec/bundler/ci_detector_spec.rb", ], shard_d: [ + "spec/commands/credential_helper_spec.rb", "spec/bundler/rubygems_ext_spec.rb", "spec/bundler/resolver/cooldown_spec.rb", "spec/install/cooldown_spec.rb",