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
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/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"
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/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/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/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/lib/rubygems/compact_index_client/http_fetcher.rb b/lib/rubygems/compact_index_client/http_fetcher.rb
index 5ba5ee1147820c..87040972912f18 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 bad_response(response, uri) unless response.class.body_permitted?
+
response
when Gem::Net::HTTPMovedPermanently, Gem::Net::HTTPFound, Gem::Net::HTTPSeeOther,
Gem::Net::HTTPTemporaryRedirect, Gem::Net::HTTPPermanentRedirect
@@ -42,15 +48,20 @@ 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
- 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.
- 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)
+ raise bad_response(response, uri)
end
end
@@ -65,6 +76,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/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/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?
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/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" }
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
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_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
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..eb062de65d07ab 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,15 @@ 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
+
+ class FakeNoContent < Gem::Net::HTTPNoContent
+ def initialize
+ super("1.1", "204", "No Content")
end
end
@@ -223,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"),
@@ -246,14 +264,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 +286,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
@@ -275,6 +299,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)
@@ -284,4 +318,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
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