Skip to content

Commit af9cd43

Browse files
hsbtclaude
andcommitted
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. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent d6b1dde commit af9cd43

9 files changed

Lines changed: 108 additions & 4 deletions

lib/bundler/endpoint_specification.rb

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,11 +193,16 @@ def parse_metadata(data)
193193
TIME_ZONE_SUFFIX = /(?:Z|z|[+-]\d{2}(?::?\d{2})?)\z/
194194
private_constant :TIME_ZONE_SUFFIX
195195

196+
# See Gem::Cooldown::FOUR_DIGIT_YEAR.
197+
FOUR_DIGIT_YEAR = /\A\d{4}-/
198+
private_constant :FOUR_DIGIT_YEAR
199+
196200
# A timestamp without a time zone offset is read as UTC, because reading
197201
# it as local time would shift the cooldown window by the environment's
198-
# offset. Unparsable values become nil so the cooldown fails open.
202+
# offset. Unparsable values and years outside four digits become nil so
203+
# the cooldown fails open.
199204
def parse_created_at(value)
200-
return unless value.is_a?(String)
205+
return unless value.is_a?(String) && value.match?(FOUR_DIGIT_YEAR)
201206

202207
require "time"
203208
begin

lib/rubygems/cooldown.rb

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,14 +94,21 @@ def self.output_skipped_summary(entries)
9494
TIME_ZONE_SUFFIX = /(?:Z|z|[+-]\d{2}(?::?\d{2})?)\z/ # :nodoc:
9595
private_constant :TIME_ZONE_SUFFIX
9696

97+
# Matches the four-digit year an ISO 8601 timestamp starts with.
98+
# Time.iso8601 also accepts a year of any length, and one far enough
99+
# away overflows the Float arithmetic behind #remaining_days.
100+
FOUR_DIGIT_YEAR = /\A\d{4}-/ # :nodoc:
101+
private_constant :FOUR_DIGIT_YEAR
102+
97103
##
98104
# Parses a +created_at+ timestamp from the compact index. A timestamp
99105
# without a time zone offset is read as UTC, because reading it as local
100106
# time would shift the cooldown window by the environment's offset.
101-
# Returns nil for anything unparsable, so the cooldown fails open.
107+
# Returns nil for anything unparsable, including a year outside four
108+
# digits, so the cooldown fails open.
102109

103110
def self.parse_created_at(value)
104-
return unless value.is_a?(String)
111+
return unless value.is_a?(String) && value.match?(FOUR_DIGIT_YEAR)
105112

106113
require "time"
107114
begin

spec/bundler/endpoint_specification_spec.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,14 @@ def with_tz(tz)
9797
end
9898
end
9999

100+
context "when created_at has a year that overflows Float arithmetic" do
101+
let(:metadata) { { "created_at" => ["#{"9" * 400}-01-01T00:00:00Z"] } }
102+
103+
it "leaves created_at as nil" do
104+
expect(subject.created_at).to be_nil
105+
end
106+
end
107+
100108
context "when the metadata has an empty checksum value" do
101109
let(:metadata) { { "checksum" => [] } }
102110

spec/bundler/resolver/cooldown_spec.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,16 @@ def spec(created_at:, remote:, name: "myrack", version: "1.0.0")
7373
end
7474
end
7575

76+
context "when created_at has a year that overflows Float arithmetic" do
77+
it "keeps the spec like one without created_at" do
78+
metadata = { "created_at" => ["#{"9" * 400}-01-01T00:00:00Z"] }
79+
s = Bundler::EndpointSpecification.new("myrack", "1.0.0", Gem::Platform::RUBY, nil, [], metadata)
80+
s.remote = remote(cooldown: 7)
81+
82+
expect(resolver.send(:filter_cooldown, [s])).to eq([s])
83+
end
84+
end
85+
7686
context "when the remote has no cooldown" do
7787
it "keeps every spec" do
7888
s = spec(created_at: now - 3600, remote: remote(cooldown: nil))

spec/install/cooldown_spec.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,22 @@
424424
expect(the_bundle).to include_gems("ripe_gem 2.0.0")
425425
end
426426

