From ed07390c7e95d71f54d0767c86ad779c32d64528 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 28 Jul 2026 18:03:52 +0900 Subject: [PATCH 1/4] Add Gem::Cooldown and the :cooldown: gemrc setting A cooldown period excludes gem versions published within the last N days from installation and update, as a mitigation against supply chain attacks through freshly published releases. This adds the configuration mechanism: the Gem::Cooldown judgment class, the :cooldown: gemrc setting, and the --cooldown DAYS option mixin. The --cooldown option takes precedence over gemrc, and 0 disables the cooldown. Versions with an unknown publish time are never excluded, so the cooldown fails open. Co-Authored-By: Claude Fable 5 --- Manifest.txt | 2 + lib/rubygems/config_file.rb | 13 ++++- lib/rubygems/cooldown.rb | 68 +++++++++++++++++++++++++++ lib/rubygems/cooldown_option.rb | 21 +++++++++ test/rubygems/helper.rb | 4 ++ test/rubygems/test_gem_config_file.rb | 3 ++ test/rubygems/test_gem_cooldown.rb | 56 ++++++++++++++++++++++ 7 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 lib/rubygems/cooldown.rb create mode 100644 lib/rubygems/cooldown_option.rb create mode 100644 test/rubygems/test_gem_cooldown.rb 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/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/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_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 From 61ef01ef38c02e78c3e3aa78082b19ad5e76a4bf Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 28 Jul 2026 18:07:03 +0900 Subject: [PATCH 2/4] Apply the cooldown period to gem install resolution The resolver drops release candidates published within the cooldown period, alongside the platform and required_ruby_version filters, so gem install falls back to the newest version outside the window. Installed and lockfile specifications carry no publish time and are never dropped. When an explicitly requested version is within the window, resolution fails with a hint naming the cooldown period and the --cooldown 0 bypass. Sources that provide no publish times at all warn once and are not filtered. Co-Authored-By: Claude Fable 5 --- lib/rubygems/dependency_installer.rb | 3 + lib/rubygems/install_update_options.rb | 4 + lib/rubygems/request_set.rb | 9 ++ lib/rubygems/resolver.rb | 35 +++++ .../test_gem_commands_install_command.rb | 124 ++++++++++++++++++ 5 files changed, 175 insertions(+) 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/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") From c32396ac8a371a231968829e132e38cf03da9331 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 28 Jul 2026 18:09:19 +0900 Subject: [PATCH 3/4] Apply the cooldown period to gem update and gem outdated gem update installs the resolved version with an exact pin, so the resolver filter cannot make it fall back. Filter instead when picking the target name tuple, using the new Gem::Source#created_at, which looks up a version's publish time through the compact index info file for the gem. While a cooldown is active the update and outdated lookups search the full index instead of the latest-only index, which carries nothing to fall back to. gem outdated picks the newest version outside the cooldown period as the update candidate and annotates a newer version still within the period with "(cooldown Nd)". Co-Authored-By: Claude Fable 5 --- lib/rubygems/commands/outdated_command.rb | 79 +++++++++++++++++- lib/rubygems/commands/update_command.rb | 32 +++++++- lib/rubygems/source.rb | 36 +++++++++ lib/rubygems/spec_fetcher.rb | 7 +- .../test_gem_commands_outdated_command.rb | 63 +++++++++++++++ .../test_gem_commands_update_command.rb | 81 +++++++++++++++++++ test/rubygems/test_gem_source.rb | 33 ++++++++ 7 files changed, 325 insertions(+), 6 deletions(-) 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/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/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..d63ec7117480 100644 --- a/test/rubygems/test_gem_commands_update_command.rb +++ b/test/rubygems/test_gem_commands_update_command.rb @@ -70,6 +70,87 @@ 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_multiple spec_fetcher do |fetcher| fetcher.download "a", 2 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? From e29aac0147daced63fb813485c24926249982ef8 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 28 Jul 2026 18:10:54 +0900 Subject: [PATCH 4/4] Cover gem update --system with a cooldown The tuple selection and resolver filters already apply to rubygems-update, so gem update --system needs no dedicated code path. Add coverage that the newest release within the cooldown period is passed over in favor of the newest one outside it. Co-Authored-By: Claude Fable 5 --- .../test_gem_commands_update_command.rb | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test/rubygems/test_gem_commands_update_command.rb b/test/rubygems/test_gem_commands_update_command.rb index d63ec7117480..87f500fc1bdc 100644 --- a/test/rubygems/test_gem_commands_update_command.rb +++ b/test/rubygems/test_gem_commands_update_command.rb @@ -151,6 +151,40 @@ def test_execute_cooldown_missing_created_at_fails_open 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