From 9575adc2c0f4dd5cac14b6c2d7c5269336cb669c Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Sun, 12 Jul 2026 10:02:50 +0900 Subject: [PATCH 01/11] common.mk: fix prerequisites ignored by nmake nmake does not join a `{$(VPATH)}target` line and a bare `target` line into a single node, so the prerequisites declared on the braced lines never fired. An out-of-place mswin build kept using a probes.dmyh generated before gc__xcalloc was added to probes.d and gc.c failed to compile with RUBY_DTRACE_GC_XCALLOC undefined. parse.c had the same problem with its id.h prerequisite. GNU make is unaffected since tool/prereq.status strips the braces for uncommon.mk. Co-Authored-By: Claude Fable 5 --- common.mk | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/common.mk b/common.mk index fcf64566f5c1e4..204312cc8f4796 100644 --- a/common.mk +++ b/common.mk @@ -1050,8 +1050,8 @@ $(ENC_MK): $(srcdir)/enc/make_encmake.rb $(srcdir)/enc/Makefile.in $(srcdir)/enc PHONY: -{$(VPATH)}parse.c: {$(VPATH)}parse.y {$(VPATH)}id.h -{$(VPATH)}parse.h: {$(VPATH)}parse.c +parse.c: {$(VPATH)}parse.y {$(VPATH)}id.h +parse.h: {$(VPATH)}parse.c {$(srcdir)}.y.c: $(ECHO) generating $@ @@ -1310,9 +1310,7 @@ $(MAINOBJ): $(srcdir)/$(MAINSRC) $(ECHO) compiling $(srcdir)/$(MAINSRC) $(Q) $(CC) $(MAINCPPFLAGS) $(CFLAGS) $(XCFLAGS) $(CPPFLAGS) $(COUTFLAG)$@ -c $(CSRCFLAG)$(srcdir)/$(MAINSRC) -{$(VPATH)}probes.dmyh: {$(srcdir)}probes.d $(tooldir)/gen_dummy_probes.rb - -probes.dmyh: +probes.dmyh: {$(srcdir)}probes.d $(tooldir)/gen_dummy_probes.rb $(BASERUBY) $(tooldir)/gen_dummy_probes.rb $(srcdir)/probes.d > $@ probes.h: {$(VPATH)}probes.$(DTRACE_EXT) $(srcdir)/vm_opts.h From 8a89200ae3510d274acd445c5f5da0f95f643641 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 2 Sep 2026 10:50:14 +0900 Subject: [PATCH 02/11] [ruby/rubygems] Reject created_at years outside four digits in cooldown parsing Time.iso8601 accepts a year of any length. A far enough year makes the distance from now overflow to Float Infinity, and ceil then raises FloatDomainError while the cooldown summary is built after a successful resolve, aborting install, update, lock and outdated in both front ends. Such a timestamp is now treated as no publish time, the same as a missing created_at. https://github.com/ruby/rubygems/commit/af9cd431d7 Co-Authored-By: Claude Fable 5.1 --- lib/bundler/endpoint_specification.rb | 9 +++++++-- lib/rubygems/cooldown.rb | 11 +++++++++-- .../bundler/endpoint_specification_spec.rb | 8 ++++++++ .../bundler/bundler/resolver/cooldown_spec.rb | 10 ++++++++++ spec/bundler/install/cooldown_spec.rb | 16 ++++++++++++++++ .../compact_index_cooldown_bad_created_at.rb | 18 ++++++++++++++++++ .../test_gem_commands_install_command.rb | 19 +++++++++++++++++++ .../test_gem_commands_outdated_command.rb | 14 ++++++++++++++ test/rubygems/test_gem_cooldown.rb | 7 +++++++ 9 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 spec/bundler/support/artifice/compact_index_cooldown_bad_created_at.rb diff --git a/lib/bundler/endpoint_specification.rb b/lib/bundler/endpoint_specification.rb index 5d0485ee558ef0..9be06aac47c0ea 100644 --- a/lib/bundler/endpoint_specification.rb +++ b/lib/bundler/endpoint_specification.rb @@ -193,11 +193,16 @@ def parse_metadata(data) TIME_ZONE_SUFFIX = /(?:Z|z|[+-]\d{2}(?::?\d{2})?)\z/ private_constant :TIME_ZONE_SUFFIX + # See Gem::Cooldown::FOUR_DIGIT_YEAR. + FOUR_DIGIT_YEAR = /\A\d{4}-/ + private_constant :FOUR_DIGIT_YEAR + # A timestamp without a time zone offset is read as UTC, because reading # it as local time would shift the cooldown window by the environment's - # offset. Unparsable values become nil so the cooldown fails open. + # offset. Unparsable values and years outside four digits become nil so + # the cooldown fails open. def parse_created_at(value) - return unless value.is_a?(String) + return unless value.is_a?(String) && value.match?(FOUR_DIGIT_YEAR) require "time" begin diff --git a/lib/rubygems/cooldown.rb b/lib/rubygems/cooldown.rb index f44958a7bc4d4b..9af06f38202c7b 100644 --- a/lib/rubygems/cooldown.rb +++ b/lib/rubygems/cooldown.rb @@ -94,14 +94,21 @@ def self.output_skipped_summary(entries) TIME_ZONE_SUFFIX = /(?:Z|z|[+-]\d{2}(?::?\d{2})?)\z/ # :nodoc: private_constant :TIME_ZONE_SUFFIX + # Matches the four-digit year an ISO 8601 timestamp starts with. + # Time.iso8601 also accepts a year of any length, and one far enough + # away overflows the Float arithmetic behind #remaining_days. + FOUR_DIGIT_YEAR = /\A\d{4}-/ # :nodoc: + private_constant :FOUR_DIGIT_YEAR + ## # Parses a +created_at+ timestamp from the compact index. A timestamp # without a time zone offset is read as UTC, because reading it as local # time would shift the cooldown window by the environment's offset. - # Returns nil for anything unparsable, so the cooldown fails open. + # Returns nil for anything unparsable, including a year outside four + # digits, so the cooldown fails open. def self.parse_created_at(value) - return unless value.is_a?(String) + return unless value.is_a?(String) && value.match?(FOUR_DIGIT_YEAR) require "time" begin diff --git a/spec/bundler/bundler/endpoint_specification_spec.rb b/spec/bundler/bundler/endpoint_specification_spec.rb index 229ea34dda66f0..207db2fcbf3823 100644 --- a/spec/bundler/bundler/endpoint_specification_spec.rb +++ b/spec/bundler/bundler/endpoint_specification_spec.rb @@ -97,6 +97,14 @@ def with_tz(tz) end end + context "when created_at has a year that overflows Float arithmetic" do + let(:metadata) { { "created_at" => ["#{"9" * 400}-01-01T00:00:00Z"] } } + + it "leaves created_at as nil" do + expect(subject.created_at).to be_nil + end + end + context "when the metadata has an empty checksum value" do let(:metadata) { { "checksum" => [] } } diff --git a/spec/bundler/bundler/resolver/cooldown_spec.rb b/spec/bundler/bundler/resolver/cooldown_spec.rb index 37ec158cba4fcf..52862a87d9d53e 100644 --- a/spec/bundler/bundler/resolver/cooldown_spec.rb +++ b/spec/bundler/bundler/resolver/cooldown_spec.rb @@ -73,6 +73,16 @@ def spec(created_at:, remote:, name: "myrack", version: "1.0.0") end end + context "when created_at has a year that overflows Float arithmetic" do + it "keeps the spec like one without created_at" do + metadata = { "created_at" => ["#{"9" * 400}-01-01T00:00:00Z"] } + s = Bundler::EndpointSpecification.new("myrack", "1.0.0", Gem::Platform::RUBY, nil, [], metadata) + s.remote = remote(cooldown: 7) + + expect(resolver.send(:filter_cooldown, [s])).to eq([s]) + end + end + context "when the remote has no cooldown" do it "keeps every spec" do s = spec(created_at: now - 3600, remote: remote(cooldown: nil)) diff --git a/spec/bundler/install/cooldown_spec.rb b/spec/bundler/install/cooldown_spec.rb index b45d50b1f2e1bc..6991725aa364af 100644 --- a/spec/bundler/install/cooldown_spec.rb +++ b/spec/bundler/install/cooldown_spec.rb @@ -424,6 +424,22 @@ expect(the_bundle).to include_gems("ripe_gem 2.0.0") end + it "treats a created_at with a year that overflows Float arithmetic as unknown" do + gemfile <<-G + source "https://gem.repo3" + gem "ripe_gem" + G + + bundle "install --cooldown 7", artifice: "compact_index_cooldown_bad_created_at" + + expect(the_bundle).to include_gems("ripe_gem 2.0.0") + expect(out).not_to include("skipped by the cooldown setting") + + bundle "outdated --cooldown 7", artifice: "compact_index_cooldown_bad_created_at", raise_on_error: false + + expect(out).not_to include("cooldown") + end + it "annotates in-cooldown versions in bundle outdated table output" do gemfile <<-G source "https://gem.repo3" diff --git a/spec/bundler/support/artifice/compact_index_cooldown_bad_created_at.rb b/spec/bundler/support/artifice/compact_index_cooldown_bad_created_at.rb new file mode 100644 index 00000000000000..3379a9683a65ef --- /dev/null +++ b/spec/bundler/support/artifice/compact_index_cooldown_bad_created_at.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require_relative "helpers/compact_index_cooldown" + +# Serves every version with a created_at year that Time.iso8601 accepts but +# whose distance from now overflows Float. +class CompactIndexCooldownBadCreatedAt < CompactIndexCooldownAPI + helpers do + def build_gem_version(spec, deps, checksum) + CompactIndex::GemVersionV2.new(spec.version.version, spec.platform.to_s, checksum, nil, + deps, spec.required_ruby_version.to_s, spec.required_rubygems_version.to_s, "#{"9" * 400}-01-01T00:00:00Z") + end + end +end + +require_relative "helpers/artifice" + +Artifice.activate_with(CompactIndexCooldownBadCreatedAt) diff --git a/test/rubygems/test_gem_commands_install_command.rb b/test/rubygems/test_gem_commands_install_command.rb index a99afbfef74e7f..01786bea58cb89 100644 --- a/test/rubygems/test_gem_commands_install_command.rb +++ b/test/rubygems/test_gem_commands_install_command.rb @@ -788,6 +788,25 @@ def test_execute_remote_cooldown_explicit_version_error assert_match "--cooldown 0", @ui.error end + def test_execute_remote_cooldown_unparsable_created_at_fails_open + util_setup_cooldown_repo created_at: { + "a-1" => util_cooldown_time(30), + "a-2" => "#{"9" * 400}-01-01T00:00:00Z", + } + + @cmd.options[:cooldown] = 7 + @cmd.options[:args] = %w[a] + + use_ui @ui do + assert_raise Gem::MockGemUi::SystemExitException, @ui.error do + @cmd.execute + end + end + + assert_equal %w[a-2], @cmd.installed_specs.map(&:full_name) + refute_match "skipped by the cooldown setting", @ui.output + end + def test_execute_remote_cooldown_missing_created_at_fails_open util_setup_cooldown_repo diff --git a/test/rubygems/test_gem_commands_outdated_command.rb b/test/rubygems/test_gem_commands_outdated_command.rb index 4f88aef0f03bef..82e2b6c41e00c7 100644 --- a/test/rubygems/test_gem_commands_outdated_command.rb +++ b/test/rubygems/test_gem_commands_outdated_command.rb @@ -107,6 +107,20 @@ def test_execute_cooldown_missing_created_at_fails_open assert_equal 1, @ui.error.scan("publish times").size end + def test_execute_cooldown_unparsable_created_at_fails_open + util_setup_cooldown_repo "foo-0.2" => util_cooldown_time(30), + "foo-0.3" => "#{"9" * 400}-01-01T00:00:00Z" + + @cmd.options[:cooldown] = 7 + + use_ui @ui do + @cmd.execute + end + + assert_equal "foo (0.1 < 0.3)\n", @ui.output + assert_equal "", @ui.error + end + def test_cooldown_option @cmd.handle_options %w[--cooldown 7] diff --git a/test/rubygems/test_gem_cooldown.rb b/test/rubygems/test_gem_cooldown.rb index fe2ea4afd3cbb8..6950dcecc2c542 100644 --- a/test/rubygems/test_gem_cooldown.rb +++ b/test/rubygems/test_gem_cooldown.rb @@ -113,6 +113,13 @@ def test_parse_created_at_invalid assert_nil Gem::Cooldown.parse_created_at(7) end + def test_parse_created_at_rejects_years_outside_four_digits + # Time.iso8601 accepts these, but the distance from now overflows Float. + assert_nil Gem::Cooldown.parse_created_at("#{"9" * 400}-01-01T00:00:00Z") + assert_nil Gem::Cooldown.parse_created_at("-2026-06-05T10:30:45Z") + assert_nil Gem::Cooldown.parse_created_at("02026-06-05T10:30:45Z") + end + def with_tz(tz) orig_tz = ENV["TZ"] ENV["TZ"] = tz From be5c7ac9855de66789eaa101ab438e8538f78e51 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 26 Aug 2026 14:36:20 +0900 Subject: [PATCH 03/11] [ruby/rubygems] Expand the git source gemspec path before the chdir `load_gemspec` changed into the gemspec's directory and then passed the still-relative path to `Gem::StubSpecification.gemspec_stub`, which reads it while that chdir is active, so a relative path resolved one directory too deep. Expanding once up front keeps the absolute-path requirement in the code instead of relying on every caller to satisfy it. https://github.com/ruby/rubygems/commit/3d19c765b1 Co-Authored-By: Claude Opus 5 --- lib/bundler/source/git.rb | 10 ++++++---- spec/bundler/bundler/source/git_spec.rb | 26 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/lib/bundler/source/git.rb b/lib/bundler/source/git.rb index 6258308e3b6dbb..9ba67433f4210d 100644 --- a/lib/bundler/source/git.rb +++ b/lib/bundler/source/git.rb @@ -432,10 +432,12 @@ def fetch def validate_spec(_spec); end def load_gemspec(file) - dirname = Pathname.new(file).dirname - SharedHelpers.chdir(dirname.to_s) do - stub = Gem::StubSpecification.gemspec_stub(file, install_path.parent, install_path.parent) - stub.full_gem_path = dirname.expand_path(root).to_s + # Expand the path before the chdir below, since resolving it inside the + # block would base it on the gemspec directory instead of `root`. + gemspec_path = Pathname.new(file).expand_path(root) + SharedHelpers.chdir(gemspec_path.dirname.to_s) do + stub = Gem::StubSpecification.gemspec_stub(gemspec_path.to_s, install_path.parent, install_path.parent) + stub.full_gem_path = gemspec_path.dirname.to_s StubSpecification.from_stub(stub) end end diff --git a/spec/bundler/bundler/source/git_spec.rb b/spec/bundler/bundler/source/git_spec.rb index 59b145ef17adba..00f09320fc64d5 100644 --- a/spec/bundler/bundler/source/git_spec.rb +++ b/spec/bundler/bundler/source/git_spec.rb @@ -149,4 +149,30 @@ expect(::Bundler::FileUtils).to have_received(:rm_rf).once end end + + describe "#load_gemspec" do + let(:options) do + { "uri" => uri, "revision" => "123abc" } + end + + before do + allow(Bundler).to receive(:root).and_return(tmp) + allow(subject).to receive(:install_path).and_return(tmp("install/bar-123abc")) + + create_file(tmp("bar/bar.gemspec"), <<~GEMSPEC) + Gem::Specification.new do |s| + s.name = "bar" + s.version = "1.0" + end + GEMSPEC + end + + it "resolves a relative path against the root, not the gemspec directory" do + spec = Dir.chdir(tmp) { subject.send(:load_gemspec, "bar/bar.gemspec") } + + expect(spec.name).to eq("bar") + expect(spec.loaded_from).to eq(tmp("bar/bar.gemspec").to_s) + expect(spec.full_gem_path).to eq(tmp("bar").to_s) + end + end end From e866499c38bb4eddf1e8f0f5e312a7f469a35f00 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 2 Sep 2026 10:33:43 +0900 Subject: [PATCH 04/11] [ruby/rubygems] Reject dot, empty and NUL names in compact index cache `validate_name!` only compared the name with its `File.basename`, which lets `.`, `..`, the empty string and names containing NUL through. Those resolve `info/..` to the index root on the remote side, and NUL escaped as an ArgumentError instead of the Gem::Exception callers handle. https://github.com/ruby/rubygems/commit/6d3b6dc6c1 Co-Authored-By: Claude Fable 5.1 --- lib/rubygems/compact_index_client/cache.rb | 2 +- .../test_gem_compact_index_client_cache.rb | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/lib/rubygems/compact_index_client/cache.rb b/lib/rubygems/compact_index_client/cache.rb index 3bfd3bfe397d4d..61cfd62aa22806 100644 --- a/lib/rubygems/compact_index_client/cache.rb +++ b/lib/rubygems/compact_index_client/cache.rb @@ -79,7 +79,7 @@ def info_etag_path(name) # validated, so refuse anything that would escape the cache # directory when used as a path component. def validate_name!(name) - return if File.basename(name) == name + return unless name.empty? || name.include?("\0") || name == "." || name == ".." || File.basename(name) != name raise Gem::Exception, "malformed gem name: #{name.inspect}" end diff --git a/test/rubygems/test_gem_compact_index_client_cache.rb b/test/rubygems/test_gem_compact_index_client_cache.rb index 2951f8e324191e..717a71eb50fa44 100644 --- a/test/rubygems/test_gem_compact_index_client_cache.rb +++ b/test/rubygems/test_gem_compact_index_client_cache.rb @@ -136,6 +136,22 @@ def test_fetch_info_rejects_name_escaping_cache_directory assert_empty fetcher.requests end + def test_info_rejects_dot_dot_name + assert_rejects_malformed_name ".." + end + + def test_info_rejects_dot_name + assert_rejects_malformed_name "." + end + + def test_info_rejects_empty_name + assert_rejects_malformed_name "" + end + + def test_info_rejects_name_with_null_byte + assert_rejects_malformed_name "a\0b" + end + def test_info_with_special_characters_uses_hashed_path fetcher = FakeFetcher.new("1.0.0\n") cache = Gem::CompactIndexClient::Cache.new(@dir, fetcher) @@ -146,4 +162,24 @@ def test_info_with_special_characters_uses_hashed_path assert_equal "1.0.0\n", @dir.join("info-special-characters", hashed).read refute @dir.join("info", "Rails").exist? end + + private + + def assert_rejects_malformed_name(name) + fetcher = FakeFetcher.new("1.0.0\n") + cache = Gem::CompactIndexClient::Cache.new(@dir, fetcher) + before = Dir.glob("**/*", File::FNM_DOTMATCH, base: @tempdir).sort + + e = assert_raise Gem::Exception do + cache.info(name, "no-match") + end + assert_includes e.message, "malformed gem name" + + assert_raise Gem::Exception do + cache.fetch_info(name) + end + + assert_empty fetcher.requests + assert_equal before, Dir.glob("**/*", File::FNM_DOTMATCH, base: @tempdir).sort + end end From cd608054345d65e55a531005872ea405ee3025eb Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 2 Sep 2026 10:35:07 +0900 Subject: [PATCH 05/11] [ruby/rubygems] Drop If-None-Match when retrying a compact index fetch after 416 The retry after Range Not Satisfiable removed only the Range header. When the oversized local cache still matched the remote ETag, the retry came back as 304 and the corrupt cache file was kept forever. https://github.com/ruby/rubygems/commit/a94646bf62 Co-Authored-By: Claude Fable 5.1 --- lib/rubygems/compact_index_client/http_fetcher.rb | 6 ++++-- .../test_gem_compact_index_client_http_fetcher.rb | 12 +++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/lib/rubygems/compact_index_client/http_fetcher.rb b/lib/rubygems/compact_index_client/http_fetcher.rb index 5ba5ee1147820c..59a74de5a00ff4 100644 --- a/lib/rubygems/compact_index_client/http_fetcher.rb +++ b/lib/rubygems/compact_index_client/http_fetcher.rb @@ -47,8 +47,10 @@ def fetch(uri, headers, redirects_remaining) when Gem::Net::HTTPRangeNotSatisfiable raise Gem::RemoteFetcher::FetchError.new("bad response #{response.message} #{response.code}", uri) unless headers.key?("Range") - # The local cache is longer than the remote file, refetch it whole. - fetch(uri, headers.except("Range"), redirects_remaining) + # The local cache is longer than the remote file, refetch it whole. A + # matching ETag would otherwise turn the retry into a 304 and keep the + # oversized cache. + fetch(uri, headers.except("Range", "If-None-Match"), redirects_remaining) else raise Gem::RemoteFetcher::FetchError.new("bad response #{response.message} #{response.code}", uri) end diff --git a/test/rubygems/test_gem_compact_index_client_http_fetcher.rb b/test/rubygems/test_gem_compact_index_client_http_fetcher.rb index 1ac4ed6a03300b..3939f25dca82d1 100644 --- a/test/rubygems/test_gem_compact_index_client_http_fetcher.rb +++ b/test/rubygems/test_gem_compact_index_client_http_fetcher.rb @@ -246,14 +246,20 @@ def test_call_raises_after_too_many_redirects assert_match(/too many redirects/, error.message) end - def test_call_retries_without_range_on_range_not_satisfiable + def test_call_retries_without_range_and_etag_on_range_not_satisfiable requests = [] remote = Object.new remote.define_singleton_method(:request) do |uri, request_class, &block| request = request_class.new(uri) block&.call(request) requests << request - request["Range"] ? FakeRangeNotSatisfiable.new : FakeResponse.new("full data") + if request["Range"] + FakeRangeNotSatisfiable.new + elsif request["If-None-Match"] + Gem::Net::HTTPNotModified.new("1.1", "304", "Not Modified") + else + FakeResponse.new("full data") + end end fetcher = Gem::CompactIndexClient::HTTPFetcher.new("https://index.example", remote) @@ -262,7 +268,7 @@ def test_call_retries_without_range_on_range_not_satisfiable assert_equal "full data", response.body assert_equal 2, requests.size assert_nil requests.last["Range"] - assert_equal '"abc"', requests.last["If-None-Match"] + assert_nil requests.last["If-None-Match"] end def test_call_raises_on_range_not_satisfiable_without_range From f44fc03c153b61b41953d2b6f81994cdd1895235 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 2 Sep 2026 10:36:23 +0900 Subject: [PATCH 06/11] [ruby/rubygems] Treat body-less success responses as compact index fetch errors A 204 No Content passed the HTTPSuccess branch and the updater wrote its nil body into the cache, truncating the cached versions or info file to zero bytes. https://github.com/ruby/rubygems/commit/0b41d83470 Co-Authored-By: Claude Fable 5.1 --- .../compact_index_client/http_fetcher.rb | 8 +++++++- ...test_gem_compact_index_client_http_fetcher.rb | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/lib/rubygems/compact_index_client/http_fetcher.rb b/lib/rubygems/compact_index_client/http_fetcher.rb index 59a74de5a00ff4..0fafcfdcd655f4 100644 --- a/lib/rubygems/compact_index_client/http_fetcher.rb +++ b/lib/rubygems/compact_index_client/http_fetcher.rb @@ -29,7 +29,13 @@ def fetch(uri, headers, redirects_remaining) response = request(uri, headers) case response - when Gem::Net::HTTPSuccess, Gem::Net::HTTPNotModified + when Gem::Net::HTTPNotModified + response + when Gem::Net::HTTPSuccess + # The callers write the body into the cache, so a body-less success + # such as 204 would truncate the cached file. + raise Gem::RemoteFetcher::FetchError.new("bad response #{response.message} #{response.code}", uri) unless response.class.body_permitted? + response when Gem::Net::HTTPMovedPermanently, Gem::Net::HTTPFound, Gem::Net::HTTPSeeOther, Gem::Net::HTTPTemporaryRedirect, Gem::Net::HTTPPermanentRedirect diff --git a/test/rubygems/test_gem_compact_index_client_http_fetcher.rb b/test/rubygems/test_gem_compact_index_client_http_fetcher.rb index 3939f25dca82d1..897acf22da6b01 100644 --- a/test/rubygems/test_gem_compact_index_client_http_fetcher.rb +++ b/test/rubygems/test_gem_compact_index_client_http_fetcher.rb @@ -44,6 +44,12 @@ def initialize end end + class FakeNoContent < Gem::Net::HTTPNoContent + def initialize + super("1.1", "204", "No Content") + end + end + class FakeRangeNotSatisfiable < Gem::Net::HTTPRangeNotSatisfiable def initialize super("1.1", "416", "Range Not Satisfiable") @@ -281,6 +287,16 @@ def test_call_raises_on_range_not_satisfiable_without_range assert_match(/bad response Range Not Satisfiable 416/, error.message) end + def test_call_raises_fetch_error_on_no_content + fetcher, _remote = fetcher_for("https://index.example/versions" => FakeNoContent.new) + + error = assert_raise Gem::RemoteFetcher::FetchError do + fetcher.call("versions") + end + + assert_match(/bad response No Content 204/, error.message) + end + def test_call_raises_fetch_error_on_failure_response fetcher, _remote = fetcher_for("https://index.example/versions" => FakeNotFound.new) From f7be9f5c65cc8ec9308c402a9abc22186acf2671 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 2 Sep 2026 10:37:03 +0900 Subject: [PATCH 07/11] [ruby/rubygems] Surface X-Error-Message in compact index fetch errors Gem::RemoteFetcher already prefers the X-Error-Message header over the status text, so the block reason a corporate proxy sends was lost only on the compact index path. https://github.com/ruby/rubygems/commit/96bcc78924 Co-Authored-By: Claude Fable 5.1 --- lib/rubygems/compact_index_client/http_fetcher.rb | 11 ++++++++--- .../test_gem_compact_index_client_http_fetcher.rb | 15 ++++++++++++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/lib/rubygems/compact_index_client/http_fetcher.rb b/lib/rubygems/compact_index_client/http_fetcher.rb index 0fafcfdcd655f4..652f379b4cdc39 100644 --- a/lib/rubygems/compact_index_client/http_fetcher.rb +++ b/lib/rubygems/compact_index_client/http_fetcher.rb @@ -34,7 +34,7 @@ def fetch(uri, headers, redirects_remaining) when Gem::Net::HTTPSuccess # The callers write the body into the cache, so a body-less success # such as 204 would truncate the cached file. - raise Gem::RemoteFetcher::FetchError.new("bad response #{response.message} #{response.code}", uri) unless response.class.body_permitted? + raise bad_response(response, uri) unless response.class.body_permitted? response when Gem::Net::HTTPMovedPermanently, Gem::Net::HTTPFound, Gem::Net::HTTPSeeOther, @@ -51,14 +51,14 @@ def fetch(uri, headers, redirects_remaining) fetch(redirect, headers, redirects_remaining - 1) when Gem::Net::HTTPRangeNotSatisfiable - raise Gem::RemoteFetcher::FetchError.new("bad response #{response.message} #{response.code}", uri) unless headers.key?("Range") + raise bad_response(response, uri) unless headers.key?("Range") # The local cache is longer than the remote file, refetch it whole. A # matching ETag would otherwise turn the retry into a 304 and keep the # oversized cache. fetch(uri, headers.except("Range", "If-None-Match"), redirects_remaining) else - raise Gem::RemoteFetcher::FetchError.new("bad response #{response.message} #{response.code}", uri) + raise bad_response(response, uri) end end @@ -73,6 +73,11 @@ def request(uri, headers) raise Gem::RemoteFetcher::FetchError.new("#{e.class}: #{e}", uri) end + def bad_response(response, uri) + detail = response["X-Error-Message"] || response.message + Gem::RemoteFetcher::FetchError.new("bad response #{detail} #{response.code}", uri) + end + def https?(uri) uri.scheme == "https" end diff --git a/test/rubygems/test_gem_compact_index_client_http_fetcher.rb b/test/rubygems/test_gem_compact_index_client_http_fetcher.rb index 897acf22da6b01..b97a5d8bd2f3c2 100644 --- a/test/rubygems/test_gem_compact_index_client_http_fetcher.rb +++ b/test/rubygems/test_gem_compact_index_client_http_fetcher.rb @@ -39,8 +39,9 @@ def initialize(location) end class FakeNotFound < Gem::Net::HTTPNotFound - def initialize + def initialize(error_message = nil) super("1.1", "404", "Not Found") + self["X-Error-Message"] = error_message if error_message end end @@ -306,4 +307,16 @@ def test_call_raises_fetch_error_on_failure_response assert_match(/bad response Not Found 404/, error.message) end + + def test_call_includes_x_error_message_in_fetch_error + fetcher, _remote = fetcher_for( + "https://index.example/versions" => FakeNotFound.new("blocked by corporate proxy policy") + ) + + error = assert_raise Gem::RemoteFetcher::FetchError do + fetcher.call("versions") + end + + assert_match(/bad response blocked by corporate proxy policy 404/, error.message) + end end From c82cf0c64f4045648a5e9817a55ad62e93e59f19 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 2 Sep 2026 10:38:15 +0900 Subject: [PATCH 08/11] [ruby/rubygems] Keep credentials on same-host absolute redirects of the compact index `uri + location` drops the userinfo when Location is an absolute URL, so a private index redirecting to another path on the same host lost the credentials and the follow-up request failed authentication. Bundler's downloader already re-applies them; cross-host redirects still drop them. https://github.com/ruby/rubygems/commit/2e8ffc65b6 Co-Authored-By: Claude Fable 5.1 --- lib/rubygems/compact_index_client/http_fetcher.rb | 3 +++ .../test_gem_compact_index_client_http_fetcher.rb | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/lib/rubygems/compact_index_client/http_fetcher.rb b/lib/rubygems/compact_index_client/http_fetcher.rb index 652f379b4cdc39..87040972912f18 100644 --- a/lib/rubygems/compact_index_client/http_fetcher.rb +++ b/lib/rubygems/compact_index_client/http_fetcher.rb @@ -48,6 +48,9 @@ def fetch(uri, headers, redirects_remaining) if https?(uri) && !https?(redirect) raise Gem::RemoteFetcher::FetchError.new("redirecting to non-https resource: #{Gem::Uri.redact(redirect)}", uri) end + # An absolute Location on the same host drops the credentials that a + # relative one would have kept. + redirect.userinfo = uri.userinfo if redirect.host == uri.host && !redirect.userinfo fetch(redirect, headers, redirects_remaining - 1) when Gem::Net::HTTPRangeNotSatisfiable diff --git a/test/rubygems/test_gem_compact_index_client_http_fetcher.rb b/test/rubygems/test_gem_compact_index_client_http_fetcher.rb index b97a5d8bd2f3c2..eb062de65d07ab 100644 --- a/test/rubygems/test_gem_compact_index_client_http_fetcher.rb +++ b/test/rubygems/test_gem_compact_index_client_http_fetcher.rb @@ -230,6 +230,17 @@ def test_call_keeps_credentials_on_an_accepted_redirect assert_equal "s3cr3t", remote.requests.last.first.password end + def test_call_keeps_credentials_on_an_absolute_same_host_redirect + remote = FakeRemoteFetcher.new( + "https://user:s3cr3t@index.example/versions" => FakeRedirect.new("https://index.example/v2/versions"), + "https://user:s3cr3t@index.example/v2/versions" => FakeResponse.new("data") + ) + fetcher = Gem::CompactIndexClient::HTTPFetcher.new("https://user:s3cr3t@index.example", remote) + + assert_equal "data", fetcher.call("versions").body + assert_equal "s3cr3t", remote.requests.last.first.password + end + def test_call_drops_credentials_on_a_cross_host_redirect remote = FakeRemoteFetcher.new( "https://user:s3cr3t@index.example/versions" => FakeRedirect.new("https://mirror.example/versions"), From 19a31ce9b28c960a7da2771fecd9a40e984b9719 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 2 Sep 2026 10:26:17 +0900 Subject: [PATCH 09/11] [ruby/rubygems] Read git source credentials through Settings#credentials_for With `credential_store` enabled, `Settings#set_key` moves host credentials out of the config file and into the OS store, but `GitProxy#configured_uri` still read `Bundler.settings[host]`, which only sees the config file. Private git sources were cloned without credentials and failed. `credentials_for` already covers env, store and config file lookups for rubygems sources, so use it here too. https://github.com/ruby/rubygems/commit/30a2491864 Co-Authored-By: Claude Fable 5.1 --- lib/bundler/source/git/git_proxy.rb | 2 +- .../bundler/source/git/git_proxy_spec.rb | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/lib/bundler/source/git/git_proxy.rb b/lib/bundler/source/git/git_proxy.rb index 42f1e9dd71ffb2..eba61c02386c76 100644 --- a/lib/bundler/source/git/git_proxy.rb +++ b/lib/bundler/source/git/git_proxy.rb @@ -430,7 +430,7 @@ def verify(reference) def configured_uri @configured_uri ||= if /https?:/.match?(uri) remote = Gem::URI(uri) - config_auth = Bundler.settings[remote.to_s] || Bundler.settings[remote.host] + config_auth = Bundler.settings.credentials_for(remote) remote.userinfo ||= config_auth remote.to_s else diff --git a/spec/bundler/bundler/source/git/git_proxy_spec.rb b/spec/bundler/bundler/source/git/git_proxy_spec.rb index 760819c7e51ff4..f47e8f57997031 100644 --- a/spec/bundler/bundler/source/git/git_proxy_spec.rb +++ b/spec/bundler/bundler/source/git/git_proxy_spec.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +require "rubygems/credential_store" +require_relative "../../../support/fake_credential_backend" + RSpec.describe Bundler::Source::Git::GitProxy do let(:path) { Pathname("path") } let(:uri) { "https://github.com/ruby/rubygems.git" } @@ -98,6 +101,36 @@ end end + context "with credentials in the credential store" do + let(:fake_store) { Gem::CredentialStore.new(backend: FakeCredentialBackend.new) } + + before do + Gem::CredentialStore.instance = fake_store + fake_store.set(Bundler::Settings.key_for("github.com"), "u:p") + end + + after { Gem::CredentialStore.reset! } + + it "adds username and password from the store to URI for host" do + Bundler.settings.temporary("credential_store" => "true") do + expect(Bundler.settings["github.com"]).to be_nil + allow(git_proxy).to receive(:git_local).with("--version").and_return("git version 2.14.0") + expect(git_proxy).to receive(:capture).with([*base_clone_args, "--", "https://u:p@github.com/ruby/rubygems.git", path.to_s], nil).and_return(["", "", clone_result]) + subject.checkout + end + end + + it "keeps original userinfo" do + Bundler.settings.temporary("credential_store" => "true") do + original = "https://orig:info@github.com/ruby/rubygems.git" + git_proxy = described_class.new(Pathname("path"), original, options) + allow(git_proxy).to receive(:git_local).with("--version").and_return("git version 2.14.0") + expect(git_proxy).to receive(:capture).with([*base_clone_args, "--", original, path.to_s], nil).and_return(["", "", clone_result]) + git_proxy.checkout + end + end + end + describe "filtering credentials out of command output" do let(:secret) { "s3cr3tp4ss" } let(:credentialed_uri) { "https://user:#{secret}@github.com/ruby/rubygems.git" } From 409c74f979c20289be43aa69451e2ce22ff3d573 Mon Sep 17 00:00:00 2001 From: BurdetteLamar Date: Wed, 2 Sep 2026 08:12:29 -0500 Subject: [PATCH 10/11] [DOC] Harmonize join methods --- file.c | 10 +++++----- pathname_builtin.rb | 21 +++++++++++++++------ 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/file.c b/file.c index 7193bc28dd99f2..cd206a692bb1cf 100644 --- a/file.c +++ b/file.c @@ -6038,14 +6038,14 @@ rb_file_join(long argc, VALUE *args) } /* * call-seq: - * File.join(*objects) -> new_string + * File.join(*components) -> string * - * Returns a new string formed by joining the given string-converted +objects+ + * Returns a new string formed by joining the given string +components+ * with character '/': * - * File.join # => "" - * File.join('foo') # => "foo" - * File.join('foo', 'bar', 'baz') # => "foo/bar/baz" + * File.join # => "" + * File.join('foo') # => "foo" + * File.join(*%w[bar baz bat]) # => "bar/baz/bat" * */ diff --git a/pathname_builtin.rb b/pathname_builtin.rb index b5309a88f78555..79264a18882f06 100644 --- a/pathname_builtin.rb +++ b/pathname_builtin.rb @@ -955,16 +955,25 @@ def plus(path1, path2) # :nodoc: end private :plus + # :markup: markdown + # # call-seq: - # join(*objects) -> new_pathname + # join(*components) -> self or new_pathname + # + # With no arguments, returns `self`. # - # Joins the string-converted given +objects+ to the string path in +self+; + # With arguments, joins the given `components` to the string path in `self` + # with character `'/'`; # returns a new pathname containing the joined string: # - # Pathname('foo').join # => # - # Pathname('foo').join('bar') # => # - # Pathname('foo').join('bar', 'baz') # => # - # Pathname('foo').join(Pathname('bar')) # => # + # ```ruby + # pn = Pathname('foo') # => # + # # String arguments. + # pn.join('bar') # => # + # pn.join(*%w[bar baz bat]) # => # + # # Pathname arguments. + # pn.join(Pathname('bar'), Pathname('baz')) # => # + # ``` # def join(*args) return self if args.empty? From 2fb0f3b490d91feef7811efe3343e75ebdec396b Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 3 Sep 2026 14:42:48 +0900 Subject: [PATCH 11/11] [ruby/rubygems] Order bundler.gemspec authors chronologically and list personal emails Match the layout of rubygems-update.gemspec: authors sorted by first commit date, oldest first, and one personal address per author with an empty string where none is known. https://github.com/ruby/rubygems/commit/198d88f544 Co-Authored-By: Claude Opus 5 --- lib/bundler/bundler.gemspec | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/bundler/bundler.gemspec b/lib/bundler/bundler.gemspec index 62ac4ba3331463..346d156693e018 100644 --- a/lib/bundler/bundler.gemspec +++ b/lib/bundler/bundler.gemspec @@ -12,12 +12,15 @@ Gem::Specification.new do |s| s.version = Bundler::VERSION s.license = "MIT" s.authors = [ - "André Arko", "Samuel Giddins", "Colby Swandale", "Hiroshi Shibata", - "David Rodríguez", "Grey Baker", "Stephanie Morillo", "Chris Morris", "James Wen", "Tim Moore", - "André Medeiros", "Jessica Lynn Suttles", "Terence Lee", "Carl Lerche", - "Yehuda Katz" + "Yehuda Katz", "Carl Lerche", "André Arko", "Terence Lee", "Tim Moore", + "Jessica Lynn Suttles", "Hiroshi SHIBATA", "André Medeiros", "Samuel Giddins", "David Rodríguez", + "James Wen", "Chris Morris", "Colby Swandale", "Grey Baker", "Stephanie Morillo" + ] + s.email = [ + "wycats@gmail.com", "me@carllerche.com", "andre@arko.net", "hone02@gmail.com", "tmoore@incrementalism.net", + "jlsuttles@gmail.com", "hsbt@ruby-lang.org", "me@andremedeiros.info", "segiddins@segiddins.me", "deivid.rodriguez@riseup.net", + "jrw2175@columbia.edu", "chrismo@clabs.org", "colby@rubygems.org", "greysteil@gmail.com", "" ] - s.email = ["team@bundler.io"] s.homepage = "https://bundler.io" s.summary = "The best way to manage your application's dependencies" s.description = "Bundler manages an application's dependencies through its entire life, across many machines, systematically and repeatably"