427+
it "treats a created_at with a year that overflows Float arithmetic as unknown" do
428+
gemfile <<-G
429+
source "https://gem.repo3"
430+
gem "ripe_gem"
431+
G
432+
433+
bundle "install --cooldown 7", artifice: "compact_index_cooldown_bad_created_at"
434+
435+
expect(the_bundle).to include_gems("ripe_gem 2.0.0")
436+
expect(out).not_to include("skipped by the cooldown setting")
437+
438+
bundle "outdated --cooldown 7", artifice: "compact_index_cooldown_bad_created_at", raise_on_error: false
439+
440+
expect(out).not_to include("cooldown")
441+
end
442+
427443
it "annotates in-cooldown versions in bundle outdated table output" do
428444
gemfile <<-G
429445
source "https://gem.repo3"
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "helpers/compact_index_cooldown"
4+
5+
# Serves every version with a created_at year that Time.iso8601 accepts but
6+
# whose distance from now overflows Float.
7+
class CompactIndexCooldownBadCreatedAt < CompactIndexCooldownAPI
8+
helpers do
9+
def build_gem_version(spec, deps, checksum)
10+
CompactIndex::GemVersionV2.new(spec.version.version, spec.platform.to_s, checksum, nil,
11+
deps, spec.required_ruby_version.to_s, spec.required_rubygems_version.to_s, "#{"9" * 400}-01-01T00:00:00Z")
12+
end
13+
end
14+
end
15+
16+
require_relative "helpers/artifice"
17+
18+
Artifice.activate_with(CompactIndexCooldownBadCreatedAt)

test/rubygems/test_gem_commands_install_command.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,25 @@ def test_execute_remote_cooldown_explicit_version_error
788788
assert_match "--cooldown 0", @ui.error
789789
end
790790

791+
def test_execute_remote_cooldown_unparsable_created_at_fails_open
792+
util_setup_cooldown_repo created_at: {
793+
"a-1" => util_cooldown_time(30),
794+
"a-2" => "#{"9" * 400}-01-01T00:00:00Z",
795+
}
796+
797+
@cmd.options[:cooldown] = 7
798+
@cmd.options[:args] = %w[a]
799+
800+
use_ui @ui do
801+
assert_raise Gem::MockGemUi::SystemExitException, @ui.error do
802+
@cmd.execute
803+
end
804+
end
805+
806+
assert_equal %w[a-2], @cmd.installed_specs.map(&:full_name)
807+
refute_match "skipped by the cooldown setting", @ui.output
808+
end
809+
791810
def test_execute_remote_cooldown_missing_created_at_fails_open
792811
util_setup_cooldown_repo
793812

test/rubygems/test_gem_commands_outdated_command.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,20 @@ def test_execute_cooldown_missing_created_at_fails_open
107107
assert_equal 1, @ui.error.scan("publish times").size
108108
end
109109

110+
def test_execute_cooldown_unparsable_created_at_fails_open
111+
util_setup_cooldown_repo "foo-0.2" => util_cooldown_time(30),
112+
"foo-0.3" => "#{"9" * 400}-01-01T00:00:00Z"
113+
114+
@cmd.options[:cooldown] = 7
115+
116+
use_ui @ui do
117+
@cmd.execute
118+
end
119+
120+
assert_equal "foo (0.1 < 0.3)\n", @ui.output
121+
assert_equal "", @ui.error
122+
end
123+
110124
def test_cooldown_option
111125
@cmd.handle_options %w[--cooldown 7]
112126

test/rubygems/test_gem_cooldown.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,13 @@ def test_parse_created_at_invalid
113113
assert_nil Gem::Cooldown.parse_created_at(7)
114114
end
115115

116+
def test_parse_created_at_rejects_years_outside_four_digits
117+
# Time.iso8601 accepts these, but the distance from now overflows Float.
118+
assert_nil Gem::Cooldown.parse_created_at("#{"9" * 400}-01-01T00:00:00Z")
119+
assert_nil Gem::Cooldown.parse_created_at("-2026-06-05T10:30:45Z")
120+
assert_nil Gem::Cooldown.parse_created_at("02026-06-05T10:30:45Z")
121+
end
122+
116123
def with_tz(tz)
117124
orig_tz = ENV["TZ"]
118125
ENV["TZ"] = tz

0 commit comments

Comments
 (0)