diff --git a/Manifest.txt b/Manifest.txt
index 8d7b9a0f0961..64004690adc8 100644
--- a/Manifest.txt
+++ b/Manifest.txt
@@ -391,6 +391,8 @@ lib/rubygems/compact_index_client/http_fetcher.rb
lib/rubygems/compact_index_client/parser.rb
lib/rubygems/compact_index_client/updater.rb
lib/rubygems/config_file.rb
+lib/rubygems/cooldown.rb
+lib/rubygems/cooldown_option.rb
lib/rubygems/core_ext/kernel_gem.rb
lib/rubygems/core_ext/kernel_require.rb
lib/rubygems/core_ext/kernel_warn.rb
diff --git a/lib/rubygems/commands/outdated_command.rb b/lib/rubygems/commands/outdated_command.rb
index 08a9221a261a..7721be88e71f 100644
--- a/lib/rubygems/commands/outdated_command.rb
+++ b/lib/rubygems/commands/outdated_command.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
require_relative "../command"
+require_relative "../cooldown"
+require_relative "../cooldown_option"
require_relative "../local_remote_options"
require_relative "../spec_fetcher"
require_relative "../version_option"
@@ -8,12 +10,14 @@
class Gem::Commands::OutdatedCommand < Gem::Command
include Gem::LocalRemoteOptions
include Gem::VersionOption
+ include Gem::CooldownOption
def initialize
super "outdated", "Display all gems that need updates"
add_local_remote_options
add_platform_option
+ add_cooldown_option
end
def description # :nodoc:
@@ -26,8 +30,79 @@ def description # :nodoc:
end
def execute
- Gem::Specification.outdated_and_latest_version.each do |spec, remote_version|
- say "#{spec.name} (#{spec.version} < #{remote_version})"
+ @cooldown = Gem::Cooldown.from_options options
+
+ unless @cooldown.active?
+ Gem::Specification.outdated_and_latest_version.each do |spec, remote_version|
+ say "#{spec.name} (#{spec.version} < #{remote_version})"
+ end
+
+ return
end
+
+ execute_with_cooldown
+ end
+
+ private
+
+ ##
+ # Like Gem::Specification.outdated_and_latest_version, but the newest
+ # version outside the cooldown period becomes the update candidate, and
+ # newer versions still within the period are annotated.
+
+ def execute_with_cooldown
+ fetcher = Gem::SpecFetcher.fetcher
+
+ Gem::Specification.latest_specs(true).each do |local_spec|
+ dependency = Gem::Dependency.new local_spec.name, ">= #{local_spec.version}"
+
+ # The :latest index carries only the newest version of each gem,
+ # which leaves nothing to fall back to when the cooldown excludes
+ # it, so search the full index instead.
+ remotes, = fetcher.search_for_dependency dependency,
+ type: dependency.prerelease? ? :complete : :released
+
+ selectable, embargoed = partition_by_cooldown remotes
+
+ candidate = selectable.max
+ candidate = nil unless candidate && local_spec.version < candidate
+
+ pending = embargoed.max
+ pending = nil unless pending && local_spec.version < pending &&
+ (candidate.nil? || candidate < pending)
+
+ next unless candidate || pending
+
+ pending = "#{pending} (cooldown #{@cooldown.days}d)" if pending
+ say "#{local_spec.name} (#{local_spec.version} < #{[candidate, pending].compact.join(", ")})"
+ end
+ end
+
+ ##
+ # Splits [NameTuple, Gem::Source] pairs into versions outside and within
+ # the cooldown period. Tuples with an unknown publish time count as
+ # outside the period, so the cooldown fails open.
+
+ def partition_by_cooldown(spec_tuples)
+ selectable = []
+ embargoed = []
+
+ with_times = spec_tuples.map do |tup, source|
+ [tup, source, source.created_at(tup.name, tup.version, tup.platform)]
+ end
+
+ if !with_times.empty? && with_times.none? {|_, _, created_at| created_at }
+ Gem::Cooldown.warn_missing_created_at with_times.first[1]
+ end
+
+ with_times.each do |tup, _, created_at|
+ if @cooldown.skip?(created_at)
+ embargoed << tup.version
+ else
+ selectable << tup.version
+ end
+ end
+
+ [selectable, embargoed]
end
end
diff --git a/lib/rubygems/commands/update_command.rb b/lib/rubygems/commands/update_command.rb
index cb0a2660dde4..daa9f53a673d 100644
--- a/lib/rubygems/commands/update_command.rb
+++ b/lib/rubygems/commands/update_command.rb
@@ -2,6 +2,7 @@
require_relative "../command"
require_relative "../command_manager"
+require_relative "../cooldown"
require_relative "../dependency_installer"
require_relative "../install_update_options"
require_relative "../local_remote_options"
@@ -95,6 +96,8 @@ def check_update_arguments # :nodoc:
end
def execute
+ @cooldown = Gem::Cooldown.from_options options
+
if options[:system]
update_rubygems
return
@@ -140,7 +143,12 @@ def fetch_remote_gems(spec) # :nodoc:
fetcher = Gem::SpecFetcher.fetcher
- spec_tuples, errors = fetcher.search_for_dependency dependency
+ # The default indexes carry only the newest version of each gem, which
+ # leaves nothing to fall back to when the cooldown excludes it, so
+ # search the full index instead.
+ type = dependency.prerelease? ? :complete : :released if @cooldown&.active?
+
+ spec_tuples, errors = fetcher.search_for_dependency dependency, type: type
error = errors.find {|e| e.respond_to? :exception }
@@ -167,12 +175,32 @@ def highest_installed_gems # :nodoc:
def highest_remote_name_tuple(spec) # :nodoc:
spec_tuples = fetch_remote_gems spec
- highest_remote_gem = spec_tuples.max
+ highest_remote_gem = filter_cooldown_tuples(spec_tuples).max
return unless highest_remote_gem
highest_remote_gem.first
end
+ ##
+ # Rejects [NameTuple, Gem::Source] pairs published within the cooldown
+ # period. Tuples with an unknown publish time are kept, so the cooldown
+ # fails open.
+
+ def filter_cooldown_tuples(spec_tuples) # :nodoc:
+ return spec_tuples unless @cooldown&.active?
+
+ with_times = spec_tuples.map do |tup, source|
+ [tup, source, source.created_at(tup.name, tup.version, tup.platform)]
+ end
+
+ if !with_times.empty? && with_times.none? {|_, _, created_at| created_at }
+ Gem::Cooldown.warn_missing_created_at with_times.first[1]
+ end
+
+ with_times.reject {|_, _, created_at| @cooldown.skip?(created_at) }.
+ map {|tup, source, _| [tup, source] }
+ end
+
def install_rubygems(spec) # :nodoc:
args = update_rubygems_arguments
version = spec.version
diff --git a/lib/rubygems/config_file.rb b/lib/rubygems/config_file.rb
index d5e9eb4e33ae..3d63af60487b 100644
--- a/lib/rubygems/config_file.rb
+++ b/lib/rubygems/config_file.rb
@@ -27,6 +27,7 @@
#
# +:backtrace+:: See #backtrace
# +:bulk_threshold+:: See #bulk_threshold
+# +:cooldown+:: See #cooldown
# +:verbose+:: See #verbose
# +:update_sources+:: See #update_sources
# +:concurrent_downloads+:: See #concurrent_downloads
@@ -55,6 +56,7 @@ class Gem::ConfigFile
DEFAULT_BACKTRACE = true
DEFAULT_BULK_THRESHOLD = 1000
+ DEFAULT_COOLDOWN = 0
DEFAULT_VERBOSITY = true
DEFAULT_UPDATE_SOURCES = true
DEFAULT_CONCURRENT_DOWNLOADS = 8
@@ -116,6 +118,13 @@ class Gem::ConfigFile
attr_accessor :bulk_threshold
+ ##
+ # Number of days a newly published gem version must wait before it is
+ # considered for installation or update (the cooldown period). 0
+ # disables the cooldown.
+
+ attr_accessor :cooldown
+
##
# Verbose level of output:
# * false -- No output
@@ -211,6 +220,7 @@ def initialize(args)
@backtrace = DEFAULT_BACKTRACE
@bulk_threshold = DEFAULT_BULK_THRESHOLD
+ @cooldown = DEFAULT_COOLDOWN
@verbose = DEFAULT_VERBOSITY
@update_sources = DEFAULT_UPDATE_SOURCES
@concurrent_downloads = DEFAULT_CONCURRENT_DOWNLOADS
@@ -239,7 +249,7 @@ def initialize(args)
@hash.transform_keys! do |k|
# gemhome and gempath are not working with symbol keys
- if %w[backtrace bulk_threshold verbose update_sources cert_expiration_length_days
+ if %w[backtrace bulk_threshold cooldown verbose update_sources cert_expiration_length_days
concurrent_downloads install_extension_in_lib ipv4_fallback_enabled
global_gem_cache use_psych sources
disable_default_gem_server ssl_verify_mode ssl_ca_cert ssl_client_cert].include?(k)
@@ -252,6 +262,7 @@ def initialize(args)
# HACK: these override command-line args, which is bad
@backtrace = @hash[:backtrace] if @hash.key? :backtrace
@bulk_threshold = @hash[:bulk_threshold] if @hash.key? :bulk_threshold
+ @cooldown = @hash[:cooldown] if @hash.key? :cooldown
@verbose = @hash[:verbose] if @hash.key? :verbose
@update_sources = @hash[:update_sources] if @hash.key? :update_sources
@concurrent_downloads = @hash[:concurrent_downloads] if @hash.key? :concurrent_downloads
diff --git a/lib/rubygems/cooldown.rb b/lib/rubygems/cooldown.rb
new file mode 100644
index 000000000000..b6474b52b82f
--- /dev/null
+++ b/lib/rubygems/cooldown.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require_relative "user_interaction"
+
+##
+# Applies a cooldown period to remote gem versions as a supply chain attack
+# mitigation. When a cooldown of N days is configured, gem versions
+# published within the last N days are not considered for installation or
+# update. Versions whose publish time is unknown are never excluded, so
+# sources that do not provide publish times keep working.
+#
+# The cooldown period comes from the --cooldown DAYS option when
+# given, falling back to the :cooldown: setting in the gemrc file.
+# A value of 0 disables the cooldown.
+
+class Gem::Cooldown
+ ##
+ # The cooldown period in days.
+
+ attr_reader :days
+
+ ##
+ # Creates a Cooldown from the command line +options+, preferring the
+ # --cooldown option over the :cooldown: gemrc setting.
+
+ def self.from_options(options)
+ new(options[:cooldown] || Gem.configuration.cooldown)
+ end
+
+ def initialize(days, now: Time.now)
+ @days = days.to_i
+ @now = now
+ end
+
+ ##
+ # True when a cooldown period is configured.
+
+ def active?
+ @days > 0
+ end
+
+ ##
+ # True when a gem version published at +created_at+ must not be
+ # considered. Versions with an unknown publish time (+nil+) are kept.
+
+ def skip?(created_at)
+ return false unless active?
+ return false unless created_at
+
+ (@now - created_at) < @days * 86_400
+ end
+
+ ##
+ # Warns once per process that +source+ did not provide publish times, so
+ # the cooldown cannot be applied to gems from it.
+
+ def self.warn_missing_created_at(source)
+ return if @warned
+ @warned = true
+
+ Gem::DefaultUserInteraction.ui.alert_warning \
+ "#{source.uri} does not provide gem publish times, the cooldown period does not apply to gems from this source"
+ end
+
+ def self.reset_warned_missing_created_at # :nodoc:
+ @warned = nil
+ end
+end
diff --git a/lib/rubygems/cooldown_option.rb b/lib/rubygems/cooldown_option.rb
new file mode 100644
index 000000000000..84195d39f1ab
--- /dev/null
+++ b/lib/rubygems/cooldown_option.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require_relative "cooldown"
+
+##
+# Mixin methods for the cooldown option for Gem::Commands.
+
+module Gem::CooldownOption
+ ##
+ # Add the --cooldown option to the option parser.
+
+ def add_cooldown_option(group = nil)
+ args = [group, "--cooldown DAYS", Integer,
+ "Do not use gem versions published within",
+ "the last DAYS days (0 disables the cooldown)"].compact
+
+ add_option(*args) do |value, options|
+ options[:cooldown] = value
+ end
+ end
+end
diff --git a/lib/rubygems/dependency_installer.rb b/lib/rubygems/dependency_installer.rb
index c842714d9580..804b20b359ef 100644
--- a/lib/rubygems/dependency_installer.rb
+++ b/lib/rubygems/dependency_installer.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
require_relative "../rubygems"
+require_relative "cooldown"
require_relative "dependency_list"
require_relative "package"
require_relative "installer"
@@ -90,6 +91,7 @@ def initialize(options = {})
@prog_mode = options[:prog_mode]
@build_extension = options[:build_extension]
@install_plugin = options[:install_plugin]
+ @cooldown = Gem::Cooldown.from_options options
# Indicates that we should not try to update any deps unless
# we absolutely must.
@@ -210,6 +212,7 @@ def resolve_dependencies(dep_or_name, version) # :nodoc:
request_set.development_shallow = @dev_shallow
request_set.soft_missing = @force
request_set.prerelease = @prerelease
+ request_set.cooldown = @cooldown
installer_set = Gem::Resolver::InstallerSet.new @domain
installer_set.ignore_installed = (@minimal_deps == false) || @only_install_dir
diff --git a/lib/rubygems/install_update_options.rb b/lib/rubygems/install_update_options.rb
index e8859cadaf15..942b1af214c0 100644
--- a/lib/rubygems/install_update_options.rb
+++ b/lib/rubygems/install_update_options.rb
@@ -7,12 +7,14 @@
#++
require_relative "../rubygems"
+require_relative "cooldown_option"
require_relative "security_option"
##
# Mixin methods for install and update options for Gem::Commands
module Gem::InstallUpdateOptions
+ include Gem::CooldownOption
include Gem::SecurityOption
##
@@ -204,6 +206,8 @@ def add_install_update_options
"Defaults to true") do |v, _o|
options[:install_plugin] = v
end
+
+ add_cooldown_option :"Install/Update"
end
##
diff --git a/lib/rubygems/request_set.rb b/lib/rubygems/request_set.rb
index 9737cba7bfb7..d97c313da302 100644
--- a/lib/rubygems/request_set.rb
+++ b/lib/rubygems/request_set.rb
@@ -1,5 +1,6 @@
# frozen_string_literal: true
+require_relative "cooldown"
require_relative "vendored_tsort"
##
@@ -23,6 +24,11 @@ class Gem::RequestSet
attr_accessor :always_install
+ ##
+ # The Gem::Cooldown applied to release candidates, if any.
+
+ attr_accessor :cooldown
+
attr_reader :dependencies
attr_accessor :development
@@ -96,6 +102,7 @@ def initialize(*deps)
@always_install = []
@conservative = false
+ @cooldown = nil
@dependency_names = {}
@development = false
@development_shallow = false
@@ -223,6 +230,7 @@ def install_from_gemdeps(options, &block)
@prerelease = options[:prerelease]
@remote = options[:domain] != :local
@conservative = true if options[:conservative]
+ @cooldown = Gem::Cooldown.from_options options
gem_deps_api = load_gemdeps gemdeps, options[:without_groups], true
@@ -442,6 +450,7 @@ def resolve(set = Gem::Resolver::BestSet.new)
set.prerelease = @prerelease
resolver = Gem::Resolver.new @dependencies, set
+ resolver.cooldown = @cooldown
resolver.development = @development
resolver.development_shallow = @development_shallow
resolver.ignore_dependencies = @ignore_dependencies
diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb
index 788206c0566f..492617b52ba2 100644
--- a/lib/rubygems/resolver.rb
+++ b/lib/rubygems/resolver.rb
@@ -34,6 +34,11 @@ class Gem::Resolver
attr_accessor :ignore_dependencies
+ ##
+ # The Gem::Cooldown applied to release candidates, if any.
+
+ attr_accessor :cooldown
+
##
# Hash of gems to skip resolution. Keyed by gem name, with arrays of
# gem specifications as values.
@@ -94,6 +99,7 @@ def initialize(needed, set = nil)
@set = set || Gem::Resolver::IndexSet.new
@needed = needed
+ @cooldown = nil
@development = false
@development_shallow = false
@ignore_dependencies = false
@@ -373,9 +379,27 @@ def filter_specs(specs)
end
end
+ filtered = filter_cooldown_specs(filtered) if @cooldown&.active?
+
filtered
end
+ ##
+ # Rejects specs published within the cooldown period. Specs with an
+ # unknown publish time (installed gems, lockfiles, sources without
+ # timestamps) are kept, so the cooldown fails open.
+
+ def filter_cooldown_specs(specs)
+ remote = specs.select do |s|
+ Gem::Resolver::APISpecification === s || Gem::Resolver::IndexSpecification === s
+ end
+ if remote.any? && remote.none?(&:created_at)
+ Gem::Cooldown.warn_missing_created_at remote.first.source
+ end
+
+ specs.reject {|s| @cooldown.skip?(s.created_at) }
+ end
+
def spec_for(name, version)
@spec_for_cache[name][version]
end
@@ -486,6 +510,17 @@ def build_extended_explanation(name, constraint)
hints << "#{name} #{versions.join(", ")} requires Ruby #{ruby_req} (you have #{Gem.ruby_version})"
end
+ # Check for specs filtered by the cooldown period
+ if @cooldown&.active?
+ cooldown_specs = installable.select do |s|
+ constraint.range.include?(s.version) && @cooldown.skip?(s.created_at)
+ end
+ if cooldown_specs.any?
+ versions = cooldown_specs.map(&:version).uniq.sort.reverse.first(3)
+ hints << "#{name} #{versions.join(", ")} published within the cooldown period (#{@cooldown.days} days). Use --cooldown 0 to bypass it."
+ end
+ end
+
# Check for specs filtered by prerelease status
if prerelease_gated
prerelease_versions = @all_versions[pkg].select(&:prerelease?)
diff --git a/lib/rubygems/source.rb b/lib/rubygems/source.rb
index b01619371efc..3060bfaa3129 100644
--- a/lib/rubygems/source.rb
+++ b/lib/rubygems/source.rb
@@ -184,6 +184,42 @@ def compact_index_client # :nodoc:
end
end
+ ##
+ # The publish time of gem +name+ at +version+ for +platform+, when this
+ # source provides it through the compact index created_at metadata.
+ # Returns nil when the source, the gem or the version has no known
+ # publish time.
+
+ def created_at(name, version, platform = Gem::Platform::RUBY)
+ return unless %w[http https].include?(uri.scheme)
+
+ @created_at_info ||= {}
+ info = @created_at_info[name] ||= begin
+ compact_index_client.fetch_info(name)
+ rescue Gem::RemoteFetcher::FetchError, Gem::CompactIndexClient::Error
+ []
+ end
+
+ platform = (platform || Gem::Platform::RUBY).to_s
+ version = version.to_s
+
+ row = info.find do |row_info|
+ row_info[Gem::CompactIndexClient::INFO_VERSION] == version &&
+ (row_info[Gem::CompactIndexClient::INFO_PLATFORM] || Gem::Platform::RUBY) == platform
+ end
+ return unless row
+
+ value = row[Gem::CompactIndexClient::INFO_REQS].assoc("created_at")&.last&.first
+ return unless value.is_a?(String)
+
+ require "time"
+ begin
+ Time.iso8601(value)
+ rescue ArgumentError
+ nil
+ end
+ end
+
##
# Downloads +spec+ and writes it to +dir+. See also
# Gem::RemoteFetcher#download.
diff --git a/lib/rubygems/spec_fetcher.rb b/lib/rubygems/spec_fetcher.rb
index 835dedf9489a..6f06b554d2c1 100644
--- a/lib/rubygems/spec_fetcher.rb
+++ b/lib/rubygems/spec_fetcher.rb
@@ -82,13 +82,16 @@ def initialize(sources = nil)
# Find and fetch gem name tuples that match +dependency+.
#
# If +matching_platform+ is false, gems for all platforms are returned.
+ #
+ # +type+ overrides the index type derived from +dependency+. See
+ # #available_specs for the list of types.
- def search_for_dependency(dependency, matching_platform = true)
+ def search_for_dependency(dependency, matching_platform = true, type: nil)
found = {}
rejected_specs = {}
- list, errors = available_specs(dependency.identity)
+ list, errors = available_specs(type || dependency.identity)
list.each do |source, specs|
if dependency.name.is_a?(String) && specs.respond_to?(:bsearch)
diff --git a/test/rubygems/helper.rb b/test/rubygems/helper.rb
index 4c47d46835e3..808454faecb9 100644
--- a/test/rubygems/helper.rb
+++ b/test/rubygems/helper.rb
@@ -536,6 +536,10 @@ def teardown
Gem::RemoteFetcher.fetcher = nil
end
+ if defined? Gem::Cooldown
+ Gem::Cooldown.reset_warned_missing_created_at
+ end
+
Dir.chdir @current_dir
ENV.replace(@orig_env)
diff --git a/test/rubygems/test_gem_commands_install_command.rb b/test/rubygems/test_gem_commands_install_command.rb
index d75ba349f96a..27c200856e1b 100644
--- a/test/rubygems/test_gem_commands_install_command.rb
+++ b/test/rubygems/test_gem_commands_install_command.rb
@@ -729,6 +729,130 @@ def test_execute_remote
assert_match "1 gem installed", @ui.output
end
+ def util_setup_cooldown_repo(created_at: {})
+ spec_fetcher
+
+ a1, a1_gem = util_gem "a", 1
+ a2, a2_gem = util_gem "a", 2
+
+ util_setup_compact_index a1, a2, created_at: created_at
+
+ add_to_fetcher a1, a1_gem
+ add_to_fetcher a2, a2_gem
+ end
+
+ def util_cooldown_time(days_ago)
+ (Time.now - days_ago * 86_400).utc.strftime("%Y-%m-%dT%H:%M:%SZ")
+ end
+
+ def test_execute_remote_cooldown_falls_back_to_older_version
+ util_setup_cooldown_repo created_at: {
+ "a-1" => util_cooldown_time(30),
+ "a-2" => util_cooldown_time(1),
+ }
+
+ @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-1], @cmd.installed_specs.map(&:full_name)
+ end
+
+ def test_execute_remote_cooldown_explicit_version_error
+ util_setup_cooldown_repo created_at: {
+ "a-1" => util_cooldown_time(30),
+ "a-2" => util_cooldown_time(1),
+ }
+
+ @cmd.options[:cooldown] = 7
+ @cmd.options[:version] = Gem::Requirement.new("= 2")
+ @cmd.options[:args] = %w[a]
+
+ use_ui @ui do
+ e = assert_raise Gem::MockGemUi::TermError do
+ @cmd.execute
+ end
+
+ assert_equal 2, e.exit_code
+ end
+
+ assert_empty @cmd.installed_specs
+ assert_match "cooldown period (7 days)", @ui.error
+ assert_match "--cooldown 0", @ui.error
+ end
+
+ def test_execute_remote_cooldown_missing_created_at_fails_open
+ util_setup_cooldown_repo
+
+ @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)
+ assert_equal 1, @ui.error.scan("publish times").size
+ end
+
+ def test_execute_remote_cooldown_from_gemrc
+ util_setup_cooldown_repo created_at: {
+ "a-1" => util_cooldown_time(30),
+ "a-2" => util_cooldown_time(1),
+ }
+
+ orig_cooldown = Gem.configuration.cooldown
+ Gem.configuration.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-1], @cmd.installed_specs.map(&:full_name)
+ ensure
+ Gem.configuration.cooldown = orig_cooldown
+ end
+
+ def test_execute_remote_cooldown_zero_overrides_gemrc
+ util_setup_cooldown_repo created_at: {
+ "a-1" => util_cooldown_time(30),
+ "a-2" => util_cooldown_time(1),
+ }
+
+ orig_cooldown = Gem.configuration.cooldown
+ Gem.configuration.cooldown = 7
+
+ @cmd.options[:cooldown] = 0
+ @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)
+ ensure
+ Gem.configuration.cooldown = orig_cooldown
+ end
+
+ def test_cooldown_option
+ @cmd.handle_options %w[--cooldown 7 a]
+
+ assert_equal 7, @cmd.options[:cooldown]
+ end
+
def test_execute_with_invalid_gem_file
FileUtils.touch("a.gem")
diff --git a/test/rubygems/test_gem_commands_outdated_command.rb b/test/rubygems/test_gem_commands_outdated_command.rb
index 07289d821fba..4f88aef0f03b 100644
--- a/test/rubygems/test_gem_commands_outdated_command.rb
+++ b/test/rubygems/test_gem_commands_outdated_command.rb
@@ -50,6 +50,69 @@ def test_execute_compact_index
assert_equal "", @ui.error
end
+ def util_cooldown_time(days_ago)
+ (Time.now - days_ago * 86_400).utc.strftime("%Y-%m-%dT%H:%M:%SZ")
+ end
+
+ def util_setup_cooldown_repo(created_at)
+ spec_fetcher do |fetcher|
+ fetcher.gem "foo", "0.1"
+ end
+
+ specs = created_at.keys.map {|full_name| util_spec "foo", full_name.delete_prefix("foo-") }
+ util_setup_compact_index(*specs, created_at: created_at.compact)
+
+ # drop the in-memory tuples spec_fetcher pre-populated so the lookup
+ # goes through Gem::Source#load_specs
+ Gem::SpecFetcher.fetcher = nil
+ end
+
+ def test_execute_cooldown_annotates_newer_version_within_period
+ util_setup_cooldown_repo "foo-0.2" => util_cooldown_time(30),
+ "foo-0.3" => util_cooldown_time(1)
+
+ @cmd.options[:cooldown] = 7
+
+ use_ui @ui do
+ @cmd.execute
+ end
+
+ assert_equal "foo (0.1 < 0.2, 0.3 (cooldown 7d))\n", @ui.output
+ assert_equal "", @ui.error
+ end
+
+ def test_execute_cooldown_only_version_within_period
+ util_setup_cooldown_repo "foo-0.3" => util_cooldown_time(1)
+
+ @cmd.options[:cooldown] = 7
+
+ use_ui @ui do
+ @cmd.execute
+ end
+
+ assert_equal "foo (0.1 < 0.3 (cooldown 7d))\n", @ui.output
+ assert_equal "", @ui.error
+ end
+
+ def test_execute_cooldown_missing_created_at_fails_open
+ util_setup_cooldown_repo "foo-0.2" => nil, "foo-0.3" => nil
+
+ @cmd.options[:cooldown] = 7
+
+ use_ui @ui do
+ @cmd.execute
+ end
+
+ assert_equal "foo (0.1 < 0.3)\n", @ui.output
+ assert_equal 1, @ui.error.scan("publish times").size
+ end
+
+ def test_cooldown_option
+ @cmd.handle_options %w[--cooldown 7]
+
+ assert_equal 7, @cmd.options[:cooldown]
+ end
+
def test_execute_with_up_to_date_platform_specific_gem
spec_fetcher do |fetcher|
fetcher.download "foo", "2.0"
diff --git a/test/rubygems/test_gem_commands_update_command.rb b/test/rubygems/test_gem_commands_update_command.rb
index 89b56e0f7cfd..87f500fc1bdc 100644
--- a/test/rubygems/test_gem_commands_update_command.rb
+++ b/test/rubygems/test_gem_commands_update_command.rb
@@ -70,6 +70,121 @@ def test_execute_compact_index
assert_path_exist File.join(@gemhome, "specifications", "b-2.gemspec")
end
+ def util_cooldown_time(days_ago)
+ (Time.now - days_ago * 86_400).utc.strftime("%Y-%m-%dT%H:%M:%SZ")
+ end
+
+ def util_setup_cooldown_repo(b2_created_at:, b3_created_at:)
+ spec_fetcher do |fetcher|
+ fetcher.gem "b", 1
+ end
+
+ b2, b2_gem = util_gem "b", 2
+ b3, b3_gem = util_gem "b", 3
+ util_setup_compact_index b2, b3, created_at: {
+ "b-2" => b2_created_at,
+ "b-3" => b3_created_at,
+ }.compact
+ add_to_fetcher b2, b2_gem
+ add_to_fetcher b3, b3_gem
+
+ # drop the in-memory tuples spec_fetcher pre-populated so the lookup
+ # goes through Gem::Source#load_specs
+ Gem::SpecFetcher.fetcher = nil
+ end
+
+ def test_execute_cooldown_falls_back_to_older_version
+ util_setup_cooldown_repo b2_created_at: util_cooldown_time(30),
+ b3_created_at: util_cooldown_time(1)
+
+ @cmd.options[:cooldown] = 7
+ @cmd.options[:args] = []
+
+ use_ui @ui do
+ @cmd.execute
+ end
+
+ out = @ui.output.split "\n"
+ assert_equal "Updating installed gems", out.shift
+ assert_equal "Updating b", out.shift
+ assert_equal "Gems updated: b", out.shift
+ assert_empty out
+
+ assert_path_exist File.join(@gemhome, "specifications", "b-2.gemspec")
+ assert_path_not_exist File.join(@gemhome, "specifications", "b-3.gemspec")
+ end
+
+ def test_execute_cooldown_all_new_versions_within_period
+ util_setup_cooldown_repo b2_created_at: util_cooldown_time(1),
+ b3_created_at: util_cooldown_time(1)
+
+ @cmd.options[:cooldown] = 7
+ @cmd.options[:args] = []
+
+ use_ui @ui do
+ @cmd.execute
+ end
+
+ out = @ui.output.split "\n"
+ assert_equal "Updating installed gems", out.shift
+ assert_equal "Nothing to update", out.shift
+ assert_empty out
+ end
+
+ def test_execute_cooldown_missing_created_at_fails_open
+ util_setup_cooldown_repo b2_created_at: nil, b3_created_at: nil
+
+ @cmd.options[:cooldown] = 7
+ @cmd.options[:args] = []
+
+ use_ui @ui do
+ @cmd.execute
+ end
+
+ out = @ui.output.split "\n"
+ assert_equal "Updating installed gems", out.shift
+ assert_equal "Updating b", out.shift
+ assert_equal "Gems updated: b", out.shift
+ assert_empty out
+
+ assert_path_exist File.join(@gemhome, "specifications", "b-3.gemspec")
+ assert_equal 1, @ui.error.scan("publish times").size
+ end
+
+ def test_execute_system_cooldown
+ spec_fetcher
+
+ ru8, ru8_gem = util_gem "rubygems-update", 8 do |s|
+ s.files = %w[setup.rb]
+ end
+ ru9, ru9_gem = util_gem "rubygems-update", 9 do |s|
+ s.files = %w[setup.rb]
+ end
+
+ util_setup_compact_index ru8, ru9, created_at: {
+ "rubygems-update-8" => util_cooldown_time(30),
+ "rubygems-update-9" => util_cooldown_time(1),
+ }
+ add_to_fetcher ru8, ru8_gem
+ add_to_fetcher ru9, ru9_gem
+
+ Gem::SpecFetcher.fetcher = nil
+
+ @cmd.options[:args] = []
+ @cmd.options[:system] = true
+ @cmd.options[:cooldown] = 7
+
+ use_ui @ui do
+ @cmd.execute
+ end
+
+ out = @ui.output.split "\n"
+ assert_equal "Installing RubyGems 8", out.shift
+ assert_equal "RubyGems system software updated", out.shift
+
+ assert_empty out
+ end
+
def test_execute_multiple
spec_fetcher do |fetcher|
fetcher.download "a", 2
diff --git a/test/rubygems/test_gem_config_file.rb b/test/rubygems/test_gem_config_file.rb
index 3c79cb0762d7..0ca05e7203ae 100644
--- a/test/rubygems/test_gem_config_file.rb
+++ b/test/rubygems/test_gem_config_file.rb
@@ -44,6 +44,7 @@ def test_initialize
assert_equal 365, @cfg.cert_expiration_length_days
assert_equal false, @cfg.ipv4_fallback_enabled
assert_equal true, @cfg.install_extension_in_lib
+ assert_equal Gem::ConfigFile::DEFAULT_COOLDOWN, @cfg.cooldown
File.open @temp_conf, "w" do |fp|
fp.puts ":backtrace: true"
@@ -63,6 +64,7 @@ def test_initialize
fp.puts ":install_extension_in_lib: false"
fp.puts ":ipv4_fallback_enabled: true"
fp.puts ":use_psych: true"
+ fp.puts ":cooldown: 7"
end
util_config_file
@@ -81,6 +83,7 @@ def test_initialize
assert_equal false, @cfg.install_extension_in_lib
assert_equal true, @cfg.ipv4_fallback_enabled
assert_equal true, @cfg.use_psych
+ assert_equal 7, @cfg.cooldown
end
def test_initialize_ipv4_fallback_enabled_env
diff --git a/test/rubygems/test_gem_cooldown.rb b/test/rubygems/test_gem_cooldown.rb
new file mode 100644
index 000000000000..704b6c169f26
--- /dev/null
+++ b/test/rubygems/test_gem_cooldown.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require_relative "helper"
+require "rubygems/cooldown"
+
+class TestGemCooldown < Gem::TestCase
+ def test_skip_eh
+ now = Time.now
+ cooldown = Gem::Cooldown.new 7, now: now
+
+ assert cooldown.skip?(now - 6 * 86_400)
+ refute cooldown.skip?(now - 8 * 86_400)
+ end
+
+ def test_skip_eh_boundary
+ now = Time.now
+ cooldown = Gem::Cooldown.new 7, now: now
+
+ assert cooldown.skip?(now - 7 * 86_400 + 1)
+ refute cooldown.skip?(now - 7 * 86_400)
+ end
+
+ def test_skip_eh_unknown_publish_time
+ refute Gem::Cooldown.new(7).skip?(nil)
+ end
+
+ def test_skip_eh_inactive
+ cooldown = Gem::Cooldown.new 0
+
+ refute cooldown.active?
+ refute cooldown.skip?(Time.now)
+ end
+
+ def test_from_options
+ orig_cooldown = Gem.configuration.cooldown
+ Gem.configuration.cooldown = 5
+
+ assert_equal 5, Gem::Cooldown.from_options({}).days
+ assert_equal 7, Gem::Cooldown.from_options(cooldown: 7).days
+ refute Gem::Cooldown.from_options(cooldown: 0).active?
+ ensure
+ Gem.configuration.cooldown = orig_cooldown
+ end
+
+ def test_warn_missing_created_at_warns_once
+ source = Gem::Source.new @gem_repo
+
+ use_ui @ui do
+ Gem::Cooldown.warn_missing_created_at source
+ Gem::Cooldown.warn_missing_created_at source
+ end
+
+ assert_equal 1, @ui.error.scan("publish times").size
+ assert_match @gem_repo, @ui.error
+ end
+end
diff --git a/test/rubygems/test_gem_source.rb b/test/rubygems/test_gem_source.rb
index a030b58851c7..e63a5e61fa7c 100644
--- a/test/rubygems/test_gem_source.rb
+++ b/test/rubygems/test_gem_source.rb
@@ -208,6 +208,39 @@ def test_load_specs_compact_index_skips_invalid_version
assert_equal %w[a-1], released
end
+ def test_created_at
+ a1 = util_spec "a", "1"
+ a2 = util_spec "a", "2"
+ b2_java = util_spec "b", "2" do |s|
+ s.platform = "java"
+ end
+
+ util_setup_compact_index a1, a2, b2_java, created_at: {
+ "a-2" => "2026-06-05T10:30:45Z",
+ "b-2-java" => "2026-06-06T00:00:00Z",
+ }
+
+ assert_equal Time.utc(2026, 6, 5, 10, 30, 45), @source.created_at("a", v(2))
+ assert_equal Time.utc(2026, 6, 6), @source.created_at("b", v(2), "java")
+
+ # no created_at metadata for this version
+ assert_nil @source.created_at("a", v(1))
+
+ # unknown version and unknown gem
+ assert_nil @source.created_at("a", v(9))
+ assert_nil @source.created_at("c", v(1))
+ end
+
+ def test_created_at_file_uri
+ source = Gem::Source.new "file:///tmp/gems"
+
+ assert_nil source.created_at("a", v(1))
+ end
+
+ def test_created_at_fetch_error
+ assert_nil @source.created_at("a", v(1))
+ end
+
def test_compact_index_cache_dir_removes_tmpdir_at_exit
source = Gem::Source.new @gem_repo
def source.update_cache?