diff --git a/Manifest.txt b/Manifest.txt
index afb06f2b87c7..ea1a0c32b03d 100644
--- a/Manifest.txt
+++ b/Manifest.txt
@@ -367,6 +367,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/install_command.rb b/lib/rubygems/commands/install_command.rb
index 6d3beec0b432..2ebbc40a0308 100644
--- a/lib/rubygems/commands/install_command.rb
+++ b/lib/rubygems/commands/install_command.rb
@@ -152,6 +152,7 @@ def execute
end
@installed_specs = []
+ @cooldown_skipped = []
ENV.delete "GEM_PATH" if options[:install_dir].nil?
@@ -163,6 +164,8 @@ def execute
show_installed
+ Gem::Cooldown.output_skipped_summary @cooldown_skipped
+
say update_suggestion if eligible_for_update?
terminate_interaction exit_code
@@ -184,6 +187,8 @@ def install_from_gemdeps # :nodoc:
@installed_specs = specs
+ Gem::Cooldown.output_skipped_summary rs.resolver&.cooldown_skipped
+
terminate_interaction
end
@@ -207,6 +212,8 @@ def install_gem(name, version) # :nodoc:
@installed_specs.concat request_set.install options
end
+ (@cooldown_skipped ||= []).concat dinst.cooldown_skipped
+
show_install_errors dinst.errors
end
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..71942f920d37 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,8 +96,12 @@ def check_update_arguments # :nodoc:
end
def execute
+ @cooldown = Gem::Cooldown.from_options options
+ @cooldown_skipped = []
+
if options[:system]
update_rubygems
+ output_cooldown_skipped_summary
return
end
@@ -132,6 +137,8 @@ def execute
end
say "Gems already up-to-date: #{up_to_date_names.join(" ")}" unless up_to_date_names.empty?
say "Gems not currently installed: #{not_installed_names.join(" ")}" unless not_installed_names.empty?
+
+ output_cooldown_skipped_summary
end
def fetch_remote_gems(spec) # :nodoc:
@@ -140,7 +147,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 +179,78 @@ 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 do |tup, _, created_at|
+ next false unless @cooldown.skip?(created_at)
+
+ (@cooldown_skipped_tuples ||= {})[[tup.name, tup.version]] ||= created_at
+ true
+ end.map {|tup, source, _| [tup, source] }
+ end
+
+ ##
+ # Summary entries for tuples the cooldown kept out of the update, kept
+ # only when newer than the version the update actually settled on (the
+ # updated version, or the one already installed when nothing moved).
+
+ def cooldown_skipped_tuple_entries # :nodoc:
+ skipped = @cooldown_skipped_tuples
+ return [] unless skipped
+
+ resolved = {}
+ @updated.each do |spec|
+ version = resolved[spec.name]
+ resolved[spec.name] = spec.version if version.nil? || spec.version > version
+ end
+
+ skipped.filter_map do |(name, version), created_at|
+ resolved_version = resolved[name] || resolved_fallback_version(name)
+ next unless resolved_version && version > resolved_version
+
+ {
+ name: name,
+ version: version,
+ resolved: resolved_version,
+ available_in_days: @cooldown.remaining_days(created_at),
+ }
+ end
+ end
+
+ def resolved_fallback_version(name) # :nodoc:
+ if name == "rubygems-update"
+ Gem::Version.new Gem::VERSION
+ else
+ Gem::Specification.find_all_by_name(name).map(&:version).max
+ end
+ end
+
+ def output_cooldown_skipped_summary # :nodoc:
+ entries = (@cooldown_skipped || []) + cooldown_skipped_tuple_entries
+
+ Gem::Cooldown.output_skipped_summary entries
+ end
+
def install_rubygems(spec) # :nodoc:
args = update_rubygems_arguments
version = spec.version
@@ -256,6 +334,10 @@ def update_gem(name, version = Gem::Requirement.default)
@installer.installed_gems.each do |spec|
@updated << spec
end
+
+ (@cooldown_skipped ||= []).concat @installer.cooldown_skipped
+
+ @installer.installed_gems
end
def update_gems(gems_to_update)
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..499e08b3be19
--- /dev/null
+++ b/lib/rubygems/cooldown.rb
@@ -0,0 +1,101 @@
+# 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
+
+ ##
+ # Number of days until a gem version published at +created_at+ leaves
+ # the cooldown period, rounded up and at least 1.
+
+ def remaining_days(created_at)
+ remaining = @days * 86_400 - (@now - created_at)
+
+ [(remaining / 86_400.0).ceil, 1].max
+ end
+
+ ##
+ # Reports, per gem, the newest version the cooldown kept out of a
+ # completed installation or update. +entries+ are hashes with :name,
+ # :version, :resolved and :available_in_days keys; when several entries
+ # name the same gem only the newest version is shown.
+
+ def self.output_skipped_summary(entries)
+ return if entries.nil? || entries.empty?
+
+ newest = {}
+ entries.each do |entry|
+ current = newest[entry[:name]]
+ newest[entry[:name]] = entry if current.nil? || entry[:version] > current[:version]
+ end
+
+ ui = Gem::DefaultUserInteraction.ui
+ ui.say "The following gem versions were skipped by the cooldown setting:"
+ newest.values.sort_by {|entry| entry[:name] }.each do |entry|
+ days = entry[:available_in_days]
+ ui.say " * #{entry[:name]} #{entry[:version]} (available in #{days} #{days == 1 ? "day" : "days"}), resolved #{entry[:resolved]} instead"
+ end
+ 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..f30764728504 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"
@@ -43,6 +44,12 @@ class Gem::DependencyInstaller
attr_reader :installed_gems
+ ##
+ # Per-gem summary entries for the newest versions the cooldown kept out
+ # of the last resolution. See Gem::Cooldown.output_skipped_summary.
+
+ attr_reader :cooldown_skipped
+
##
# Creates a new installer instance.
#
@@ -90,6 +97,8 @@ def initialize(options = {})
@prog_mode = options[:prog_mode]
@build_extension = options[:build_extension]
@install_plugin = options[:install_plugin]
+ @cooldown = Gem::Cooldown.from_options options
+ @cooldown_skipped = []
# Indicates that we should not try to update any deps unless
# we absolutely must.
@@ -210,6 +219,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
@@ -258,6 +268,7 @@ def resolve_dependencies(dep_or_name, version) # :nodoc:
request_set.resolve installer_set
@errors.concat request_set.errors
+ @cooldown_skipped = request_set.resolver&.cooldown_skipped || []
request_set
end
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..4964f5be217e 100644
--- a/lib/rubygems/resolver.rb
+++ b/lib/rubygems/resolver.rb
@@ -34,6 +34,19 @@ class Gem::Resolver
attr_accessor :ignore_dependencies
+ ##
+ # The Gem::Cooldown applied to release candidates, if any.
+
+ attr_accessor :cooldown
+
+ ##
+ # Per-gem summary entries for the newest versions the cooldown kept out
+ # of a successful resolution. See Gem::Cooldown.output_skipped_summary.
+
+ def cooldown_skipped
+ @cooldown_skipped || []
+ end
+
##
# Hash of gems to skip resolution. Keyed by gem name, with arrays of
# gem specifications as values.
@@ -94,6 +107,7 @@ def initialize(needed, set = nil)
@set = set || Gem::Resolver::IndexSet.new
@needed = needed
+ @cooldown = nil
@development = false
@development_shallow = false
@ignore_dependencies = false
@@ -158,13 +172,17 @@ def resolve
# Convert to Array
needed_by_name = @needed.group_by(&:name)
- result.filter_map do |package, version|
+ requests = result.filter_map do |package, version|
next if Gem::PubGrub::Package.root?(package)
spec = spec_for(package.to_s, version)
dep = needed_by_name[package.to_s]&.first || Gem::Dependency.new(package.to_s)
dep_request = DependencyRequest.new(dep, nil)
ActivationRequest.new(spec, dep_request)
end
+
+ @cooldown_skipped = cooldown_skipped_summary(requests) if @cooldown&.active?
+
+ requests
rescue Gem::PubGrub::SolveFailure => e
extended = extract_extended_explanation(e.incompatibility)
if extended
@@ -373,9 +391,77 @@ 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 do |s|
+ next false unless @cooldown.skip?(s.created_at)
+
+ (@cooldown_skipped_specs ||= {})[[s.name, s.version]] ||= s
+ true
+ end
+ end
+
+ ##
+ # Reports, per gem, the newest version that the cooldown kept out of a
+ # successful resolution. A skipped version is only worth reporting when
+ # it is newer than the resolved version and satisfies every requirement
+ # the final resolution places on that gem, so we don't claim a version
+ # the resolver could never have picked anyway.
+
+ def cooldown_skipped_summary(requests)
+ skipped_specs = @cooldown_skipped_specs || {}
+ return [] if skipped_specs.empty?
+
+ requirements = Hash.new {|h, name| h[name] = [] }
+ @needed.each {|dep| requirements[dep.name] << dep.requirement }
+ requests.each do |request|
+ request.spec.dependencies.each do |dep|
+ next if dep.type == :development && !@development
+ requirements[dep.name] << dep.requirement
+ end
+ end
+
+ resolved = {}
+ requests.each do |request|
+ version = resolved[request.spec.name]
+ resolved[request.spec.name] = request.spec.version if version.nil? || request.spec.version > version
+ end
+
+ newest_skipped = {}
+ skipped_specs.each do |(name, version), spec|
+ resolved_version = resolved[name]
+ next unless resolved_version && version > resolved_version
+ next unless requirements[name].all? {|req| req.satisfied_by?(version) }
+ newest = newest_skipped[name]
+ newest_skipped[name] = spec if newest.nil? || version > newest.version
+ end
+
+ newest_skipped.values.sort_by(&:name).map do |spec|
+ {
+ name: spec.name,
+ version: spec.version,
+ resolved: resolved[spec.name],
+ available_in_days: @cooldown.remaining_days(spec.created_at),
+ }
+ end
+ end
+
def spec_for(name, version)
@spec_for_cache[name][version]
end
@@ -486,6 +572,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 1a4f49ad6417..ef0ce1b5846c 100644
--- a/test/rubygems/helper.rb
+++ b/test/rubygems/helper.rb
@@ -542,6 +542,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..6ae389db2fba 100644
--- a/test/rubygems/test_gem_commands_install_command.rb
+++ b/test/rubygems/test_gem_commands_install_command.rb
@@ -729,6 +729,132 @@ 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)
+ assert_match "The following gem versions were skipped by the cooldown setting:", @ui.output
+ assert_match "* a 2 (available in 6 days), resolved 1 instead", @ui.output
+ 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..9d15406bd13f 100644
--- a/test/rubygems/test_gem_commands_update_command.rb
+++ b/test/rubygems/test_gem_commands_update_command.rb
@@ -70,6 +70,127 @@ 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_equal "The following gem versions were skipped by the cooldown setting:", out.shift
+ assert_equal " * b 3 (available in 6 days), resolved 2 instead", 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_equal "The following gem versions were skipped by the cooldown setting:", out.shift
+ assert_equal " * b 3 (available in 6 days), resolved 1 instead", 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_equal "The following gem versions were skipped by the cooldown setting:", out.shift
+ assert_equal " * rubygems-update 9 (available in 6 days), resolved 8 instead", 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?