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?