diff --git a/.rubocop.yml b/.rubocop.yml index 5f72fff33425..a5e84a3d7706 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -14,6 +14,7 @@ AllCops: - tmp/**/* - lib/rubygems/vendor/**/* - lib/bundler/vendor/**/* + - spec/support/vendor/**/* CacheRootDirectory: tmp/rubocop MaxFilesInCache: 5000 diff --git a/Manifest.txt b/Manifest.txt index e20b0809a3d9..c594e399ba1c 100644 --- a/Manifest.txt +++ b/Manifest.txt @@ -357,6 +357,7 @@ 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/content_address.rb lib/rubygems/cooldown.rb lib/rubygems/cooldown_option.rb lib/rubygems/core_ext/kernel_gem.rb diff --git a/Rakefile b/Rakefile index 3997904cc213..831c6192bfbd 100644 --- a/Rakefile +++ b/Rakefile @@ -164,6 +164,46 @@ namespace :vendor do error_message: "Vendored gems are out of sync. Please update the vendored lib patches." ) end + + # Pinned upstream revision of rubygems/rubygems.org that the vendored + # compact_index copy is generated from. Bump this (or pass COMPACT_INDEX_REF) + # and re-run the task to refresh. + COMPACT_INDEX_REF = "572cf8948f53520668c7a07808760566f07129f8" + COMPACT_INDEX_FILES = %w[ + lib/compact_index.rb + lib/compact_index/dependency.rb + lib/compact_index/gem.rb + lib/compact_index/gem_version.rb + lib/compact_index/versions_file.rb + ].freeze + + desc "Vendor spec-suite compact_index from rubygems.org (COMPACT_INDEX_REF to override ref)" + task :compact_index do + require "open-uri" + require "fileutils" + + ref = ENV["COMPACT_INDEX_REF"] || COMPACT_INDEX_REF + dest_root = File.expand_path("spec/support/vendor/compact_index", __dir__) + + COMPACT_INDEX_FILES.each do |path| + url = "https://raw.githubusercontent.com/rubygems/rubygems.org/#{ref}/#{path}" + contents = URI.parse(url).open(&:read).gsub("CompactIndex", "VendoredCompactIndex") + + target = File.join(dest_root, path) + FileUtils.mkdir_p(File.dirname(target)) + File.write(target, contents) + end + + puts "Vendored compact_index from rubygems.org@#{ref} into #{dest_root}" + end + + desc "Check vendored compact_index is up to date" + task compact_index_check: :compact_index do + Spec::Rubygems.check_source_control_changes( + success_message: "Vendored compact_index is in sync", + error_message: "Vendored compact_index is out of sync. Run `rake vendor:compact_index`." + ) + end end namespace :rubocop do diff --git a/lib/bundler/endpoint_specification.rb b/lib/bundler/endpoint_specification.rb index 5d0485ee558e..e04c7d1630a0 100644 --- a/lib/bundler/endpoint_specification.rb +++ b/lib/bundler/endpoint_specification.rb @@ -5,24 +5,33 @@ module Bundler class EndpointSpecification < Gem::Specification include MatchRemoteMetadata - attr_reader :name, :version, :platform, :checksum, :created_at + attr_reader :name, :version, :platform, :checksum, :created_at, :content_address attr_writer :dependencies attr_accessor :remote, :locked_platform - def initialize(name, version, platform, spec_fetcher, dependencies, metadata = nil) + def initialize(name, version, suffix, spec_fetcher, dependencies, metadata = nil) super() @name = name @version = Gem::Version.create version - @platform = Gem::Platform.new(platform) @spec_fetcher = spec_fetcher @dependencies = nil @unbuilt_dependencies = dependencies + @content_address = nil + @required_platform = nil @loaded_from = nil @remote_specification = nil @locked_platform = nil parse_metadata(metadata) + + if Gem::ContentAddress.match?(suffix) && @required_platform + @content_address = suffix + @platform = @required_platform + @required_rubygems_version ||= Gem::Requirement.default + else + @platform = Gem::Platform.new(suffix) + end end def insecurely_materialized? @@ -147,7 +156,8 @@ def inspect private def _remote_specification - @_remote_specification ||= @spec_fetcher.fetch_spec([@name, @version, @platform]) + suffix = @content_address || @platform + @_remote_specification ||= @spec_fetcher.fetch_spec([@name, @version, suffix]) end def local_specification_path @@ -183,6 +193,8 @@ def parse_metadata(data) @required_ruby_version = Gem::Requirement.new(v) when "created_at" @created_at = parse_created_at(v.is_a?(Array) ? v.last : v)&.freeze + when "platform" + @required_platform = required_platform_from(Array(v).last) end end rescue StandardError => e @@ -210,5 +222,12 @@ def parse_created_at(value) def build_dependency(name, requirements) Dependency.new(name, requirements) end + + def required_platform_from(value) + op, platform = value.to_s.split(" ", 2) + return unless op == "=" && platform + + Gem::Platform.new(platform) + end end end diff --git a/lib/bundler/fetcher.rb b/lib/bundler/fetcher.rb index ecaca9242056..3d77f8750b8d 100644 --- a/lib/bundler/fetcher.rb +++ b/lib/bundler/fetcher.rb @@ -177,13 +177,13 @@ def specs_with_retry(gem_names, source) def specs(gem_names, source) index = Bundler::Index.new - fetch_specs(gem_names).each do |name, version, platform, dependencies, metadata| + fetch_specs(gem_names).each do |name, version, suffix, dependencies, metadata| spec = if dependencies - EndpointSpecification.new(name, version, platform, self, dependencies, metadata).tap do |es| + EndpointSpecification.new(name, version, suffix, self, dependencies, metadata).tap do |es| source.checksum_store.replace(es, es.checksum) end else - RemoteSpecification.new(name, version, platform, self) + RemoteSpecification.new(name, version, suffix, self) end spec.source = source spec.remote = @remote diff --git a/lib/bundler/lazy_specification.rb b/lib/bundler/lazy_specification.rb index 0a6c46299752..cc62608252ea 100644 --- a/lib/bundler/lazy_specification.rb +++ b/lib/bundler/lazy_specification.rb @@ -8,7 +8,7 @@ class LazySpecification include MatchPlatform include ForcePlatform - attr_reader :name, :version, :platform, :materialization + attr_reader :name, :version, :platform, :materialization, :content_address attr_accessor :source, :remote, :force_ruby_platform, :dependencies, :required_ruby_version, :required_rubygems_version attr_accessor :overrides @@ -27,7 +27,7 @@ class LazySpecification alias_method :runtime_dependencies, :dependencies def self.from_spec(s) - lazy_spec = new(s.name, s.version, s.platform, s.source) + lazy_spec = new(s.name, s.version, s.platform, s.source, content_address: s.content_address) lazy_spec.dependencies = s.runtime_dependencies lazy_spec.required_ruby_version = s.required_ruby_version lazy_spec.required_rubygems_version = s.required_rubygems_version @@ -35,13 +35,14 @@ def self.from_spec(s) lazy_spec end - def initialize(name, version, platform, source = nil, **materialization_options) + def initialize(name, version, platform, source = nil, content_address: nil, **materialization_options) @name = name @version = version @dependencies = [] @required_ruby_version = Gem::Requirement.default @required_rubygems_version = Gem::Requirement.default @platform = platform || Gem::Platform::RUBY + @content_address = content_address @original_source = source @source = source @@ -65,7 +66,9 @@ def source_changed? end def full_name - @full_name ||= if platform == Gem::Platform::RUBY + @full_name ||= if Gem::ContentAddress.match?(@content_address) && platform != Gem::Platform::RUBY + "#{@name}-#{@version}-#{@content_address}" + elsif platform == Gem::Platform::RUBY "#{@name}-#{@version}" else "#{@name}-#{@version}-#{platform}" @@ -81,7 +84,7 @@ def lock_name end def name_tuple - Gem::NameTuple.new(@name, @version, @platform) + Gem::NameTuple.new(@name, @version, @platform, content_address: @content_address) end def ==(other) @@ -118,7 +121,11 @@ def satisfies?(dependency) def to_lock out = String.new - out << " #{lock_name}\n" + out << " #{lock_name}" + # Append the platform additionally for content-addressable gems that contain a SHA + # where the platform would otherwise be + out << " #{platform}" if Gem::ContentAddress.match?(content_address) && platform != Gem::Platform::RUBY + out << "\n" dependencies.sort_by(&:to_s).uniq.each do |dep| next if dep.type == :development @@ -192,6 +199,8 @@ def use_exact_resolved_specifications? # Used for legacy lockfiles and as a fallback when the exact locked spec # is incompatible. Falls back to frozen bundle behavior if none match. def resolve_best_platform(specs, locked_platforms: nil) + specs = MatchPlatform.select_all_content_address_match(specs, content_address) + find_compatible_platform_spec(specs, locked_platforms: locked_platforms) || frozen_bundle_fallback(specs) end diff --git a/lib/bundler/lockfile_parser.rb b/lib/bundler/lockfile_parser.rb index 852fc631f3b1..160d5583d67f 100644 --- a/lib/bundler/lockfile_parser.rb +++ b/lib/bundler/lockfile_parser.rb @@ -264,11 +264,13 @@ def parse_checksum(line) checksums = $6 name = $2 version = $3 - platform = $4 + content_address = $4 if Gem::ContentAddress.match?($4) + platform = $4 unless content_address version = Gem::Version.new(version) platform = platform ? Gem::Platform.new(platform) : Gem::Platform::RUBY - full_name = Gem::NameTuple.new(name, version, platform).full_name + name_tuple = Gem::NameTuple.new(name, version, platform, content_address: content_address) + full_name = name_tuple.full_name spec = @specs[full_name] if name == "bundler" @@ -295,11 +297,17 @@ def parse_spec(line) if spaces.size == 4 # only load platform for non-dependency (spec) line - platform = $4 + if Gem::ContentAddress.match?($4) && $6 && $6 != Gem::Platform::RUBY.to_s + content_address = $4 + platform = $6 + else + platform = $4 + content_address = $6 if Gem::ContentAddress.match?($6) + end version = Gem::Version.new(version) platform = platform ? Gem::Platform.new(platform) : Gem::Platform::RUBY - @current_spec = LazySpecification.new(name, version, platform, @current_source, strict: @strict) + @current_spec = LazySpecification.new(name, version, platform, @current_source, content_address: content_address, strict: @strict) @current_source.add_dependency_names(name) @specs[@current_spec.full_name] = @current_spec diff --git a/lib/bundler/match_platform.rb b/lib/bundler/match_platform.rb index 11d510ba6c47..fee9d97f5e57 100644 --- a/lib/bundler/match_platform.rb +++ b/lib/bundler/match_platform.rb @@ -2,6 +2,10 @@ module Bundler module MatchPlatform + def content_address + nil + end + def installable_on_platform?(target_platform) # :nodoc: return true if [Gem::Platform::RUBY, nil, target_platform].include?(platform) return true if Gem::Platform.new(platform) === target_platform @@ -11,13 +15,30 @@ def installable_on_platform?(target_platform) # :nodoc: def self.select_best_platform_match(specs, platform, force_ruby: false, prefer_locked: false) matching = select_all_platform_match(specs, platform, force_ruby: force_ruby, prefer_locked: prefer_locked) + matching = prefer_content_addressable(matching) Gem::Platform.sort_and_filter_best_platform_match(matching, platform) end + def self.select_all_content_address_match(specs, content_address) + return specs unless Gem::ContentAddress.match?(content_address) + + specs.select {|spec| spec.content_address == content_address } + end + + def self.prefer_content_addressable(matching) + addressable, non_addressable = matching.partition {|s| Gem::ContentAddress.match?(s.content_address) } + return matching if addressable.empty? + + compatible = addressable.select(&:matches_current_metadata?) + compatible.any? ? compatible : non_addressable + end + def self.select_best_local_platform_match(specs, force_ruby: false, locked_platforms: nil) local = Bundler.local_platform - matching = select_all_platform_match(specs, local, force_ruby: force_ruby).filter_map {|spec| spec.materialized_for_installation(locked_platforms) } + matching = select_all_platform_match(specs, local, force_ruby: force_ruby) + matching = matching.filter_map {|spec| spec.materialized_for_installation(locked_platforms) } + matching = prefer_content_addressable(matching) Gem::Platform.sort_best_platform_match(matching, local) end @@ -31,7 +52,6 @@ def self.select_all_platform_match(specs, platform, force_ruby: false, prefer_lo locked_originally = matching.select {|spec| spec.is_a?(::Bundler::LazySpecification) } return locked_originally if locked_originally.any? end - matching end diff --git a/lib/bundler/remote_specification.rb b/lib/bundler/remote_specification.rb index dcaaf6af2e61..bf899693f536 100644 --- a/lib/bundler/remote_specification.rb +++ b/lib/bundler/remote_specification.rb @@ -10,11 +10,11 @@ class RemoteSpecification include MatchPlatform include Comparable - attr_reader :name, :version, :platform + attr_reader :name, :version, :platform, :content_address attr_writer :dependencies attr_accessor :source, :remote, :locked_platform, :created_at - def initialize(name, version, platform, spec_fetcher) + def initialize(name, version, platform, spec_fetcher, content_address: nil) @name = name @version = Gem::Version.create version @original_platform = platform || Gem::Platform::RUBY @@ -22,6 +22,7 @@ def initialize(name, version, platform, spec_fetcher) @spec_fetcher = spec_fetcher @dependencies = nil @locked_platform = nil + @content_address = content_address end def insecurely_materialized? @@ -35,7 +36,9 @@ def fetch_platform end def full_name - @full_name ||= if @platform == Gem::Platform::RUBY + @full_name ||= if Gem::ContentAddress.match?(@content_address) && @platform != Gem::Platform::RUBY + "#{@name}-#{@version}-#{@content_address}" + elsif @platform == Gem::Platform::RUBY "#{@name}-#{@version}" else "#{@name}-#{@version}-#{@platform}" diff --git a/lib/bundler/resolver.rb b/lib/bundler/resolver.rb index a164a4193541..72c6a32afb34 100644 --- a/lib/bundler/resolver.rb +++ b/lib/bundler/resolver.rb @@ -275,7 +275,7 @@ def incompatibilities_for(package, version) def all_versions_for(package) name = package.name - results = (@base[name] + filter_specs(@all_specs[name], package)).uniq {|spec| [spec.version.hash, spec.platform] } + results = (@base[name] + filter_specs(@all_specs[name], package)).uniq {|spec| [spec.version.hash, spec.platform, spec.content_address] } if name == "bundler" && !bundler_pinned_to_current_version? bundler_spec = Gem.loaded_specs["bundler"] diff --git a/lib/bundler/rubygems_ext.rb b/lib/bundler/rubygems_ext.rb index 4ad2bdf46f04..623de1836e77 100644 --- a/lib/bundler/rubygems_ext.rb +++ b/lib/bundler/rubygems_ext.rb @@ -13,7 +13,34 @@ # `Gem::Source` from the redefined `Gem::Specification#source`. require "rubygems/source" +# Can be removed once RubyGems 4.0.0 support is dropped +unless Gem::BasicSpecification.method_defined?(:content_address) + Gem::BasicSpecification.attr_accessor :content_address +end + +# Can be removed once RubyGems 4.0.0 support is dropped +unless Gem::NameTuple.method_defined?(:content_address) + Gem::NameTuple.attr_reader :content_address +end + module Gem + # Can be removed once RubyGems 4.0.0 support is dropped + unless defined?(Gem::ContentAddress) + module ContentAddress + def self.match?(token) + false + end + + def self.applicable?(spec) + false + end + + def self.content_addressed?(spec) + false + end + end + end + # Can be removed once RubyGems 3.5.11 support is dropped unless Gem.respond_to?(:freebsd_platform?) def self.freebsd_platform? @@ -417,7 +444,8 @@ class NameTuple unless Gem::NameTuple.new("a", Gem::Version.new("1"), Gem::Platform.new("x86_64-linux")).platform.is_a?(String) alias_method :initialize_with_platform, :initialize - def initialize(name, version, platform = Gem::Platform::RUBY) + def initialize(name, version, platform = Gem::Platform::RUBY, content_address = nil) + @content_address = content_address if Gem::Platform === platform initialize_with_platform(name, version, platform.to_s) else @@ -426,7 +454,18 @@ def initialize(name, version, platform = Gem::Platform::RUBY) end end + unless instance_method(:initialize).parameters.any? {|kind, name| kind == :key && name == :content_address } + alias_method :initialize_without_content_address, :initialize + + def initialize(name, version, platform = Gem::Platform::RUBY, content_address: nil) + initialize_without_content_address(name, version, platform) + @content_address = content_address + end + end + def lock_name + return "#{name} (#{version}-#{content_address})" if Gem::ContentAddress.match?(content_address) + if platform == Gem::Platform::RUBY "#{name} (#{version})" else diff --git a/lib/bundler/rubygems_gem_installer.rb b/lib/bundler/rubygems_gem_installer.rb index d8c50556c531..e33f135c59e4 100644 --- a/lib/bundler/rubygems_gem_installer.rb +++ b/lib/bundler/rubygems_gem_installer.rb @@ -4,6 +4,11 @@ module Bundler class RubyGemsGemInstaller < Gem::Installer + # Can be removed once RubyGems 4.0.0 support is dropped + unless private_method_defined?(:assign_content_address) + private def assign_content_address; end + end + # Cap how many jobserver slots a single gem's `make` may grab so that one # gem with many recipes doesn't starve the others sharing the pool. Beyond # a handful of jobs the extra parallelism rarely pays off in practice. @@ -14,6 +19,8 @@ def check_executable_overwrite(filename) end def install + assign_content_address + pre_install_checks run_pre_install_hooks diff --git a/lib/bundler/rubygems_integration.rb b/lib/bundler/rubygems_integration.rb index e04ef232592a..06d17d0cacdc 100644 --- a/lib/bundler/rubygems_integration.rb +++ b/lib/bundler/rubygems_integration.rb @@ -144,7 +144,12 @@ def ext_lock def spec_from_gem(path) require "rubygems/package" - Gem::Package.new(path).spec + package = Gem::Package.new(path) + spec = package.spec + if package.respond_to?(:content_address) + spec.content_address = package.content_address + end + spec end def build_gem(gem_dir, spec) diff --git a/lib/bundler/source/rubygems.rb b/lib/bundler/source/rubygems.rb index 26e7b90426c4..b549315a3871 100644 --- a/lib/bundler/source/rubygems.rb +++ b/lib/bundler/source/rubygems.rb @@ -196,6 +196,8 @@ def download(spec, options = {}) "the security policy didn't allow it, with the message: #{e.message}" end + s.content_address = spec.content_address if spec.content_address + spec.__swap__(s) end diff --git a/lib/bundler/stub_specification.rb b/lib/bundler/stub_specification.rb index 6de398a129b2..ca293fab5618 100644 --- a/lib/bundler/stub_specification.rb +++ b/lib/bundler/stub_specification.rb @@ -4,7 +4,8 @@ module Bundler class StubSpecification < RemoteSpecification def self.from_stub(stub) return stub if stub.is_a?(Bundler::StubSpecification) - spec = new(stub.name, stub.version, stub.platform, nil) + content_address = stub.content_address + spec = new(stub.name, stub.version, stub.platform, nil, content_address: content_address) spec.stub = stub spec end diff --git a/lib/rubygems.rb b/lib/rubygems.rb index 1cac0433cd81..2835862e2c1a 100644 --- a/lib/rubygems.rb +++ b/lib/rubygems.rb @@ -1411,6 +1411,7 @@ def default_gem_load_paths MARSHAL_SPEC_DIR = "quick/Marshal.#{Gem.marshal_version}/".freeze autoload :ConfigFile, File.expand_path("rubygems/config_file", __dir__) + autoload :ContentAddress, File.expand_path("rubygems/content_address", __dir__) autoload :CIDetector, File.expand_path("rubygems/ci_detector", __dir__) autoload :Dependency, File.expand_path("rubygems/dependency", __dir__) autoload :DependencyList, File.expand_path("rubygems/dependency_list", __dir__) diff --git a/lib/rubygems/basic_specification.rb b/lib/rubygems/basic_specification.rb index 61d35307f4ea..39a7582fd0e2 100644 --- a/lib/rubygems/basic_specification.rb +++ b/lib/rubygems/basic_specification.rb @@ -140,18 +140,30 @@ def full_gem_path end ## - # Returns the full name (name-version) of this Gem. Platform information - # is included (name-version-platform) if it is specified and not the + # Returns the full name (name-version) of this Gem. + # Content address is included (name-version-content_address) if the gem + # is content-addressed (eligible and has a valid content address). + # Platform information is included (name-version-platform) if it is specified and not the # default Ruby platform. def full_name - if platform == Gem::Platform::RUBY || platform.nil? + if Gem::ContentAddress.content_addressed?(self) + "#{name}-#{version}-#{content_address}" + elsif platform == Gem::Platform::RUBY || platform.nil? "#{name}-#{version}" else "#{name}-#{version}-#{platform}" end end + ## + # The content address of this gem, or +nil+ when it is not + # content-addressable. + + def content_address + nil + end + ## # Returns the full name of this Gem (see `Gem::BasicSpecification#full_name`). # Information about where the gem is installed is also included if not diff --git a/lib/rubygems/commands/build_command.rb b/lib/rubygems/commands/build_command.rb index cfe1f8ec3c86..9e4d812c3626 100644 --- a/lib/rubygems/commands/build_command.rb +++ b/lib/rubygems/commands/build_command.rb @@ -25,6 +25,8 @@ def initialize add_option "-o", "--output FILE", "output gem with the given filename" do |value, options| options[:output] = value end + + add_ruby_abi_option("build", " (builds a content addressable gem)") end def arguments # :nodoc: @@ -52,6 +54,10 @@ def description # :nodoc: $ gem build my_gem-1.0.gemspec --output=release.gem +Platform gems can be built for a single Ruby ABI with the --ruby-abi option: + + $ gem build my_gem-1.0.gemspec --ruby-abi=3.4 + EOF end @@ -88,7 +94,8 @@ def build_package(gemspec) spec, options[:force], options[:strict], - options[:output] + options[:output], + options[:ruby_abi] ) else alert_error "Error loading gemspec. Aborting." diff --git a/lib/rubygems/commands/dependency_command.rb b/lib/rubygems/commands/dependency_command.rb index 9aaefae999d4..3caf5c367270 100644 --- a/lib/rubygems/commands/dependency_command.rb +++ b/lib/rubygems/commands/dependency_command.rb @@ -66,7 +66,11 @@ def fetch_remote_specs(name, requirement, prerelease) # :nodoc: end end - ss.map {|tuple, source| source.fetch_spec(tuple) } + fetcher.decode_content_addressable_tuples(ss).map do |tuple, source| + spec = source.fetch_spec(tuple) + spec.content_address = tuple.content_address if tuple.content_address + spec + end end def fetch_specs(name_pattern, requirement, prerelease) # :nodoc: @@ -85,7 +89,7 @@ def fetch_specs(name_pattern, requirement, prerelease) # :nodoc: ensure_specs specs - specs.uniq.sort + specs.uniq(&:full_name).sort end def display_pipe(specs) # :nodoc: @@ -148,9 +152,17 @@ def ensure_specs(specs) # :nodoc: terminate_interaction 1 end + def content_address_annotation(spec) # :nodoc: + return "" unless Gem::ContentAddress.content_addressed?(spec) + + parts = ["Platform: #{spec.platform}"] + parts << "Ruby ABI: #{spec.ruby_abi}" if spec.ruby_abi + " (#{parts.join(" ")})" + end + def print_dependencies(spec, level = 0) # :nodoc: response = String.new - response << " " * level + "Gem #{spec.full_name}\n" + response << " " * level + "Gem #{spec.full_name}#{content_address_annotation(spec)}\n" unless spec.dependencies.empty? spec.dependencies.sort_by(&:name).each do |dep| response << " " * level + " #{dep}\n" diff --git a/lib/rubygems/commands/outdated_command.rb b/lib/rubygems/commands/outdated_command.rb index 7721be88e71f..f0bd03a6e672 100644 --- a/lib/rubygems/commands/outdated_command.rb +++ b/lib/rubygems/commands/outdated_command.rb @@ -88,7 +88,7 @@ def partition_by_cooldown(spec_tuples) embargoed = [] with_times = spec_tuples.map do |tup, source| - [tup, source, source.created_at(tup.name, tup.version, tup.platform)] + [tup, source, source.created_at_for_tuple(tup)] end if !with_times.empty? && with_times.none? {|_, _, created_at| created_at } diff --git a/lib/rubygems/commands/push_command.rb b/lib/rubygems/commands/push_command.rb index 494525d661af..74335ef17fc2 100644 --- a/lib/rubygems/commands/push_command.rb +++ b/lib/rubygems/commands/push_command.rb @@ -2,11 +2,13 @@ require_relative "../command" require_relative "../local_remote_options" +require_relative "../version_option" require_relative "../gemcutter_utilities" require_relative "../package" class Gem::Commands::PushCommand < Gem::Command include Gem::LocalRemoteOptions + include Gem::VersionOption include Gem::GemcutterUtilities def description # :nodoc: @@ -47,6 +49,14 @@ def initialize @user_defined_host = true end + add_option("--platform PLATFORM", + "Push a gem for a specific platform", + " (e.g. x86_64-darwin-20)") do |value, options| + options[:platform] = value + end + + add_ruby_abi_option("push", " (e.g. 3.4)") + add_option("--attestation FILE", "Push with sigstore attestations") do |value, options| options[:attestations] << value @@ -56,7 +66,12 @@ def initialize end def execute - gem_name = get_one_gem_name + gem_name = if gem_name_selectors? + resolve_gem_name(get_all_gem_names) + else + get_one_gem_name + end + default_gem_server, push_host = get_hosts_for(gem_name) @host = if @user_defined_host @@ -93,6 +108,65 @@ def send_gem(name) private + def gem_name_selectors? + options[:platform] || options[:ruby_abi] + end + + def resolve_gem_name(names) + candidates = names.filter_map do |name| + [name, Gem::Package.new(name).spec] + rescue Gem::Package::FormatError => e + alert_warning "Skipping #{name}: #{e.message}" + nil + end + + matches = candidates.select do |_, spec| + platform_matches?(spec) && ruby_matches?(spec) + end + + raise Gem::CommandLineError, "No gem matched #{gem_name_selector_description}" if matches.empty? + raise Gem::CommandLineError, multiple_matches_message(matches) if matches.length > 1 + + matches.first.first + end + + def multiple_matches_message(matches) + message = "Multiple gems matched #{gem_name_selector_description}: #{matches.map(&:first).join(", ")}" + suggestion = multiple_matches_suggestion(matches) + message += "\n#{suggestion}" if suggestion + message + end + + def multiple_matches_suggestion(matches) + if options[:platform] && !options[:ruby_abi] + ruby_abis = matches.filter_map {|_, spec| spec.ruby_abi }.uniq.sort + suggestions = [] + suggestions << "Specify --ruby-abi with one of: #{ruby_abis.join(", ")}" unless ruby_abis.empty? + suggestions << "To push a gem without a Ruby ABI, pass the exact filename." if matches.any? {|_, spec| spec.ruby_abi.nil? } + suggestions.join("\n") unless suggestions.empty? + elsif options[:ruby_abi] && !options[:platform] + platforms = matches.map {|_, spec| spec.platform.to_s }.uniq.sort + "Specify --platform with one of: #{platforms.join(", ")}" unless platforms.empty? + end + end + + def gem_name_selector_description + selectors = [] + selectors << "platform #{options[:platform]}" if options[:platform] + selectors << "Ruby ABI #{options[:ruby_abi]}" if options[:ruby_abi] + selectors.join(" and ") + end + + def platform_matches?(spec) + !options[:platform] || spec.platform == Gem::Platform.new(options[:platform]) + end + + def ruby_matches?(spec) + return true unless options[:ruby_abi] + + Gem::ContentAddress.applicable?(spec) && spec.ruby_abi == options[:ruby_abi] + end + def send_push_request(name, args) # Always honor explicit --attestation option # Auto-attestation is only supported on rubygems.org with GitHub Actions (not JRuby) diff --git a/lib/rubygems/commands/update_command.rb b/lib/rubygems/commands/update_command.rb index 71942f920d37..99d82dde0912 100644 --- a/lib/rubygems/commands/update_command.rb +++ b/lib/rubygems/commands/update_command.rb @@ -194,7 +194,7 @@ 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)] + [tup, source, source.created_at_for_tuple(tup)] end if !with_times.empty? && with_times.none? {|_, _, created_at| created_at } diff --git a/lib/rubygems/commands/yank_command.rb b/lib/rubygems/commands/yank_command.rb index fbdc262549d1..8fe668cdfd24 100644 --- a/lib/rubygems/commands/yank_command.rb +++ b/lib/rubygems/commands/yank_command.rb @@ -25,7 +25,7 @@ def arguments # :nodoc: end def usage # :nodoc: - "#{program_name} -v VERSION [-p PLATFORM] [--key KEY_NAME] [--host HOST] GEM" + "#{program_name} -v VERSION [-p PLATFORM] [--ruby-abi RUBY_ABI] [--key KEY_NAME] [--host HOST] GEM" end def initialize @@ -33,6 +33,7 @@ def initialize add_version_option("remove") add_platform_option("remove") + add_ruby_abi_option("remove") add_otp_option add_option("--host HOST", @@ -50,20 +51,21 @@ def execute sign_in @host, scope: get_yank_scope - version = get_version_from_requirements(options[:version]) - platform = get_platform_from_requirements(options) + version = get_version_from_requirements(options[:version]) + platform = get_platform_from_requirements(options) + ruby_abi = options[:ruby_abi] if version - yank_gem(version, platform) + yank_gem(version, platform, ruby_abi) else say "A version argument is required: #{usage}" terminate_interaction end end - def yank_gem(version, platform) + def yank_gem(version, platform, ruby_abi) say "Yanking gem from #{host}..." - args = [:delete, version, platform, "api/v1/gems/yank"] + args = [:delete, version, platform, ruby_abi, "api/v1/gems/yank"] response = yank_api_request(*args) say response.body @@ -71,7 +73,7 @@ def yank_gem(version, platform) private - def yank_api_request(method, version, platform, api) + def yank_api_request(method, version, platform, ruby_abi, api) name = get_one_gem_name response = rubygems_api_request(method, api, host, scope: get_yank_scope) do |request| request.add_field("Authorization", api_key) @@ -81,6 +83,7 @@ def yank_api_request(method, version, platform, api) "version" => version, } data["platform"] = platform if platform + data["ruby_abi"] = ruby_abi if ruby_abi request.set_form_data data end diff --git a/lib/rubygems/compact_index_client.rb b/lib/rubygems/compact_index_client.rb index 7cb012d38faf..436aa3758dc8 100644 --- a/lib/rubygems/compact_index_client.rb +++ b/lib/rubygems/compact_index_client.rb @@ -20,10 +20,13 @@ class Gem::CompactIndexClient # info returns an Array of INFO Arrays. Each INFO Array has the following indices: INFO_NAME = 0 INFO_VERSION = 1 - INFO_PLATFORM = 2 + INFO_SUFFIX = 2 INFO_DEPS = 3 INFO_REQS = 4 + INFO_PLATFORM = INFO_SUFFIX + deprecate_constant :INFO_PLATFORM + def self.debug return unless ENV["DEBUG_COMPACT_INDEX"] DEBUG_MUTEX.synchronize { warn("[#{self}] #{yield}") } diff --git a/lib/rubygems/content_address.rb b/lib/rubygems/content_address.rb new file mode 100644 index 000000000000..5ed8a86d313f --- /dev/null +++ b/lib/rubygems/content_address.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +## +# Gem::ContentAddress encapsulates the pattern for recognizing +# content-addressable gem file names. + +module Gem::ContentAddress + # :nodoc: + PATTERN = /\A[0-9a-f]{8,64}\z/ + + ## + # Whether +spec+ is eligible for content addressing. A gem must + # pin a required_ruby_version and declare a non-RUBY platform to be + # content addressed. + + def self.applicable?(spec) + required_ruby_version = spec.required_ruby_version + !required_ruby_version.nil? && !required_ruby_version.none? && + !spec.platform.nil? && spec.platform != Gem::Platform::RUBY + end + + ## + # Whether +spec+ is content-addressed: it is eligible for content + # addressing and has a valid content address set. + + def self.content_addressed?(spec) + applicable?(spec) && match?(spec.content_address) + end + + ## + # Whether +value+ is a valid content address (a string of 8-64 + # lowercase hexadecimal characters). + + def self.match?(value) + value.is_a?(String) && PATTERN.match?(value) + end + + ## + # Ranks +spec+ for candidate selection against +ruby_version+: a + # content-addressed spec built for that Ruby ranks first (0), any + # non-content-addressed spec next (1), and a content-addressed spec built + # for another Ruby last (2), since its binary cannot load there. + + def self.ruby_abi_specificity_match(spec, ruby_version = Gem.ruby_version) + return 1 unless match?(spec.content_address) + + if spec.required_ruby_version.satisfied_by?(ruby_version) + 0 + else + 2 + end + end +end diff --git a/lib/rubygems/installer.rb b/lib/rubygems/installer.rb index 2599c0ff6ec8..bb22bb1edb6a 100644 --- a/lib/rubygems/installer.rb +++ b/lib/rubygems/installer.rb @@ -114,6 +114,12 @@ def extract_files(destination_dir, pattern = "*") def copy_to(path) end + + def gem + end + + def content_address + end end ## @@ -266,6 +272,7 @@ def spec # specifications/.gemspec #=> the Gem::Specification def install + assign_content_address pre_install_checks run_pre_install_hooks @@ -966,6 +973,12 @@ def ensure_writable_dir(dir) # :nodoc: private + def assign_content_address + address = @package.content_address + @gem_dir = nil if address != spec.content_address + spec.content_address = address + end + def user_install_dir # never install to user home in --build-root mode return unless @build_root.nil? diff --git a/lib/rubygems/name_tuple.rb b/lib/rubygems/name_tuple.rb index cbdf4d7ac5f2..cb451dea3ed3 100644 --- a/lib/rubygems/name_tuple.rb +++ b/lib/rubygems/name_tuple.rb @@ -2,27 +2,46 @@ ## # -# Represents a gem of name +name+ at +version+ of +platform+. These -# wrap the data returned from the indexes. +# Represents a gem of name +name+ at +version+ of +platform+, optionally +# carrying a +content_address+ and +ruby_abi+ for content-addressable gems. +# These wrap the data returned from the indexes. class Gem::NameTuple - def initialize(name, version, platform = Gem::Platform::RUBY) + def initialize(name, version, platform = Gem::Platform::RUBY, content_address: nil, ruby_abi: nil) @name = name @version = version platform &&= platform.to_s platform = Gem::Platform::RUBY if !platform || platform.empty? @platform = platform + @content_address = content_address unless content_address.nil? + @ruby_abi = ruby_abi unless ruby_abi.nil? end - attr_reader :name, :version, :platform + attr_reader :name, :version, :platform, :content_address, :ruby_abi ## - # Turn an array of [name, version, platform] into an array of - # NameTuple objects. + # Turn an array of tuples into an array of NameTuple objects. Accepts: + # * Gem::NameTuple objects (passed through as-is) + # * 3-element arrays: [name, version, platform] + # * 5-element arrays: [name, version, platform, content_address, ruby_abi] def self.from_list(list) - list.map {|t| new(*t) } + list.map do |tuple| + case tuple + when Gem::NameTuple + tuple + when Array + case tuple.length + when 3, 5 + new(tuple[0], tuple[1], tuple[2], content_address: tuple[3], ruby_abi: tuple[4]) + else + raise ArgumentError, "Expected a 3- or 5-element tuple, got #{tuple.length}" + end + else + raise ArgumentError, "Expected a Gem::NameTuple or Array, got #{tuple.class}" + end + end end ## @@ -30,7 +49,7 @@ def self.from_list(list) # [name, version, platform] tuples. def self.to_basic(list) - list.map(&:to_a) + list.map {|tuple| [tuple.name, tuple.version, tuple.platform] } end ## @@ -46,8 +65,9 @@ def self.null # of Gem::Specification#full_name. def full_name - case @platform - when nil, "", Gem::Platform::RUBY + if @content_address + "#{@name}-#{@version}-#{@content_address}" + elsif @platform.nil? || @platform.empty? || @platform == Gem::Platform::RUBY "#{@name}-#{@version}" else "#{@name}-#{@version}-#{@platform}" @@ -75,41 +95,58 @@ def spec_name end ## - # Convert back to the [name, version, platform] tuple + # Convert back to the tuple array. Returns [name, version, platform] for + # non-content-addressable gems, or [name, version, platform, content_address, + # ruby_abi] for content-addressable gems. def to_a - [@name, @version, @platform] + if @content_address + [@name, @version, @platform, @content_address, @ruby_abi] + else + [@name, @version, @platform] + end end alias_method :deconstruct, :to_a def deconstruct_keys(keys) - { name: @name, version: @version, platform: @platform } + { + name: @name, + version: @version, + platform: @platform, + content_address: @content_address, + ruby_abi: @ruby_abi, + } end def inspect # :nodoc: - "#" + "#" end alias_method :to_s, :inspect # :nodoc: def <=>(other) - [@name, @version, Gem::Platform.sort_priority(@platform)] <=> - [other.name, other.version, Gem::Platform.sort_priority(other.platform)] + sort_key <=> other.sort_key + end + + def sort_key # :nodoc: + [@name, @version, Gem::Platform.sort_priority(@platform), @content_address.to_s, @ruby_abi.to_s] end include Comparable ## # Compare with +other+. Supports another NameTuple or an Array - # in the [name, version, platform] format. + # in the [name, version, platform, content_address, ruby_abi] format. def ==(other) case other when self.class @name == other.name && @version == other.version && - @platform == other.platform + @platform == other.platform && + @content_address == other.content_address && + @ruby_abi == other.ruby_abi when Array to_a == other else diff --git a/lib/rubygems/package.rb b/lib/rubygems/package.rb index e1a80717dec6..21dca9826907 100644 --- a/lib/rubygems/package.rb +++ b/lib/rubygems/package.rb @@ -129,16 +129,60 @@ class TarInvalidError < Error; end # Permission for other files attr_accessor :data_mode - def self.build(spec, skip_validation = false, strict_validation = false, file_name = nil) - gem_file = file_name || spec.file_name + ## + # The number of characters of the SHA-256 digest of the gem contents used + # in a content-addressable gem file name. + + DEFAULT_CONTENT_ADDRESS_LENGTH = 8 + + ## + # Builds the gem described by +spec+ and returns the built file name; + # passing +ruby_abi+ ("X.Y") builds a content-addressable gem named by the + # SHA-256 of its contents and updates +spec.required_ruby_version+ to + # "~> X.Y.0" (incompatible with +file_name+). + + def self.build(spec, skip_validation = false, strict_validation = false, file_name = nil, ruby_abi = nil) + if ruby_abi && file_name + raise ArgumentError, "Cannot specify both a Ruby ABI and an output file name because content addressable gems must use the generated file name." + end + if ruby_abi + require "digest" + require "stringio" + + validate_ruby_abi(spec, ruby_abi) + + build_spec = spec.dup + build_spec.required_ruby_version = Gem::Requirement.new("~> #{ruby_abi}.0") - package = new gem_file - package.spec = spec - package.build skip_validation, strict_validation + io = StringIO.new + io.set_encoding(Encoding::BINARY) + package = new io + package.spec = build_spec + gem_file = package.build_content_addressable_file skip_validation, strict_validation + + spec.required_ruby_version = build_spec.required_ruby_version + else + gem_file = file_name || spec.file_name + + package = new gem_file + package.spec = spec + package.build skip_validation, strict_validation + end gem_file end + def self.validate_ruby_abi(spec, ruby_abi) + if !/\A\d+\.\d+\z/.match?(ruby_abi) + raise ArgumentError, "Ruby ABI must be in X.Y format" + elsif spec.platform.nil? || spec.platform == Gem::Platform::RUBY + raise ArgumentError, "Cannot build a gem scoped to a single Ruby ABI as no platform or a Ruby platform has been set" + elsif spec.required_ruby_version && spec.required_ruby_version != Gem::Requirement.default && spec.ruby_abi != ruby_abi + raise ArgumentError, "Cannot build gem for Ruby ABI #{ruby_abi} because required_ruby_version is set to #{spec.required_ruby_version}. Please set required_ruby_version to \"~> #{ruby_abi}.0\"." + end + end + private_class_method :validate_ruby_abi + ## # Creates a new Gem::Package for the file at +gem+. +gem+ can also be # provided as an IO object. @@ -216,6 +260,28 @@ def copy_to(path) FileUtils.cp @gem.path, path unless File.exist? path end + ## + # Derives the content address from the gem's file name and verifies it + # against the SHA256 digest of the file contents. + + def content_address + path = @gem&.path + return unless path + + return nil unless Gem::ContentAddress.applicable?(spec) + + filename = File.basename(path, ".gem") + base = "#{spec.name}-#{spec.version}" + suffix = filename.delete_prefix("#{base}-") + return nil if suffix == filename + return nil unless Gem::ContentAddress.match?(suffix) + + require "digest" + digest = Digest::SHA256.file(path).hexdigest + raise Gem::InstallError, "content address mismatch for #{File.basename(path)}" unless digest.start_with?(suffix) + suffix + end + ## # Adds a checksum for each entry in the gem to checksums.yaml.gz. @@ -315,16 +381,35 @@ def build(skip_validation = false, strict_validation = false) end end - say <<-EOM + message = <<-EOM Successfully built RubyGem Name: #{@spec.name} Version: #{@spec.version} - File: #{File.basename @gem.path} EOM + + message += " File: #{File.basename(@gem.path)}\n" if @gem.path + say message ensure @signer = nil end + ## + # Builds this package, then writes it to a content-addressable file name + # derived from the SHA-256 digest of the gem contents, e.g. + # "example-1.0-01234567.gem". Returns the file name of the written gem. + + def build_content_addressable_file(skip_validation = false, strict_validation = false) + build skip_validation, strict_validation + + bytes = @gem.with_read_io(&:read) + gem_file = "#{@spec.name}-#{@spec.version}-#{Digest::SHA256.hexdigest(bytes)[0, DEFAULT_CONTENT_ADDRESS_LENGTH]}.gem" + File.binwrite(gem_file, bytes) + + say " File: #{gem_file}" + + gem_file + end + ## # A list of file names contained in this gem diff --git a/lib/rubygems/package_task.rb b/lib/rubygems/package_task.rb index d26411684dd0..398f7820ad77 100644 --- a/lib/rubygems/package_task.rb +++ b/lib/rubygems/package_task.rb @@ -111,10 +111,10 @@ def define file gem_path => [package_dir, gem_dir] + @gem_spec.files do chdir(gem_dir) do when_writing "Creating #{gem_spec.file_name}" do - Gem::Package.build gem_spec + built_gem_file = Gem::Package.build gem_spec verbose trace do - mv gem_file, ".." + mv built_gem_file, ".." end end end diff --git a/lib/rubygems/query_utils.rb b/lib/rubygems/query_utils.rb index 9849370b1a62..1f1e708052fe 100644 --- a/lib/rubygems/query_utils.rb +++ b/lib/rubygems/query_utils.rb @@ -149,9 +149,15 @@ def show_remote_gems(name) spec_tuples = if name.nil? fetcher.detect(specs_type) { true } else - fetcher.detect(specs_type) do |name_tuple| + matching_tuples = fetcher.detect(specs_type) do |name_tuple| name === name_tuple.name && options[:version].satisfied_by?(name_tuple.version) end + + if args.empty? + matching_tuples + else + fetcher.decode_content_addressable_tuples(matching_tuples, latest: specs_type == :latest) + end end output_query_results(spec_tuples) @@ -200,9 +206,11 @@ def output_versions(output, versions) matching_tuples = matching_tuples.sort_by {|n,_| n.version }.reverse platforms = Hash.new {|h,version| h[version] = [] } + platform_ruby_abis = Hash.new {|h,version| h[version] = Hash.new {|hh,platform| hh[platform] = [] } } matching_tuples.each do |n, _| platforms[n.version] << n.platform if n.platform + platform_ruby_abis[n.version][n.platform] << n.ruby_abi if n.ruby_abi end seen = {} @@ -216,11 +224,11 @@ def output_versions(output, versions) end end - output << clean_text(make_entry(matching_tuples, platforms)) + output << clean_text(make_entry(matching_tuples, platforms, platform_ruby_abis)) end end - def entry_details(entry, detail_tuple, specs, platforms) + def entry_details(entry, detail_tuple, specs, platforms, platform_ruby_abis) return unless options[:details] name_tuple, spec = detail_tuple @@ -229,7 +237,11 @@ def entry_details(entry, detail_tuple, specs, platforms) entry << "\n" - spec_platforms entry, platforms + if ruby_abi_metadata?(platform_ruby_abis) + spec_platform_ruby_abis entry, platforms, platform_ruby_abis + else + spec_platforms entry, platforms + end spec_authors entry, spec spec_homepage entry, spec spec_license entry, spec @@ -237,36 +249,63 @@ def entry_details(entry, detail_tuple, specs, platforms) spec_summary entry, spec end - def entry_versions(entry, name_tuples, platforms, specs) + def entry_versions(entry, name_tuples, platforms, platform_ruby_abis, specs) return unless options[:versions] list = if platforms.empty? || options[:details] name_tuples.map(&:version).uniq + elsif ruby_abi_metadata?(platform_ruby_abis) + platforms.sort.reverse.flat_map do |version, pls| + out = version_label(version, specs) + labels = version_platform_labels(version, pls, platform_ruby_abis, label_platform: true) + labels.empty? ? [out] : labels.map {|label| "#{out} #{label}" } + end else platforms.sort.reverse.map do |version, pls| - out = version.to_s + out = version_label(version, specs) + labels = version_platform_labels(version, pls, platform_ruby_abis) + labels.empty? ? out : "#{out} #{labels.join(" ")}" + end + end - if options[:domain] == :local - default = specs.any? do |s| - !s.is_a?(Gem::Source) && s.version == version && s.default_gem? - end - out = "default: #{out}" if default - end + use_multiline_separator = !options[:details] && ruby_abi_metadata?(platform_ruby_abis) && list.length > 1 + separator = use_multiline_separator ? "\n#{" " * (entry.first.length + 2)}" : ", " + entry << " (#{list.join separator})" + end - if pls != [Gem::Platform::RUBY] - platform_list = [pls.delete(Gem::Platform::RUBY), *pls.sort].compact - out = platform_list.unshift(out).join(" ") - end + def version_label(version, specs) + out = version.to_s + return out unless options[:domain] == :local - out - end - end + default = specs.any? do |s| + !s.is_a?(Gem::Source) && s.version == version && s.default_gem? + end + default ? "default: #{out}" : out + end + + def ruby_abi_metadata?(platform_ruby_abis) + platform_ruby_abis.values.any? do |ruby_abis_by_platform| + ruby_abis_by_platform.values.any?(&:any?) + end + end + + def version_platform_labels(version, platforms, platform_ruby_abis, label_platform: false) + platforms = platforms.uniq + return [] if platforms == [Gem::Platform::RUBY] - entry << " (#{list.join ", "})" + platforms = [platforms.delete(Gem::Platform::RUBY), *platforms.sort].compact + platforms.map do |platform| + ruby_abis = platform_ruby_abis[version][platform].uniq.sort + platform_label = label_platform ? "Platform: #{platform}" : platform + next platform_label if ruby_abis.empty? + + separator = label_platform ? ", " : " " + "#{platform_label}#{separator}Ruby ABI: #{ruby_abis.join(", ")}" + end end - def make_entry(entry_tuples, platforms) + def make_entry(entry_tuples, platforms, platform_ruby_abis) detail_tuple = entry_tuples.first name_tuples, specs = entry_tuples.flatten.partition do |item| @@ -275,8 +314,8 @@ def make_entry(entry_tuples, platforms) entry = [name_tuples.first.name] - entry_versions(entry, name_tuples, platforms, specs) - entry_details(entry, detail_tuple, specs, platforms) + entry_versions(entry, name_tuples, platforms, platform_ruby_abis, specs) + entry_details(entry, detail_tuple, specs, platforms, platform_ruby_abis) entry.join end @@ -319,6 +358,7 @@ def spec_loaded_from(entry, spec, specs) end def spec_platforms(entry, platforms) + platforms = platforms.transform_values(&:uniq) non_ruby = platforms.any? do |_, pls| pls.any? {|pl| pl != Gem::Platform::RUBY } end @@ -326,7 +366,7 @@ def spec_platforms(entry, platforms) return unless non_ruby if platforms.length == 1 - title = platforms.values.length == 1 ? "Platform" : "Platforms" + title = platforms.values.first.length == 1 ? "Platform" : "Platforms" entry << " #{title}: #{platforms.values.sort.join(", ")}\n" else entry << " Platforms:\n" @@ -342,6 +382,26 @@ def spec_platforms(entry, platforms) end end + def spec_platform_ruby_abis(entry, platforms, platform_ruby_abis) + entry << " Platforms:\n" + + platforms.sort.each do |version, pls| + labels = version_platform_labels(version, pls, platform_ruby_abis) + next if labels.empty? + + if platforms.length == 1 + labels.each do |label| + entry << " #{label}\n" + end + else + label = " #{version}: " + data = format_text labels.join(", "), 68, label.length + data[0, label.length] = label + entry << data << "\n" + end + end + end + def spec_summary(entry, spec) summary = truncate_text(spec.summary, "the summary for #{spec.full_name}") entry << "\n\n" << format_text(summary, 68, 4) diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb index 4964f5be217e..ee5953ee18c9 100644 --- a/lib/rubygems/resolver.rb +++ b/lib/rubygems/resolver.rb @@ -482,10 +482,12 @@ def build_spec_for_cache(name) next installed.first if installed.length == 1 candidates = installed if installed.any? - # Among remaining candidates, prefer the most specific platform, then the + # Among remaining candidates, prefer the most specific platform, then a + # content-addressed candidate built for the running Ruby, then the # earlier-supplied source. candidates.min_by do |s| [Gem::Platform.platform_specificity_match(s.platform, Gem::Platform.local), + Gem::ContentAddress.ruby_abi_specificity_match(s), source_rank[s.source]] end end diff --git a/lib/rubygems/resolver/api_set.rb b/lib/rubygems/resolver/api_set.rb index c3dc4c0bcabc..9574f9885c1d 100644 --- a/lib/rubygems/resolver/api_set.rb +++ b/lib/rubygems/resolver/api_set.rb @@ -108,12 +108,12 @@ def versions(name) # :nodoc: [] end - infos.each do |_, number, platform, dependencies, requirements| - platform ||= "ruby" + infos.each do |_, number, suffix, dependencies, requirements| + suffix ||= "ruby" dependencies = dependencies.map {|dep_name, reqs| [dep_name, reqs.join(", ")] } requirements = requirements.map {|req_name, reqs| [req_name.to_sym, reqs] }.to_h - @data[name] << { name: name, number: number, platform: platform, dependencies: dependencies, requirements: requirements } + @data[name] << { name: name, number: number, suffix: suffix, dependencies: dependencies, requirements: requirements } end @data[name] diff --git a/lib/rubygems/resolver/api_specification.rb b/lib/rubygems/resolver/api_specification.rb index 7a0d98cb80c5..a37b7ef417d5 100644 --- a/lib/rubygems/resolver/api_specification.rb +++ b/lib/rubygems/resolver/api_specification.rb @@ -33,8 +33,7 @@ def initialize(set, api_data) @set = set @name = api_data[:name] @version = Gem::Version.new(api_data[:number]).freeze - @platform = Gem::Platform.new(api_data[:platform]).freeze - @original_platform = api_data[:platform].freeze + assign_platform(api_data) @dependencies = api_data[:dependencies].map do |name, ver| Gem::Dependency.new(name, ver.split(/\s*,\s*/)).freeze end.freeze @@ -48,15 +47,17 @@ def ==(other) # :nodoc: @set == other.set && @name == other.name && @version == other.version && - @platform == other.platform + @platform == other.platform && + @content_address == other.content_address end def hash - @set.hash ^ @name.hash ^ @version.hash ^ @platform.hash + @set.hash ^ @name.hash ^ @version.hash ^ @platform.hash ^ @content_address.hash end def fetch_development_dependencies # :nodoc: - spec = source.fetch_spec Gem::NameTuple.new @name, @version, @platform + suffix = @content_address || @platform + spec = source.fetch_spec Gem::NameTuple.new @name, @version, suffix @dependencies = spec.dependencies end @@ -101,6 +102,7 @@ def spec # :nodoc: s.original_platform = @original_platform s.required_ruby_version = @required_ruby_version s.required_rubygems_version = @required_rubygems_version + s.content_address = @content_address @dependencies.each do |dependency| s.add_runtime_dependency dependency.name, *dependency.requirement.as_list @@ -114,6 +116,30 @@ def source # :nodoc: private + def assign_platform(api_data) + suffix = api_data[:suffix] + required_platform = required_platform_from(api_data.dig(:requirements, :platform)) + + if Gem::ContentAddress.match?(suffix) && required_platform + @content_address = suffix.freeze + @platform = required_platform.freeze + @original_platform = required_platform.to_s.freeze + else + @content_address = nil + @platform = Gem::Platform.new(suffix).freeze + @original_platform = suffix.freeze + end + end + + def required_platform_from(requirement) + return unless requirement + + op, platform = requirement.last&.split(" ", 2) + return unless op == "=" && platform + + Gem::Platform.new(platform) + end + def parse_created_at(value) value = value.first if value.is_a?(Array) diff --git a/lib/rubygems/resolver/index_specification.rb b/lib/rubygems/resolver/index_specification.rb index 7b9560807148..20ef14e68291 100644 --- a/lib/rubygems/resolver/index_specification.rb +++ b/lib/rubygems/resolver/index_specification.rb @@ -15,7 +15,7 @@ class Gem::Resolver::IndexSpecification < Gem::Resolver::Specification # The +name+, +version+ and +platform+ are the name, version and platform of # the gem. - def initialize(set, name, version, source, platform) + def initialize(set, name, version, source, platform, content_address: nil) super() @set = set @@ -24,6 +24,7 @@ def initialize(set, name, version, source, platform) @source = source @platform = Gem::Platform.new(platform.to_s) @original_platform = platform.to_s + @content_address = content_address @spec = nil end @@ -60,11 +61,12 @@ def ==(other) self.class === other && @name == other.name && @version == other.version && - @platform == other.platform + @platform == other.platform && + @content_address == other.content_address end def hash - @name.hash ^ @version.hash ^ @platform.hash + [@name, @version, @platform, @content_address].hash end def inspect # :nodoc: @@ -93,7 +95,7 @@ def pretty_print(q) # :nodoc: def spec # :nodoc: @spec ||= begin - tuple = Gem::NameTuple.new @name, @version, @original_platform + tuple = Gem::NameTuple.new @name, @version, @original_platform, content_address: @content_address @source.fetch_spec tuple end diff --git a/lib/rubygems/resolver/installer_set.rb b/lib/rubygems/resolver/installer_set.rb index 42ce0890e2b6..e1113e595560 100644 --- a/lib/rubygems/resolver/installer_set.rb +++ b/lib/rubygems/resolver/installer_set.rb @@ -163,7 +163,7 @@ def find_all(req) @local_source.find_all_gems(name, dep.requirement).each do |local_spec| res << Gem::Resolver::IndexSpecification.new( self, local_spec.name, local_spec.version, - @local_source, local_spec.platform + @local_source, local_spec.platform, content_address: local_spec.content_address ) end rescue Gem::Package::FormatError diff --git a/lib/rubygems/resolver/spec_specification.rb b/lib/rubygems/resolver/spec_specification.rb index 00ef9fdba05b..f08d9773f8ea 100644 --- a/lib/rubygems/resolver/spec_specification.rb +++ b/lib/rubygems/resolver/spec_specification.rb @@ -23,6 +23,10 @@ def dependencies spec.dependencies end + def content_address # :nodoc: + spec.content_address + end + ## # The required_ruby_version constraint for this specification diff --git a/lib/rubygems/resolver/specification.rb b/lib/rubygems/resolver/specification.rb index 986fa7c9ae82..7b96d4ac4196 100644 --- a/lib/rubygems/resolver/specification.rb +++ b/lib/rubygems/resolver/specification.rb @@ -60,6 +60,11 @@ class Gem::Resolver::Specification attr_reader :created_at + ## + # The content address of this specification. + + attr_reader :content_address + ## # Sets default instance variables for the specification. @@ -73,6 +78,7 @@ def initialize @version = nil @required_ruby_version = Gem::Requirement.default @required_rubygems_version = Gem::Requirement.default + @content_address = nil end ## diff --git a/lib/rubygems/safe_marshal.rb b/lib/rubygems/safe_marshal.rb index 871f24727dcb..8bb8d95a692a 100644 --- a/lib/rubygems/safe_marshal.rb +++ b/lib/rubygems/safe_marshal.rb @@ -51,7 +51,7 @@ module SafeMarshal @name @requirement @prerelease @version_requirement @version_requirements @type @force_ruby_platform ], - "Gem::NameTuple" => %w[@name @version @platform], + "Gem::NameTuple" => %w[@name @version @platform @content_address @ruby_abi], "Gem::Platform" => %w[@os @cpu @version], "Psych::PrivateType" => %w[@value @type_id], "YAML::PrivateType" => %w[@value @type_id], diff --git a/lib/rubygems/source.rb b/lib/rubygems/source.rb index 286a036a5f4b..5ce5d19e926d 100644 --- a/lib/rubygems/source.rb +++ b/lib/rubygems/source.rb @@ -18,6 +18,24 @@ class Gem::Source prerelease: "prerelease_specs", }.freeze + ## + # Decoded compact index metadata for one content-addressable gem build: + # its +version+, content-address +suffix+, +ruby_abi+, and +platform+. + # Two infos are equal when their version and suffix match. + + ContentAddressableInfo = Struct.new(:version, :suffix, :ruby_abi, :platform) do + def hash + [version, suffix].hash + end + + def eql?(other) + other.is_a?(self.class) && + version == other.version && + suffix == other.suffix + end + alias_method :==, :eql? + end + ## # The URI this source will fetch gems from. @@ -186,12 +204,48 @@ def compact_index_client # :nodoc: end ## - # The publish time of gem +name+ at +version+ for +platform+, when this + # Decodes content-addressable +tuples+ (whose platform field holds the + # content address) into tuples carrying their real platform and Ruby ABI + # from the compact index /info metadata. + + def decode_content_addressable_tuples(tuples, latest: false) + ca_tuples = tuples.select(&:content_address) + return tuples if ca_tuples.empty? + + decoded_tuples = ca_tuples.group_by(&:name).flat_map do |name, name_tuples| + rows = name_tuples.map do |tuple| + [tuple.name, tuple.version, tuple.version.to_s, tuple.content_address] + end + + content_addressable_tuples(name, rows) + end + + decoded_by_key = decoded_tuples.to_h do |tuple| + [[tuple.name, tuple.version, tuple.content_address], tuple] + end + + decoded = tuples.filter_map do |tuple| + if tuple.content_address + decoded_by_key[[tuple.name, tuple.version, tuple.content_address]] + else + tuple + end + end + + return decoded unless latest + + decoded.group_by(&:name).flat_map do |_, name_tuples| + max_versions_by_platform(name_tuples) + end + end + + ## + # The publish time of gem +name+ at +version+ for +suffix+, 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) + def created_at(name, version, suffix = Gem::Platform::RUBY) return unless %w[http https].include?(uri.scheme) @created_at_info ||= {} @@ -201,12 +255,12 @@ def created_at(name, version, platform = Gem::Platform::RUBY) [] end - platform = (platform || Gem::Platform::RUBY).to_s + suffix = (suffix || 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 + (row_info[Gem::CompactIndexClient::INFO_SUFFIX] || Gem::Platform::RUBY) == suffix end return unless row @@ -215,6 +269,14 @@ def created_at(name, version, platform = Gem::Platform::RUBY) Gem::Cooldown.parse_created_at(value) end + ## + # The publish time for +tuple+. Content-addressable tuples are looked up by + # content address; all other tuples are looked up by platform. + + def created_at_for_tuple(tuple) + created_at(tuple.name, tuple.version, tuple.content_address || tuple.platform) + end + ## # Downloads +spec+ and writes it to +dir+. See also # Gem::RemoteFetcher#download. @@ -290,13 +352,16 @@ def load_compact_index_specs(type) tuples = [] versions.each_value do |rows| - gem_tuples = rows.filter_map do |name, version_string, platform| + gem_tuples = rows.filter_map do |row_name, version_string, suffix| next unless Gem::Version.correct?(version_string) version = Gem::Version.new(version_string) next if version.prerelease? != (type == :prerelease) - Gem::NameTuple.new(name, version, platform || "ruby") + suffix ||= "ruby" + content_address = suffix if Gem::ContentAddress.match?(suffix) + + Gem::NameTuple.new(row_name, version, suffix, content_address: content_address) end gem_tuples = max_versions_by_platform(gem_tuples) if type == :latest @@ -314,8 +379,87 @@ def compact_index_versions nil end + def compact_index_info_rows(name) + compact_index_client.info(name) + rescue Gem::RemoteFetcher::FetchError, Gem::CompactIndexClient::Error + [] + end + + def content_addressable_tuples(name, rows) + metadata = content_addressable_metadata(name, rows) + + rows.filter_map do |row_name, version, version_string, suffix| + row_metadata = metadata.find do |entry| + entry.version == version_string && entry.suffix == suffix + end + next unless row_metadata + + Gem::NameTuple.new( + row_name, + version, + row_metadata.platform, + content_address: suffix, + ruby_abi: row_metadata.ruby_abi + ) + end + end + + def content_addressable_metadata(name, rows) + wanted_rows = rows.map do |row| + ContentAddressableInfo.new(row[2], row[3], nil, nil) + end + + available_rows = compact_index_info_rows(name).filter_map do |info_row| + version = info_row[Gem::CompactIndexClient::INFO_VERSION] + suffix = info_row[Gem::CompactIndexClient::INFO_SUFFIX] + + requirements = compact_index_requirements(info_row) + platform = required_platform_from(requirements[:platform]) + next unless platform + next unless requirements[:ruby] + + ContentAddressableInfo.new(version, suffix, ruby_abi_from(requirements[:ruby]), platform) + end + + available_rows & wanted_rows + end + + def compact_index_requirements(info_row) + info_row[Gem::CompactIndexClient::INFO_REQS].to_h do |key, requirements| + [key.to_sym, requirements] + end + end + + def required_platform_from(requirement) + platform_requirement = Array(requirement).last.to_s + operator, platform = platform_requirement.split(" ", 2) + return unless operator == "=" && platform + + platform + end + + def ruby_abi_from(requirement) + Array(requirement).each do |ruby_requirement| + match = ruby_requirement.to_s.match(/\A~>\s*(\d+)\.(\d+)\.0\z/) + return "#{match[1]}.#{match[2]}" if match + end + + nil + end + def max_versions_by_platform(tuples) - tuples.group_by(&:platform).map {|_, platform_tuples| platform_tuples.max_by(&:version) } + grouped_tuples = tuples.group_by {|tuple| latest_platform_key(tuple) } + grouped_tuples.map do |_, platform_tuples| + platform_tuples.max_by(&:version) + end + end + + def latest_platform_key(tuple) + if tuple.content_address + [tuple.platform, tuple.ruby_abi || tuple.content_address] + else + tuple.platform + end end def compact_index_uri diff --git a/lib/rubygems/source/local.rb b/lib/rubygems/source/local.rb index 4bef31a2655f..af2f79a05949 100644 --- a/lib/rubygems/source/local.rb +++ b/lib/rubygems/source/local.rb @@ -41,7 +41,8 @@ def load_specs(type) # :nodoc: Dir["*.gem"].each do |file| pkg = Gem::Package.new(file) spec = pkg.spec - rescue SystemCallError, Gem::Package::FormatError + spec.content_address = pkg.content_address + rescue SystemCallError, Gem::Package::FormatError, Gem::InstallError # ignore else tup = spec.name_tuple diff --git a/lib/rubygems/spec_fetcher.rb b/lib/rubygems/spec_fetcher.rb index 6f06b554d2c1..200e08f0e352 100644 --- a/lib/rubygems/spec_fetcher.rb +++ b/lib/rubygems/spec_fetcher.rb @@ -91,7 +91,8 @@ def search_for_dependency(dependency, matching_platform = true, type: nil) rejected_specs = {} - list, errors = available_specs(type || dependency.identity) + specs_type = type || dependency.identity + list, errors = available_specs(specs_type) list.each do |source, specs| if dependency.name.is_a?(String) && specs.respond_to?(:bsearch) @@ -100,17 +101,20 @@ def search_for_dependency(dependency, matching_platform = true, type: nil) specs = specs[start_index...end_index] if start_index && end_index end + specs = specs.select {|tup| dependency.match?(tup) } + specs = decode_source_content_addressable_tuples(source, specs, latest: specs_type == :latest) + found[source] = specs.select do |tup| - if dependency.match?(tup) - if matching_platform && !Gem::Platform.match_gem?(tup.platform, tup.name) - pm = ( - rejected_specs[dependency] ||= \ - Gem::PlatformMismatch.new(tup.name, tup.version)) - pm.add_platform tup.platform - false - else - true - end + if matching_platform && !Gem::Platform.match_gem?(tup.platform, tup.name) + pm = ( + rejected_specs[dependency] ||= \ + Gem::PlatformMismatch.new(tup.name, tup.version)) + pm.add_platform tup.platform + false + elsif matching_platform && !ruby_abi_match?(tup) + false + else + true end end end @@ -159,6 +163,7 @@ def spec_for_dependency(dependency, matching_platform = true) specs = [] tuples.each do |tup, source| spec = source.fetch_spec(tup) + spec.content_address = tup.content_address if tup.content_address rescue Gem::RemoteFetcher::FetchError => e errors << Gem::SourceFetchProblem.new(source, e) else @@ -168,6 +173,17 @@ def spec_for_dependency(dependency, matching_platform = true) [specs, errors] end + ## + # Decodes the content-addressable tuples in +spec_tuples+ ([tuple, source] + # pairs) to carry their real platform and Ruby ABI via each source. + + def decode_content_addressable_tuples(spec_tuples, latest: false) + spec_tuples.group_by {|_, source| source }.flat_map do |source, source_tuples| + tuples = source_tuples.map(&:first) + decode_source_content_addressable_tuples(source, tuples, latest: latest).map {|tuple| [tuple, source] } + end + end + ## # Suggests gems based on the supplied +gem_name+. Returns an array of # alternative gem names. @@ -290,4 +306,20 @@ def tuples_for(source, type, gracefully_ignore = false) # :nodoc: raise unless gracefully_ignore [] end + + private + + def decode_source_content_addressable_tuples(source, tuples, latest: false) # :nodoc: + return tuples unless source.respond_to?(:decode_content_addressable_tuples) + + source.decode_content_addressable_tuples(tuples, latest: latest) + end + + def ruby_abi_match?(tuple) # :nodoc: + !tuple.ruby_abi || tuple.ruby_abi == current_ruby_abi + end + + def current_ruby_abi # :nodoc: + @current_ruby_abi ||= Gem.ruby_version.segments.first(2).join(".") + end end diff --git a/lib/rubygems/specification.rb b/lib/rubygems/specification.rb index adee800051fc..a3018912a041 100644 --- a/lib/rubygems/specification.rb +++ b/lib/rubygems/specification.rb @@ -419,6 +419,13 @@ def licenses=(licenses) attr_accessor :metadata + ## + # The content address of this gem, a SHA-256 prefix of the gem file + # contents used in place of the platform in file and install names + # (e.g. "example-1.0-78be552b"), or +nil+ for non-content-addressable gems. + + attr_accessor :content_address + ###################################################################### # :section: Optional gemspec attributes @@ -568,6 +575,23 @@ def add_dependency(gem, *requirements) add_dependency_with_type(gem, :runtime, requirements) end + ## + # Ruby ABI of the gem derived from required_ruby_version + # Only supports required_ruby_version in "~> X.Y.0" format (single pessimistic requirement with 3 segments) + # Returns nil if the required_ruby_version does not specify a single Ruby ABI + + def ruby_abi + return nil if required_ruby_version.nil? || required_ruby_version == Gem::Requirement.default + + requirements = required_ruby_version.requirements + return nil if requirements.size != 1 + + op, version = requirements.first + return nil if op != "~>" || version.segments.size != 3 || version.segments[2] != 0 + + version.segments[0..1].join(".") + end + ## # Executables included in the gem. # @@ -1354,7 +1378,8 @@ def ==(other) # :nodoc: self.class === other && name == other.name && version == other.version && - platform == other.platform + platform == other.platform && + content_address == other.content_address end ## @@ -1925,7 +1950,7 @@ def has_unit_tests? # :nodoc: # :startdoc: def hash # :nodoc: - name.hash ^ version.hash + [name, version, platform, content_address].hash end def init_with(coder) # :nodoc: @@ -1961,6 +1986,7 @@ def initialize(name = nil, version = nil) @loaded_from = nil @original_platform = nil @installed_by_version = nil + @content_address = nil set_nil_attributes_to_nil set_not_nil_attributes_to_default_values @@ -2114,7 +2140,7 @@ def normalize # Return a NameTuple that represents this Specification def name_tuple - Gem::NameTuple.new name, version, original_platform + Gem::NameTuple.new name, version, original_platform, content_address: content_address end ## @@ -2281,7 +2307,8 @@ def runtime_dependencies # True if this gem has the same attributes as +other+. def same_attributes?(spec) - @@attributes.all? {|name, _default| send(name) == spec.send(name) } + @@attributes.all? {|name, _default| send(name) == spec.send(name) } && + content_address == spec.content_address end private :same_attributes? @@ -2380,11 +2407,14 @@ def test_files # :nodoc: # still have their default values are omitted. def to_ruby + content_addressed = Gem::ContentAddress.content_addressed?(self) + gem_suffix = content_addressed ? content_address : platform result = [] result << "# -*- encoding: utf-8 -*-" - result << "#{Gem::StubSpecification::PREFIX}#{name} #{version} #{platform} #{raw_require_paths.join("\0")}" + result << "#{Gem::StubSpecification::PREFIX}#{name} #{version} #{gem_suffix} #{raw_require_paths.join("\0")}" result << "#{Gem::StubSpecification::PREFIX}#{extensions.join "\0"}" unless extensions.empty? + result << "#{Gem::StubSpecification::TARGET_PREFIX}platform=#{platform}" if content_addressed result << nil result << "Gem::Specification.new do |s|" @@ -2393,6 +2423,7 @@ def to_ruby unless platform.nil? || platform == Gem::Platform::RUBY result << " s.platform = #{ruby_code original_platform}" end + result << " s.content_address = #{ruby_code content_address} if s.respond_to? :content_address=" if content_addressed result << "" result << " s.required_rubygems_version = #{ruby_code required_rubygems_version} if s.respond_to? :required_rubygems_version=" diff --git a/lib/rubygems/stub_specification.rb b/lib/rubygems/stub_specification.rb index 53b337ed8554..825f1ec11d5e 100644 --- a/lib/rubygems/stub_specification.rb +++ b/lib/rubygems/stub_specification.rb @@ -9,14 +9,18 @@ class Gem::StubSpecification < Gem::BasicSpecification # :nodoc: PREFIX = "# stub: " + # :nodoc: + TARGET_PREFIX = "# stub-target: " + # :nodoc: OPEN_MODE = "r:UTF-8:-" class StubLine # :nodoc: all attr_reader :name, :version, :platform, :require_paths, :extensions, - :full_name + :full_name, :content_address NO_EXTENSIONS = [].freeze + NO_TARGET = {}.freeze # These are common require paths. REQUIRE_PATHS = { # :nodoc: @@ -33,7 +37,7 @@ class StubLine # :nodoc: all "lib" => ["lib"].freeze, }.freeze - def initialize(data, extensions) + def initialize(data, extensions, target = NO_TARGET) parts = data[PREFIX.length..-1].split(" ", 4) @name = -parts[0] @version = if Gem::Version.correct?(parts[1]) @@ -42,12 +46,17 @@ def initialize(data, extensions) Gem::Version.new(0) end - @platform = Gem::Platform.new parts[2] + suffix = parts[2] + target_platform = target["platform"] + @platform = Gem::Platform.new(target_platform || suffix) + @content_address = suffix if target_platform && Gem::ContentAddress.match?(suffix) @extensions = extensions - @full_name = if platform == Gem::Platform::RUBY + @full_name = if @content_address + "#{name}-#{version}-#{content_address}" + elsif platform == Gem::Platform::RUBY "#{name}-#{version}" else - "#{name}-#{version}-#{platform}" + "#{name}-#{version}-#{suffix}" end path_list = parts.last @@ -110,18 +119,27 @@ def data file.readline # discard encoding line stubline = file.readline if stubline.start_with?(PREFIX) - extline = file.readline - - extensions = - if extline.delete_prefix!(PREFIX) - extline.chomp! - extline.split "\0" - else - StubLine::NO_EXTENSIONS + line = file.readline + + if line.delete_prefix!(PREFIX) + line.chomp! + extensions = line.split "\0" + line = file.readline + else + extensions = StubLine::NO_EXTENSIONS + end + + target = StubLine::NO_TARGET + if line.delete_prefix!(TARGET_PREFIX) + line.chomp! + target = line.split(",").to_h do |pair| + key, value = pair.split("=", 2) + [key, value] end + end stubline.chomp! # readline(chomp: true) allocates 3x as much as .readline.chomp! - @data = StubLine.new stubline, extensions + @data = StubLine.new stubline, extensions, target end rescue EOFError end @@ -162,6 +180,10 @@ def platform data.platform end + def content_address # :nodoc: + data.content_address + end + ## # Extensions for this gem @@ -208,13 +230,14 @@ def ==(other) # :nodoc: self.class === other && name == other.name && version == other.version && - platform == other.platform + platform == other.platform && + content_address == other.content_address end alias_method :eql?, :== # :nodoc: def hash # :nodoc: - name.hash ^ version.hash ^ platform.hash + [name, version, platform, content_address].hash end def <=>(other) # :nodoc: diff --git a/lib/rubygems/version_option.rb b/lib/rubygems/version_option.rb index 7910fd3d1b50..3da18dd98253 100644 --- a/lib/rubygems/version_option.rb +++ b/lib/rubygems/version_option.rb @@ -46,6 +46,20 @@ def add_prerelease_option(*wrap) end end + ## + # Add the --ruby-abi option to the option parser. + + def add_ruby_abi_option(task = command, *wrap) + add_option("--ruby-abi RUBY_ABI", + "Specify the Ruby ABI of gem to #{task}", *wrap) do |value, options| + unless /\A\d+\.\d+\z/.match?(value) + raise Gem::OptionParser::InvalidArgument, "#{value}: Ruby ABI must be in X.Y format" + end + + options[:ruby_abi] = value + end + end + ## # Add the --version option to the option parser. diff --git a/spec/bundler/endpoint_specification_spec.rb b/spec/bundler/endpoint_specification_spec.rb index 229ea34dda66..3f38b41ac861 100644 --- a/spec/bundler/endpoint_specification_spec.rb +++ b/spec/bundler/endpoint_specification_spec.rb @@ -3,12 +3,12 @@ RSpec.describe Bundler::EndpointSpecification do let(:name) { "foo" } let(:version) { "1.0.0" } - let(:platform) { Gem::Platform::RUBY } + let(:suffix) { Gem::Platform::RUBY } let(:dependencies) { [] } let(:spec_fetcher) { double(:spec_fetcher) } let(:metadata) { nil } - subject(:spec) { described_class.new(name, version, platform, spec_fetcher, dependencies, metadata) } + subject(:spec) { described_class.new(name, version, suffix, spec_fetcher, dependencies, metadata) } def with_tz(tz) orig_tz = ENV["TZ"] @@ -44,6 +44,37 @@ def with_tz(tz) end describe "#parse_metadata" do + context "when a content-addressed suffix has platform metadata" do + let(:suffix) { "abc1234567" } + let(:metadata) { { "platform" => ["= arm64-darwin"], "ruby" => [">= 3.0.0"] } } + + it "uses the platform from the metadata" do + expect(spec.platform).to eq(Gem::Platform.new("arm64-darwin")) + expect(spec.content_address).to eq("abc1234567") + end + + it "includes the content address in full_name" do + expect(spec.full_name).to eq("foo-1.0.0-abc1234567") + end + end + + context "when the suffix is an ordinary platform" do + let(:suffix) { "x86_64-linux" } + + it "uses the suffix as the platform without a content address" do + expect(spec.platform).to eq(Gem::Platform.new("x86_64-linux")) + expect(spec.content_address).to be_nil + end + end + + context "when a content-addressed suffix has no platform metadata" do + let(:suffix) { "abc1234567" } + + it "treats the suffix as a platform without a content address" do + expect(spec.content_address).to be_nil + end + end + context "when the metadata has malformed requirements" do let(:metadata) { { "rubygems" => ">\n" } } it "raises a helpful error message" do @@ -143,34 +174,48 @@ def with_tz(tz) end describe "#required_ruby_version" do - context "required_ruby_version is already set on endpoint specification" do - existing_value = "already set value" - let(:required_ruby_version) { existing_value } + context "when the specification is content-addressed" do + let(:suffix) { "abc1234567" } + let(:metadata) { { "platform" => ["= arm64-darwin"] } } - it "should return the current value when already set on endpoint specification" do - expect(spec.required_ruby_version). eql?(existing_value) + it "fetches the remote specification using the content address" do + remote_spec = double(:remote_spec, required_ruby_version: nil) + + expect(spec_fetcher).to receive(:fetch_spec). + with([name, Gem::Version.new(version), suffix]). + and_return(remote_spec) + + spec.send(:_remote_specification) end end - it "should return the remote spec value when not set on endpoint specification and remote spec has one" do - remote_value = "remote_value" - remote_spec = double(:remote_spec, required_ruby_version: remote_value, required_rubygems_version: nil) - allow(spec_fetcher).to receive(:fetch_spec).and_return(remote_spec) + context "required_ruby_version is already set on endpoint specification" do + let(:metadata) { { "ruby" => [">= 3.0"] } } - expect(spec.required_ruby_version). eql?(remote_value) + it "returns the value from metadata without fetching the remote spec" do + expect(spec_fetcher).not_to receive(:fetch_spec) + expect(spec.required_ruby_version).to eq(Gem::Requirement.new(">= 3.0")) + end end - it "should use the default Gem Requirement value when not set on endpoint specification and not set on remote spec" do - remote_spec = double(:remote_spec, required_ruby_version: nil, required_rubygems_version: nil) + it "returns nil when not set on endpoint specification and metadata is nil" do + expect(spec_fetcher).not_to receive(:fetch_spec) + expect(spec.required_ruby_version).to be_nil + end + + it "loads required_ruby_version from the remote spec via matches_current_ruby?" do + remote_spec = double(:remote_spec, required_ruby_version: Gem::Requirement.new(">= 3.0"), required_rubygems_version: nil) allow(spec_fetcher).to receive(:fetch_spec).and_return(remote_spec) - expect(spec.required_ruby_version). eql?(Gem::Requirement.default) + + spec.matches_current_ruby? + expect(spec.required_ruby_version).to eq(Gem::Requirement.new(">= 3.0")) end end it "supports equality comparison" do remote_spec = double(:remote_spec, required_ruby_version: nil, required_rubygems_version: nil) allow(spec_fetcher).to receive(:fetch_spec).and_return(remote_spec) - other_spec = described_class.new("bar", version, platform, spec_fetcher, dependencies, metadata) + other_spec = described_class.new("bar", version, suffix, spec_fetcher, dependencies, metadata) expect(spec).to eql(spec) expect(spec).to_not eql(other_spec) end diff --git a/spec/bundler/lockfile_parser_spec.rb b/spec/bundler/lockfile_parser_spec.rb index c92d8909d29e..c54ae5d1fa92 100644 --- a/spec/bundler/lockfile_parser_spec.rb +++ b/spec/bundler/lockfile_parser_spec.rb @@ -145,6 +145,65 @@ include_examples "parsing" + context "when a spec has a content address" do + let(:lockfile_contents) do + <<~L + GEM + remote: https://rubygems.org/ + specs: + mygem (1.0-abcdef1234) x86_64-linux + + PLATFORMS + x86_64-linux + + DEPENDENCIES + mygem + + CHECKSUMS + mygem (1.0-abcdef1234) sha256=814828c34f1315d7e7b7e8295184577cc4e969bad6156ac069d02d63f58d82e8 + + BUNDLED WITH + 1.12.0.rc.2 + L + end + + it "parses the platform and content address" do + spec = subject.specs.find {|s| s.name == "mygem" } + + expect(spec.platform).to eq(Gem::Platform.new("x86_64-linux")) + expect(spec.content_address).to eq("abcdef1234") + + checksums = subject.sources.first.checksum_store.to_lock(spec) + expect(checksums).to eq("#{spec.lock_name} sha256=814828c34f1315d7e7b7e8295184577cc4e969bad6156ac069d02d63f58d82e8") + end + end + + context "when a Ruby-platform suffix resembles a content address but no platform is present" do + let(:lockfile_contents) do + <<~L + GEM + remote: https://rubygems.org/ + specs: + mygem (1.0-abcdef1234) + + PLATFORMS + ruby + + DEPENDENCIES + mygem + + BUNDLED WITH + 1.12.0.rc.2 + L + end + + it "does not parse the suffix as a content address" do + spec = subject.specs.find {|s| s.name == "mygem" } + + expect(spec.content_address).to be_nil + end + end + context "when an extra section is at the end" do let(:lockfile_contents) { super() + "\n\nFOO BAR\n baz\n baa\n qux\n" } include_examples "parsing" diff --git a/spec/bundler/override_spec.rb b/spec/bundler/override_spec.rb index ad8be75520b5..347509d27d3d 100644 --- a/spec/bundler/override_spec.rb +++ b/spec/bundler/override_spec.rb @@ -56,6 +56,7 @@ def initialize(name, ruby_req, rubygems_req) src.define_singleton_method(:runtime_dependencies) { [] } src.define_singleton_method(:required_ruby_version) { Gem::Requirement.default } src.define_singleton_method(:required_rubygems_version) { Gem::Requirement.default } + src.define_singleton_method(:content_address) { nil } src.define_singleton_method(:respond_to?) {|*| raise "from_spec must not call respond_to?" } expect { Bundler::LazySpecification.from_spec(src) }.not_to raise_error end diff --git a/spec/bundler/remote_specification_spec.rb b/spec/bundler/remote_specification_spec.rb index f35b231d5869..e68d68d1115b 100644 --- a/spec/bundler/remote_specification_spec.rb +++ b/spec/bundler/remote_specification_spec.rb @@ -3,10 +3,10 @@ RSpec.describe Bundler::RemoteSpecification do let(:name) { "foo" } let(:version) { Gem::Version.new("1.0.0") } - let(:platform) { Gem::Platform::RUBY } + let(:suffix) { Gem::Platform::RUBY } let(:spec_fetcher) { double(:spec_fetcher) } - subject { described_class.new(name, version, platform, spec_fetcher) } + subject { described_class.new(name, version, suffix, spec_fetcher) } it "is Comparable" do expect(described_class.ancestors).to include(Comparable) @@ -34,7 +34,7 @@ end context "when platform is nil" do - let(:platform) { nil } + let(:suffix) { nil } it "should return the spec name and version" do expect(subject.full_name).to eq("foo-1.0.0") @@ -42,7 +42,7 @@ end context "when platform is a non-ruby platform" do - let(:platform) { "jruby" } + let(:suffix) { "jruby" } it "should return the spec name, version, and platform" do expect(subject.full_name).to eq("foo-1.0.0-java") @@ -53,7 +53,7 @@ describe "#<=>" do let(:other_name) { name } let(:other_version) { version } - let(:other_platform) { platform } + let(:other_platform) { suffix } let(:other_spec_fetcher) { spec_fetcher } shared_examples_for "a comparison" do @@ -147,7 +147,7 @@ end context "when platform is not ruby" do - let(:platform) { "jruby" } + let(:suffix) { "jruby" } it "should return a sorting delegate array with name, version, and 1" do expect(subject.sort_obj).to match_array(["foo", version, 1]) @@ -184,4 +184,21 @@ end end end + + describe "#content_address" do + it "is nil for a hex suffix (CA gems are not handled on the legacy index path)" do + spec = Bundler::RemoteSpecification.new(name, version, "abc1234567", spec_fetcher) + expect(spec.content_address).to be_nil + end + + it "is nil for an ordinary platform" do + spec = Bundler::RemoteSpecification.new(name, version, "x86_64-linux", spec_fetcher) + expect(spec.content_address).to be_nil + end + + it "is nil for a RUBY platform" do + spec = Bundler::RemoteSpecification.new(name, version, Gem::Platform::RUBY, spec_fetcher) + expect(spec.content_address).to be_nil + end + end end diff --git a/spec/install/cooldown_spec.rb b/spec/install/cooldown_spec.rb index b45d50b1f2e1..f887a4b84031 100644 --- a/spec/install/cooldown_spec.rb +++ b/spec/install/cooldown_spec.rb @@ -173,7 +173,7 @@ #{Bundler::VERSION} L - bundle "lock --update ripe_gem", artifice: "compact_index_cooldown" + bundle "lock --update ripe_gem", artifice: "compact_index_v2" expect(lockfile).to include("fresh_gem (0.3.2)") end @@ -184,7 +184,7 @@ gem "ripe_gem" G - bundle "install --cooldown 7", artifice: "compact_index_cooldown" + bundle "install --cooldown 7", artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 1.0.0") end @@ -195,7 +195,7 @@ gem "ripe_gem" G - bundle "install --cooldown 0", artifice: "compact_index_cooldown" + bundle "install --cooldown 0", artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 2.0.0") end @@ -206,7 +206,7 @@ gem "ripe_gem" G - bundle "install --cooldown 7", artifice: "compact_index_cooldown" + bundle "install --cooldown 7", artifice: "compact_index_v2" expect(out).to include("The following gem versions were skipped by the cooldown setting:") expect(out).to include("* ripe_gem 2.0.0 (available in 6 days), resolved 1.0.0 instead") @@ -235,7 +235,7 @@ #{Bundler::VERSION} L - bundle "update ripe_gem", artifice: "compact_index_cooldown" + bundle "update ripe_gem", artifice: "compact_index_v2" expect(out).to include("The following gem versions were skipped by the cooldown setting:") expect(out).to include("* ripe_gem 2.0.0 (available in 6 days), resolved 1.0.0 instead") @@ -248,7 +248,7 @@ gem "ripe_gem" G - bundle "install --cooldown 0", artifice: "compact_index_cooldown" + bundle "install --cooldown 0", artifice: "compact_index_v2" expect(out).not_to include("skipped by the cooldown setting") end @@ -259,7 +259,7 @@ gem "ripe_gem", "~> 1.0" G - bundle "install --cooldown 7", artifice: "compact_index_cooldown" + bundle "install --cooldown 7", artifice: "compact_index_v2" expect(out).not_to include("skipped by the cooldown setting") expect(the_bundle).to include_gems("ripe_gem 1.0.0") @@ -271,10 +271,10 @@ gem "ripe_gem" G - bundle "install", artifice: "compact_index_cooldown" + bundle "install", artifice: "compact_index_v2" expect(out).to include("skipped by the cooldown setting") - bundle "install", artifice: "compact_index_cooldown" + bundle "install", artifice: "compact_index_v2" expect(out).not_to include("skipped by the cooldown setting") end @@ -284,7 +284,7 @@ gem "ripe_gem" G - bundle "install", artifice: "compact_index_cooldown" + bundle "install", artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 1.0.0") end @@ -314,7 +314,7 @@ #{Bundler::VERSION} L - bundle "update ripe_gem", artifice: "compact_index_cooldown" + bundle "update ripe_gem", artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 1.0.0") end @@ -342,7 +342,7 @@ #{Bundler::VERSION} L - bundle "install", artifice: "compact_index_cooldown" + bundle "install", artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 1.0.0", "child 1.0.0") end @@ -351,7 +351,7 @@ # https://github.com/rubygems/rubygems/issues/9723: a second declaration # of the same URL is deduped into the first one, so its cooldown cannot # act as a per-gem exemption. - install_gemfile <<-G, artifice: "compact_index_cooldown" + install_gemfile <<-G, artifice: "compact_index_v2" source "https://gem.repo3", cooldown: 7 source "https://gem.repo3", cooldown: 0 do gem "ripe_gem" @@ -363,7 +363,7 @@ end it "does not warn when the same source is declared again without a cooldown" do - install_gemfile <<-G, artifice: "compact_index_cooldown" + install_gemfile <<-G, artifice: "compact_index_v2" source "https://gem.repo3", cooldown: 7 source "https://gem.repo3" do gem "ripe_gem" @@ -375,7 +375,7 @@ end it "does not warn when the same source is declared again with the same cooldown" do - install_gemfile <<-G, artifice: "compact_index_cooldown" + install_gemfile <<-G, artifice: "compact_index_v2" source "https://gem.repo3", cooldown: 7 source "https://gem.repo3", cooldown: 7 do gem "ripe_gem" @@ -392,7 +392,7 @@ gem "ripe_gem" G - bundle "install --cooldown 7", artifice: "compact_index_cooldown" + bundle "install --cooldown 7", artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 1.0.0") end @@ -419,7 +419,7 @@ #{Bundler::VERSION} L - bundle "install --cooldown 7", artifice: "compact_index_cooldown" + bundle "install --cooldown 7", artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 2.0.0") end @@ -446,7 +446,7 @@ #{Bundler::VERSION} L - bundle "outdated --cooldown 7", artifice: "compact_index_cooldown", raise_on_error: false + bundle "outdated --cooldown 7", artifice: "compact_index_v2", raise_on_error: false expect(out).to match(/ripe_gem.*\(cooldown \d+d\)/) end @@ -473,7 +473,7 @@ #{Bundler::VERSION} L - bundle "outdated --cooldown 7 --parseable", artifice: "compact_index_cooldown", raise_on_error: false + bundle "outdated --cooldown 7 --parseable", artifice: "compact_index_v2", raise_on_error: false expect(out).to match(/ripe_gem.*in cooldown for \d+ more day/) end @@ -500,11 +500,11 @@ #{Bundler::VERSION} L - bundle "outdated --cooldown 7 --parseable", artifice: "compact_index_cooldown", raise_on_error: false + bundle "outdated --cooldown 7 --parseable", artifice: "compact_index_v2", raise_on_error: false expect(out).to match(/mid_gem \(newest 2\.0\.0, installed 1\.0\.0.*in cooldown for \d+ more days, newest out of cooldown 1\.5\.0\)/) - bundle "outdated --cooldown 7", artifice: "compact_index_cooldown", raise_on_error: false + bundle "outdated --cooldown 7", artifice: "compact_index_v2", raise_on_error: false expect(out).to match(/mid_gem.*2\.0\.0 \(cooldown \d+d, 1\.5\.0 out of cooldown\)/) end @@ -531,7 +531,7 @@ #{Bundler::VERSION} L - bundle "outdated --strict --cooldown 7 --parseable", artifice: "compact_index_cooldown", raise_on_error: false + bundle "outdated --strict --cooldown 7 --parseable", artifice: "compact_index_v2", raise_on_error: false # in strict mode "newest" is the resolved (cooldown-filtered) version # itself, so the annotations have nothing to add @@ -561,7 +561,7 @@ #{Bundler::VERSION} L - bundle "outdated --cooldown 7 --parseable", artifice: "compact_index_cooldown", raise_on_error: false + bundle "outdated --cooldown 7 --parseable", artifice: "compact_index_v2", raise_on_error: false expect(out).to match(/fresh_gem.*in cooldown for \d+ more day/) expect(out).not_to include("out of cooldown") @@ -590,7 +590,7 @@ L # mid_gem 2.0.0 is one day old, so a two-day window leaves one day - bundle "outdated --cooldown 2 --parseable", artifice: "compact_index_cooldown", raise_on_error: false + bundle "outdated --cooldown 2 --parseable", artifice: "compact_index_v2", raise_on_error: false expect(out).to match(/mid_gem \(newest 2\.0\.0, installed 1\.0\.0.*in cooldown for 1 more day, newest out of cooldown 1\.5\.0\)/) end @@ -617,7 +617,7 @@ #{Bundler::VERSION} L - bundle "outdated --parseable", artifice: "compact_index_cooldown", raise_on_error: false + bundle "outdated --parseable", artifice: "compact_index_v2", raise_on_error: false expect(out).to match(/mid_gem \(newest 2\.0\.0, installed 1\.0\.0/) expect(out).not_to include("cooldown") @@ -631,7 +631,7 @@ gem "ripe_gem" G - bundle "install --cooldown 7", artifice: "compact_index_cooldown" + bundle "install --cooldown 7", artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 1.0.0") end @@ -644,7 +644,7 @@ gem "ripe_gem" G - bundle "install --cooldown 0", artifice: "compact_index_cooldown" + bundle "install --cooldown 0", artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 2.0.0") end @@ -671,7 +671,7 @@ #{Bundler::VERSION} L - bundle "update ripe_gem --cooldown 99999", artifice: "compact_index_cooldown", raise_on_error: false + bundle "update ripe_gem --cooldown 99999", artifice: "compact_index_v2", raise_on_error: false expect(err).to match(/excluded by the cooldown setting/) expect(err).to match(/--cooldown 0/) @@ -702,7 +702,7 @@ #{Bundler::VERSION} L - bundle "update --all --cooldown 7", artifice: "compact_index_cooldown" + bundle "update --all --cooldown 7", artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 2.0.0") end @@ -729,7 +729,7 @@ #{Bundler::VERSION} L - bundle "outdated --cooldown 7", artifice: "compact_index_cooldown", raise_on_error: false + bundle "outdated --cooldown 7", artifice: "compact_index_v2", raise_on_error: false # exit 0 means no outdated gems and, crucially, no resolution failure (exit 7) expect(exitstatus).to eq(0) @@ -757,7 +757,7 @@ #{Bundler::VERSION} L - bundle "update ripe_gem --cooldown 7", artifice: "compact_index_cooldown" + bundle "update ripe_gem --cooldown 7", artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 1.0.0") end @@ -786,7 +786,7 @@ #{Bundler::VERSION} L - bundle "update --all --cooldown 7", artifice: "compact_index_cooldown" + bundle "update --all --cooldown 7", artifice: "compact_index_v2" expect(the_bundle).to include_gems("parent 1.0.0", "child 2.0.0") end @@ -813,7 +813,7 @@ #{Bundler::VERSION} L - bundle "update --all --cooldown 7", artifice: "compact_index_cooldown" + bundle "update --all --cooldown 7", artifice: "compact_index_v2" expect(the_bundle).to include_gems("upgradable 3.0.0") end @@ -859,7 +859,7 @@ #{Bundler::VERSION} L - bundle "update ripe_gem", artifice: "compact_index_cooldown" + bundle "update ripe_gem", artifice: "compact_index_v2" # A partial update converges the still-locked sources, the path that used # to drop cooldown. repo3's cooldown must survive that even with a second @@ -908,7 +908,7 @@ #{Bundler::VERSION} L - bundle "update solo_gem", artifice: "compact_index_cooldown" + bundle "update solo_gem", artifice: "compact_index_v2" # The cooldown lives on the gem-block source, which is also converged from # the lockfile. A partial update of solo_gem must keep that cooldown, so @@ -938,7 +938,7 @@ #{Bundler::VERSION} L - bundle "add child", artifice: "compact_index_cooldown" + bundle "add child", artifice: "compact_index_v2" expect(the_bundle).to include_gems("child 1.0.0") end @@ -965,7 +965,7 @@ #{Bundler::VERSION} L - bundle "lock --update ripe_gem", artifice: "compact_index_cooldown" + bundle "lock --update ripe_gem", artifice: "compact_index_v2" expect(lockfile).to include("ripe_gem (1.0.0)") expect(lockfile).not_to include("ripe_gem (2.0.0)") @@ -993,7 +993,7 @@ #{Bundler::VERSION} L - bundle "lock --update ripe_gem", artifice: "compact_index_cooldown" + bundle "lock --update ripe_gem", artifice: "compact_index_v2" expect(lockfile).to include("ripe_gem (1.0.0)") expect(lockfile).not_to include("ripe_gem (2.0.0)") @@ -1008,7 +1008,7 @@ gem "late_platform" G - bundle "install --cooldown 7", artifice: "compact_index_cooldown" + bundle "install --cooldown 7", artifice: "compact_index_v2" expect(the_bundle).to include_gems("late_platform 1.0.0") end @@ -1019,7 +1019,7 @@ gem "late_platform" G - bundle "install --cooldown 0", artifice: "compact_index_cooldown" + bundle "install --cooldown 0", artifice: "compact_index_v2" # On x86_64-linux hosts this resolves to the platform-specific build, so # assert on the lockfile instead of the installed platform. @@ -1049,7 +1049,7 @@ #{Bundler::VERSION} L - bundle "lock --update --cooldown 7", artifice: "compact_index_cooldown" + bundle "lock --update --cooldown 7", artifice: "compact_index_v2" expect(lockfile).to include("ripe_gem (1.0.0)") expect(lockfile).not_to include("ripe_gem (2.0.0)") @@ -1061,7 +1061,7 @@ gem "ripe_gem" G - bundle "lock --cooldown=-7", artifice: "compact_index_cooldown", raise_on_error: false + bundle "lock --cooldown=-7", artifice: "compact_index_v2", raise_on_error: false expect(err).to match(/non-negative integer/) end @@ -1072,7 +1072,7 @@ gem "ripe_gem" G - bundle "cache --cooldown 7", artifice: "compact_index_cooldown" + bundle "cache --cooldown 7", artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 1.0.0") expect(bundled_app("vendor/cache/ripe_gem-1.0.0.gem")).to exist @@ -1103,7 +1103,7 @@ L bundle "config set frozen true" - bundle "install", artifice: "compact_index_cooldown" + bundle "install", artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 2.0.0") end @@ -1119,7 +1119,7 @@ gem "ripe_gem" G - bundle "install", artifice: "compact_index_cooldown", + bundle "install", artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo3.to_s } expect(the_bundle).to include_gems("ripe_gem 1.0.0") diff --git a/spec/install/gemfile/content_addressable_spec.rb b/spec/install/gemfile/content_addressable_spec.rb new file mode 100644 index 000000000000..52dfca3e5f48 --- /dev/null +++ b/spec/install/gemfile/content_addressable_spec.rb @@ -0,0 +1,409 @@ +# frozen_string_literal: true + +RSpec.describe "bundle install with content-addressable gems", :compact_index, rubygems: ">= 4.1.0.dev" do + before do + skip "Gem::ContentAddress not available" if ruby_core? + end + + let(:current_abi) { "#{Gem.ruby_version.segments[0]}.#{Gem.ruby_version.segments[1]}" } + let(:mismatched_abi) { "#{Gem.ruby_version.segments[0] + 1}.0" } + + it "installs the content-addressed gem when the Ruby ABI matches" do + simulate_platform "x86_64-linux" do + build_repo2 do + build_gem "mygem", "1.0" do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.write "lib/mygem.rb", "MYGEM = '1.0 not_content_addressed'" + end + end + + build_gem "mygem", "1.0", ruby_abi: current_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{current_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed'" + end + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s } + source "https://gem.repo2" + + gem "mygem" + G + + expect(the_bundle).to include_gems "mygem 1.0 content_addressed" + + cached_files = Dir.glob(default_bundle_path("cache", "mygem-1.0-*.gem").to_s) + expect(cached_files.size).to eq(1), "expected exactly one cached gem file, found: #{cached_files}" + expect(cached_files.first).to match(/mygem-1\.0-[0-9a-f]{8,64}\.gem$/) + expect(default_bundle_path("cache", "mygem-1.0-x86_64-linux.gem")).not_to exist + expect(lockfile).to match(/^ mygem \(1\.0-[0-9a-f]{8,64}\) x86_64-linux$/) + + content_address = File.basename(cached_files.first, ".gem").rpartition("-").last + digest = Digest::SHA256.file(cached_files.first).hexdigest + expect(content_address).to eq(digest[0, content_address.length]) + + checksums = checksums_section_when_enabled do |c| + c.checksum(gem_repo2, "mygem", "1.0", "x86_64-linux", content_address: content_address) + end + expect(lockfile).to include(checksums.to_s) + expect(lockfile).to include("mygem (1.0-#{content_address}) sha256=#{digest}") + expect(lockfile).not_to include("mygem (1.0-x86_64-linux)") + end + end + + it "resolves a content-addressed binary from the local cache after a lockfile round-trip" do + simulate_platform "x86_64-linux" do + build_repo2 do + build_gem "mygem", "1.0" do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.write "lib/mygem.rb", "MYGEM = '1.0 not_content_addressed'" + end + end + + build_gem "mygem", "1.0", ruby_abi: current_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{current_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed'" + end + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s } + source "https://gem.repo2" + + gem "mygem" + G + + cached_file = Dir[default_bundle_path("cache", "mygem-1.0-*.gem").to_s].first + FileUtils.mkdir_p(bundled_app("vendor/cache")) + FileUtils.cp(cached_file, bundled_app("vendor/cache")) + + gem_dir = Dir[default_bundle_path("gems", "mygem-1.0-*").to_s].first + pristine_system_gems + bundle "install --local" + + expect(the_bundle).to include_gems "mygem 1.0 content_addressed" + expect(Dir[default_bundle_path("gems", "mygem-1.0-*").to_s].first).to eq(gem_dir) + end + end + + it "falls back to the non-content-addressed gem when the content-addressed gem requires a different Ruby ABI" do + simulate_platform "x86_64-linux" do + build_repo2 do + build_gem "mygem", "1.0" do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.write "lib/mygem.rb", "MYGEM = '1.0 not_content_addressed'" + end + end + + build_gem "mygem", "1.0", ruby_abi: mismatched_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{mismatched_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed'" + end + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s } + source "https://gem.repo2" + + gem "mygem" + G + + expect(the_bundle).to include_gems "mygem 1.0 not_content_addressed" + end + end + + it "does not treat a content-addressed suffix as content-addressable when platform metadata is missing" do + simulate_platform "x86_64-linux" do + build_repo2 do + build_gem "mygem", "1.0" do |s| + s.platform = "abcdef12" + s.write "lib/mygem.rb", "MYGEM = '1.0 hex_platform'" + end + end + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s }, raise_on_error: false + source "https://gem.repo2" + + gem "mygem" + G + + expect(err).to include("Could not find gem 'mygem'") + end + end + + it "falls back to the non-content-addressed gem when the content-addressed gem is for a different platform" do + simulate_platform "x86_64-linux" do + build_repo2 do + build_gem "mygem", "1.0" do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.write "lib/mygem.rb", "MYGEM = '1.0 not_content_addressed'" + end + end + + build_gem "mygem", "1.0", ruby_abi: current_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("arm64-darwin") + s.required_ruby_version = "~> #{current_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed'" + end + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s } + source "https://gem.repo2" + + gem "mygem" + G + + expect(the_bundle).to include_gems "mygem 1.0 not_content_addressed" + end + end + + it "installs the content-addressed gem matching the current platform when multiple platforms are available" do + simulate_platform "x86_64-linux" do + build_repo2 + + build_gem "mygem", "1.0", ruby_abi: current_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{current_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed_linux'" + end + + build_gem "mygem", "1.0", ruby_abi: current_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("arm64-darwin") + s.required_ruby_version = "~> #{current_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed_darwin'" + end + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s } + source "https://gem.repo2" + + gem "mygem" + G + + expect(the_bundle).to include_gems "mygem 1.0 content_addressed_linux" + end + end + + it "falls back to the pure-ruby gem when the content-addressed gem requires a different Ruby ABI" do + simulate_platform "x86_64-linux" do + build_repo2 do + build_gem "mygem", "1.0" do |s| + s.write "lib/mygem.rb", "MYGEM = '1.0 pure_ruby'" + end + end + + build_gem "mygem", "1.0", ruby_abi: mismatched_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{mismatched_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed'" + end + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s } + source "https://gem.repo2" + + gem "mygem" + G + + expect(the_bundle).to include_gems "mygem 1.0 pure_ruby" + end + end + + it "installs the ABI-compatible content-addressed gem when multiple content-addressed gems are available for the same platform" do + simulate_platform "x86_64-linux" do + build_repo2 + + build_gem "mygem", "1.0", ruby_abi: current_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{current_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed_matching_abi'" + end + + build_gem "mygem", "1.0", ruby_abi: mismatched_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{mismatched_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed_mismatched_abi'" + end + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s } + source "https://gem.repo2" + + gem "mygem" + G + + expect(the_bundle).to include_gems "mygem 1.0 content_addressed_matching_abi" + end + end + + it "installs the higher non-content-addressed version over a lower content-addressed version" do + simulate_platform "x86_64-linux" do + build_repo2 do + build_gem "mygem", "2.0" do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.write "lib/mygem.rb", "MYGEM = '2.0 not_content_addressed'" + end + end + + build_gem "mygem", "1.0", ruby_abi: current_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{current_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed'" + end + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s } + source "https://gem.repo2" + + gem "mygem" + G + + expect(the_bundle).to include_gems "mygem 2.0 not_content_addressed" + end + end + + it "falls back to the non-content-addressed gem when all content-addressed gems require a different Ruby ABI" do + simulate_platform "x86_64-linux" do + build_repo2 do + build_gem "mygem", "1.0" do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.write "lib/mygem.rb", "MYGEM = '1.0 not_content_addressed'" + end + end + + build_gem "mygem", "1.0", ruby_abi: mismatched_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{mismatched_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed_mismatched_abi_1'" + end + + second_mismatched_abi = "#{Gem.ruby_version.segments[0] + 2}.0" + build_gem "mygem", "1.0", ruby_abi: second_mismatched_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{second_mismatched_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed_mismatched_abi_2'" + end + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s } + source "https://gem.repo2" + + gem "mygem" + G + + expect(the_bundle).to include_gems "mygem 1.0 not_content_addressed" + end + end + + it "installs a locked content-addressed gem in frozen mode" do + simulate_platform "x86_64-linux" do + build_repo2 do + build_gem "mygem", "1.0" do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.write "lib/mygem.rb", "MYGEM = '1.0 not_content_addressed'" + end + end + + build_gem "mygem", "1.0", ruby_abi: current_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{current_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed'" + end + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s } + source "https://gem.repo2" + + gem "mygem" + G + + expect(the_bundle).to include_gems "mygem 1.0 content_addressed" + + pristine_system_gems + bundle_config "frozen true" + bundle "install", artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s } + + expect(the_bundle).to include_gems "mygem 1.0 content_addressed" + end + end + + it "does not swap a locked content-addressed gem for another artifact in frozen mode" do + simulate_platform "x86_64-linux" do + build_repo2 do + build_gem "mygem", "1.0" do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.write "lib/mygem.rb", "MYGEM = '1.0 not_content_addressed'" + end + end + + build_gem "mygem", "1.0", ruby_abi: current_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{current_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed'" + end + + build_gem "mygem", "1.0", ruby_abi: mismatched_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{mismatched_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 mismatched'" + end + + mismatched_file = Dir[gem_repo2("gems", "mygem-1.0-*.gem").to_s].find do |file| + Gem::Package.new(file).spec.required_ruby_version.to_s == "~> #{mismatched_abi}.0" + end + mismatched_address = File.basename(mismatched_file, ".gem").rpartition("-").last + mismatched_checksum = Digest::SHA256.file(mismatched_file).hexdigest + + gemfile <<~G + source "https://gem.repo2" + + gem "mygem" + G + + lockfile <<~L + GEM + remote: https://gem.repo2/ + specs: + mygem (1.0-#{mismatched_address}) x86_64-linux + + PLATFORMS + x86_64-linux + + DEPENDENCIES + mygem + + CHECKSUMS + mygem (1.0-#{mismatched_address}) sha256=#{mismatched_checksum} + + BUNDLED WITH + #{Bundler::VERSION} + L + + bundle_config "frozen true" + bundle "install", artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s }, raise_on_error: false + + expect(last_command).to be_failure + expect(the_bundle).not_to include_gems "mygem 1.0 content_addressed" + expect(lockfile).to include("mygem (1.0-#{mismatched_address}) x86_64-linux") + end + end + + it "fails when the downloaded content-addressed gem hash does not match the filename" do + simulate_platform "x86_64-linux" do + build_repo2 do + build_gem "mygem", "1.0" do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.write "lib/mygem.rb", "MYGEM = '1.0 not_content_addressed'" + end + end + + build_gem "mygem", "1.0", ruby_abi: current_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{current_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed'" + end + + ca_gem = Dir[gem_repo2("gems", "mygem-1.0-[0-9a-f]*.gem")].first + non_ca_gem = gem_repo2("gems", "mygem-1.0-x86_64-linux.gem") + FileUtils.cp non_ca_gem, ca_gem + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s }, raise_on_error: false + source "https://gem.repo2" + + gem "mygem" + G + + expect(err).to include("content address mismatch") + end + end +end diff --git a/spec/other/ext_spec.rb b/spec/other/ext_spec.rb index a883eefe0667..056371f5eb80 100644 --- a/spec/other/ext_spec.rb +++ b/spec/other/ext_spec.rb @@ -46,5 +46,20 @@ expect(Gem::NameTuple.new("a", v("1.0.0"), "ruby").lock_name).to eq("a (1.0.0)") expect(Gem::NameTuple.new("a", v("1.0.0")).lock_name).to eq("a (1.0.0)") end + + it "uses content_address in the lock name when set" do + expect(Gem::NameTuple.new("a", v("1.0.0"), "x86_64-linux", content_address: "abcdef12").lock_name).to eq("a (1.0.0-abcdef12)") + expect(Gem::NameTuple.new("a", v("1.0.0"), "ruby", content_address: "abcdef12").lock_name).to eq("a (1.0.0-abcdef12)") + end + end +end + +RSpec.describe Bundler::LazySpecification do + describe "#to_lock" do + it "appends the content address after the platform lock name when set" do + spec = Bundler::LazySpecification.new("mygem", v("1.0"), "x86_64-linux", nil, content_address: "abcdef1234") + + expect(spec.to_lock).to eq(" mygem (1.0-abcdef1234) x86_64-linux\n") + end end end diff --git a/spec/support/artifice/compact_index_concurrent_download.rb b/spec/support/artifice/compact_index_concurrent_download.rb index 5d55b8a72b0d..8f448e180874 100644 --- a/spec/support/artifice/compact_index_concurrent_download.rb +++ b/spec/support/artifice/compact_index_concurrent_download.rb @@ -21,7 +21,7 @@ class CompactIndexConcurrentDownload < CompactIndexAPI etag_response do file = tmp("versions.list") FileUtils.rm_f(file) - file = CompactIndex::VersionsFile.new(file.to_s) + file = VendoredCompactIndex::VersionsFile.new(file.to_s) file.create(gems) file.contents end diff --git a/spec/support/artifice/compact_index_cooldown.rb b/spec/support/artifice/compact_index_cooldown.rb deleted file mode 100644 index 85e3173c989c..000000000000 --- a/spec/support/artifice/compact_index_cooldown.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require_relative "helpers/compact_index_cooldown" -require_relative "helpers/artifice" - -Artifice.activate_with(CompactIndexCooldownAPI) diff --git a/spec/support/artifice/compact_index_partial_update_bad_digest.rb b/spec/support/artifice/compact_index_partial_update_bad_digest.rb index ac04336636f4..e5c9cf96f42e 100644 --- a/spec/support/artifice/compact_index_partial_update_bad_digest.rb +++ b/spec/support/artifice/compact_index_partial_update_bad_digest.rb @@ -21,7 +21,7 @@ def partial_update_bad_digest partial_update_bad_digest do file = tmp("versions.list") FileUtils.rm_f(file) - file = CompactIndex::VersionsFile.new(file.to_s) + file = VendoredCompactIndex::VersionsFile.new(file.to_s) file.create(gems) file.contents([], calculate_info_checksums: true) end @@ -30,7 +30,7 @@ def partial_update_bad_digest get "/info/:name" do partial_update_bad_digest do gem = gems.find {|g| g.name == params[:name] } - CompactIndex.info(gem ? gem.versions : []) + VendoredCompactIndex.info(gem ? gem.versions : []) end end end diff --git a/spec/support/artifice/compact_index_partial_update_no_digest_not_incremental.rb b/spec/support/artifice/compact_index_partial_update_no_digest_not_incremental.rb index 99bae039f0f4..6c587059a1e7 100644 --- a/spec/support/artifice/compact_index_partial_update_no_digest_not_incremental.rb +++ b/spec/support/artifice/compact_index_partial_update_no_digest_not_incremental.rb @@ -16,7 +16,7 @@ def partial_update_no_digest partial_update_no_digest do file = tmp("versions.list") FileUtils.rm_f(file) - file = CompactIndex::VersionsFile.new(file.to_s) + file = VendoredCompactIndex::VersionsFile.new(file.to_s) file.create(gems) lines = file.contents([], calculate_info_checksums: true).split("\n") name, versions, checksum = lines.last.split(" ") @@ -29,7 +29,7 @@ def partial_update_no_digest get "/info/:name" do partial_update_no_digest do gem = gems.find {|g| g.name == params[:name] } - lines = CompactIndex.info(gem ? gem.versions : []).split("\n") + lines = VendoredCompactIndex.info(gem ? gem.versions : []).split("\n") # shuffle versions so new versions are not appended to the end [lines.first, lines.last, *lines[1..-2]].join("\n") diff --git a/spec/support/artifice/compact_index_precompiled_before.rb b/spec/support/artifice/compact_index_precompiled_before.rb index b5f72f546a6f..da7440ce98ff 100644 --- a/spec/support/artifice/compact_index_precompiled_before.rb +++ b/spec/support/artifice/compact_index_precompiled_before.rb @@ -6,7 +6,7 @@ class CompactIndexPrecompiledBefore < CompactIndexAPI get "/info/:name" do etag_response do gem = gems.find {|g| g.name == params[:name] } - move_ruby_variant_to_the_end(CompactIndex.info(gem ? gem.versions : [])) + move_ruby_variant_to_the_end(VendoredCompactIndex.info(gem ? gem.versions : [])) end end diff --git a/spec/support/artifice/compact_index_range_ignored.rb b/spec/support/artifice/compact_index_range_ignored.rb index 2303682c1f21..37e23549ac24 100644 --- a/spec/support/artifice/compact_index_range_ignored.rb +++ b/spec/support/artifice/compact_index_range_ignored.rb @@ -28,7 +28,7 @@ def not_modified?(_checksum) etag_response do file = tmp("versions.list") FileUtils.rm_f(file) - file = CompactIndex::VersionsFile.new(file.to_s) + file = VendoredCompactIndex::VersionsFile.new(file.to_s) file.create(gems) file.contents end diff --git a/spec/support/artifice/compact_index_rate_limited.rb b/spec/support/artifice/compact_index_rate_limited.rb index 449549163504..eebe991acddc 100644 --- a/spec/support/artifice/compact_index_rate_limited.rb +++ b/spec/support/artifice/compact_index_rate_limited.rb @@ -32,7 +32,7 @@ def self.deq if RequestCounter.size == 1 etag_response do gem = gems.find {|g| g.name == params[:name] } - CompactIndex.info(gem ? gem.versions : []) + VendoredCompactIndex.info(gem ? gem.versions : []) end else status 429 diff --git a/spec/support/artifice/compact_index_v2.rb b/spec/support/artifice/compact_index_v2.rb new file mode 100644 index 000000000000..830a32e1c661 --- /dev/null +++ b/spec/support/artifice/compact_index_v2.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +require_relative "helpers/compact_index_v2" +require_relative "helpers/artifice" + +Artifice.activate_with(CompactIndexV2API) diff --git a/spec/support/artifice/compact_index_wrong_dependencies.rb b/spec/support/artifice/compact_index_wrong_dependencies.rb index 15850599b6ad..962fcd29efad 100644 --- a/spec/support/artifice/compact_index_wrong_dependencies.rb +++ b/spec/support/artifice/compact_index_wrong_dependencies.rb @@ -7,7 +7,7 @@ class CompactIndexWrongDependencies < CompactIndexAPI etag_response do gem = gems.find {|g| g.name == params[:name] } gem.versions.each {|gv| gv.dependencies.clear } if gem - CompactIndex.info(gem ? gem.versions : []) + VendoredCompactIndex.info(gem ? gem.versions : []) end end end diff --git a/spec/support/artifice/compact_index_wrong_gem_checksum.rb b/spec/support/artifice/compact_index_wrong_gem_checksum.rb index 9bd2ca0a9d6d..9a212d1a47a7 100644 --- a/spec/support/artifice/compact_index_wrong_gem_checksum.rb +++ b/spec/support/artifice/compact_index_wrong_gem_checksum.rb @@ -11,7 +11,7 @@ class CompactIndexWrongGemChecksum < CompactIndexAPI checksum = ENV.fetch("BUNDLER_SPEC_#{name.upcase}_CHECKSUM") { "IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiI=" } versions = gem ? gem.versions : [] versions.each {|v| v.checksum = checksum } - CompactIndex.info(versions) + VendoredCompactIndex.info(versions) end end end diff --git a/spec/support/artifice/helpers/compact_index.rb b/spec/support/artifice/helpers/compact_index.rb index e684aa862879..de3d57f5fac7 100644 --- a/spec/support/artifice/helpers/compact_index.rb +++ b/spec/support/artifice/helpers/compact_index.rb @@ -1,9 +1,8 @@ # frozen_string_literal: true require_relative "endpoint" +require_relative "../../vendored_compact_index" -$LOAD_PATH.unshift Spec::Path.tmp_root.join("compact_index/lib").to_s -require "compact_index" require "digest" class CompactIndexAPI < Endpoint @@ -81,31 +80,31 @@ def gems(gem_repo = default_gem_repo) gem_versions = versions.map do |spec| deps = spec.runtime_dependencies.map do |d| reqs = d.requirement.requirements.map {|r| r.join(" ") }.join(", ") - CompactIndex::Dependency.new(d.name, reqs) + VendoredCompactIndex::Dependency.new(d.name, reqs) end begin checksum = ENV.fetch("BUNDLER_SPEC_#{name.upcase}_CHECKSUM") do - Digest(:SHA256).file("#{gem_repo}/gems/#{spec.original_name}.gem").hexdigest + Digest(:SHA256).file("#{gem_repo}/gems/#{spec.full_name}.gem").hexdigest end rescue StandardError checksum = nil end build_gem_version(spec, deps, checksum) end - CompactIndex::Gem.new(name, gem_versions) + VendoredCompactIndex::Gem.new(name, gem_versions) end end end def build_gem_version(spec, deps, checksum) - CompactIndex::GemVersion.new(spec.version.version, spec.platform.to_s, checksum, nil, + VendoredCompactIndex::GemVersionV2.new(spec.version.version, spec.platform.to_s, checksum, nil, deps, spec.required_ruby_version.to_s, spec.required_rubygems_version.to_s) end end get "/names" do etag_response do - CompactIndex.names(gems.map(&:name)) + VendoredCompactIndex.names(gems.map(&:name)) end end @@ -113,7 +112,7 @@ def build_gem_version(spec, deps, checksum) etag_response do file = tmp("versions.list") FileUtils.rm_f(file) - file = CompactIndex::VersionsFile.new(file.to_s) + file = VendoredCompactIndex::VersionsFile.new(file.to_s) file.create(gems) file.contents end @@ -122,7 +121,7 @@ def build_gem_version(spec, deps, checksum) get "/info/:name" do etag_response do gem = gems.find {|g| g.name == params[:name] } - CompactIndex.info(gem ? gem.versions : []) + VendoredCompactIndex.info(gem ? gem.versions : []) end end end diff --git a/spec/support/artifice/helpers/compact_index_cooldown.rb b/spec/support/artifice/helpers/compact_index_cooldown.rb deleted file mode 100644 index 9920fd2c9520..000000000000 --- a/spec/support/artifice/helpers/compact_index_cooldown.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -require_relative "compact_index" - -class CompactIndexCooldownAPI < CompactIndexAPI - helpers do - def build_gem_version(spec, deps, checksum) - created_at = spec.date&.utc&.iso8601 - CompactIndex::GemVersionV2.new(spec.version.version, spec.platform.to_s, checksum, nil, - deps, spec.required_ruby_version.to_s, spec.required_rubygems_version.to_s, created_at) - end - end -end diff --git a/spec/support/artifice/helpers/compact_index_extra_api.rb b/spec/support/artifice/helpers/compact_index_extra_api.rb index d9a7d83d2340..af5dfe93e4f9 100644 --- a/spec/support/artifice/helpers/compact_index_extra_api.rb +++ b/spec/support/artifice/helpers/compact_index_extra_api.rb @@ -5,7 +5,7 @@ class CompactIndexExtraApi < CompactIndexAPI get "/extra/names" do etag_response do - CompactIndex.names(gems(gem_repo4).map(&:name)) + VendoredCompactIndex.names(gems(gem_repo4).map(&:name)) end end @@ -13,7 +13,7 @@ class CompactIndexExtraApi < CompactIndexAPI etag_response do file = tmp("versions.list") FileUtils.rm_f(file) - file = CompactIndex::VersionsFile.new(file.to_s) + file = VendoredCompactIndex::VersionsFile.new(file.to_s) file.create(gems(gem_repo4)) file.contents end @@ -22,7 +22,7 @@ class CompactIndexExtraApi < CompactIndexAPI get "/extra/info/:name" do etag_response do gem = gems(gem_repo4).find {|g| g.name == params[:name] } - CompactIndex.info(gem ? gem.versions : []) + VendoredCompactIndex.info(gem ? gem.versions : []) end end diff --git a/spec/support/artifice/helpers/compact_index_v2.rb b/spec/support/artifice/helpers/compact_index_v2.rb new file mode 100644 index 000000000000..116a1e39e1d5 --- /dev/null +++ b/spec/support/artifice/helpers/compact_index_v2.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require_relative "compact_index" + +class CompactIndexV2API < CompactIndexAPI + helpers do + def build_gem_version(spec, deps, checksum) + created_at = spec.date&.utc&.iso8601 + # The system-RubyGems jobs run this artifice against a Gem::Specification + # that predates the content-addressable accessors. + ruby_abi = spec.ruby_abi if spec.respond_to?(:ruby_abi) + content_address = spec.content_address if spec.respond_to?(:content_address) + VendoredCompactIndex::GemVersionV2.new(spec.version.version, spec.platform.to_s, checksum, nil, + deps, spec.required_ruby_version.to_s, spec.required_rubygems_version.to_s, created_at, + ruby_abi, content_address) + end + + def content_addressable_specs(gem_repo) + Dir.glob(File.join(gem_repo, "gems", "*.gem")).filter_map do |file| + token = File.basename(file, ".gem").rpartition("-").last + next unless Gem::ContentAddress.match?(token) + + spec = Gem::Package.new(file).spec + next unless Gem::ContentAddress.applicable?(spec) + spec.content_address = token + spec + end + end + end + + def gems(gem_repo = default_gem_repo) + all_gems = super + ca_specs = content_addressable_specs(gem_repo) + ca_specs.group_by(&:name).each do |name, versions| + gem = all_gems.find {|g| g.name == name } + new_versions = versions.map do |spec| + deps = spec.runtime_dependencies.map do |d| + reqs = d.requirement.requirements.map {|r| r.join(" ") }.join(", ") + VendoredCompactIndex::Dependency.new(d.name, reqs) + end + begin + checksum = Digest(:SHA256).file("#{gem_repo}/gems/#{spec.full_name}.gem").hexdigest + rescue StandardError + checksum = nil + end + build_gem_version(spec, deps, checksum) + end + if gem + gem.versions.concat(new_versions) + else + all_gems << VendoredCompactIndex::Gem.new(name, new_versions) + end + end + all_gems + end +end diff --git a/spec/support/builders.rb b/spec/support/builders.rb index 43ab7e053dfb..4204b93d3eca 100644 --- a/spec/support/builders.rb +++ b/spec/support/builders.rb @@ -659,17 +659,20 @@ def _build(opts) destination = opts[:path] || _default_path FileUtils.mkdir_p(lib_path.join(destination)) - if [:yaml, false].include?(opts[:gemspec]) - Dir.chdir(lib_path) do - Bundler.rubygems.build(@spec, opts[:skip_validation]) + built_gem = + if opts[:ruby_abi] + Dir.chdir(lib_path) { Gem::Package.build(@spec, false, false, nil, opts[:ruby_abi]) } + elsif [:yaml, false].include?(opts[:gemspec]) + Dir.chdir(lib_path) do + Bundler.rubygems.build(@spec, opts[:skip_validation]) + end + elsif opts[:skip_validation] + Dir.chdir(lib_path) { Gem::Package.build(@spec, true) } + else + Dir.chdir(lib_path) { Gem::Package.build(@spec) } end - elsif opts[:skip_validation] - Dir.chdir(lib_path) { Gem::Package.build(@spec, true) } - else - Dir.chdir(lib_path) { Gem::Package.build(@spec) } - end - gem_path = File.expand_path("#{@spec.full_name}.gem", lib_path) + gem_path = File.expand_path(built_gem || "#{@spec.full_name}.gem", lib_path) if opts[:to_system] @context.system_gems gem_path, default: opts[:default] elsif opts[:to_bundle] diff --git a/spec/support/checksums.rb b/spec/support/checksums.rb index 7b69bba6680b..638c9cf421f3 100644 --- a/spec/support/checksums.rb +++ b/spec/support/checksums.rb @@ -16,18 +16,18 @@ def initialize_copy(original) @checksums = @checksums.dup end - def checksum(repo, name, version, platform = Gem::Platform::RUBY, folder = "gems") + def checksum(repo, name, version, platform = Gem::Platform::RUBY, folder = "gems", content_address: nil) @bundler_registered = true if name == "bundler" - name_tuple = Gem::NameTuple.new(name, version, platform) + name_tuple = Gem::NameTuple.new(name, version, platform, content_address: content_address) gem_file = File.join(repo, folder, "#{name_tuple.full_name}.gem") File.open(gem_file, "rb") do |f| register(name_tuple, Bundler::Checksum.from_gem(f, "#{gem_file} (via ChecksumsBuilder#checksum)")) end end - def no_checksum(name, version, platform = Gem::Platform::RUBY) - name_tuple = Gem::NameTuple.new(name, version, platform) + def no_checksum(name, version, platform = Gem::Platform::RUBY, content_address: nil) + name_tuple = Gem::NameTuple.new(name, version, platform, content_address: content_address) register(name_tuple, nil) end diff --git a/spec/support/rubygems_ext.rb b/spec/support/rubygems_ext.rb index 812dc4deaa9c..cf639a660a04 100644 --- a/spec/support/rubygems_ext.rb +++ b/spec/support/rubygems_ext.rb @@ -73,48 +73,6 @@ def install_test_deps require_relative "helpers" Helpers.install_dev_bundler - - install_vendored_compact_index - end - - # Vendor `rubygems/rubygems.org#lib/compact_index/` under `tmp/compact_index/` - # so the artifice can serve compact-index responses without a runtime gem - # dependency. Pinned to a reviewed commit; override with COMPACT_INDEX_REF - # to refresh against another ref (the existing vendor copy is discarded). - def install_vendored_compact_index - target_root = Path.tmp_root.join("compact_index") - require "fileutils" - FileUtils.mkdir_p(Path.tmp_root) - - files = %w[ - lib/compact_index.rb - lib/compact_index/dependency.rb - lib/compact_index/gem.rb - lib/compact_index/gem_version.rb - lib/compact_index/versions_file.rb - ] - - # Serialize installs so parallel test setups don't race on the same - # vendor tree, and only skip the download when every file is present so - # an interrupted run can't leave a partial copy behind. - File.open(Path.tmp_root.join("compact_index.lock"), File::CREAT | File::RDWR) do |lock| - lock.flock(File::LOCK_EX) - - FileUtils.rm_rf(target_root) if ENV["COMPACT_INDEX_REF"] - - next if files.all? {|path| File.exist?(target_root.join(path)) } - - require "open-uri" - ref = ENV["COMPACT_INDEX_REF"] || "7c68a7b39761c61a66f9299f85b889ec39afc02c" - files.each do |path| - url = "https://raw.githubusercontent.com/rubygems/rubygems.org/#{ref}/#{path}" - target = target_root.join(path) - FileUtils.mkdir_p(File.dirname(target)) - tmp = "#{target}.tmp" - File.write(tmp, URI.parse(url).open(&:read)) - File.rename(tmp, target) - end - end end def check_source_control_changes(success_message:, error_message:) diff --git a/spec/support/vendor/compact_index/lib/compact_index.rb b/spec/support/vendor/compact_index/lib/compact_index.rb new file mode 100644 index 000000000000..e132143aec39 --- /dev/null +++ b/spec/support/vendor/compact_index/lib/compact_index.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# Vendored from https://github.com/rubygems/compact_index v0.15.0 + +require_relative "compact_index/gem" +require_relative "compact_index/gem_version" +require_relative "compact_index/dependency" + +require_relative "compact_index/versions_file" + +module VendoredCompactIndex + def self.names(gem_names) + gem_names.join("\n").prepend("---\n") << "\n" + end + + def self.versions(versions_file, gems = nil, args = {}) + versions_file.contents(gems, args) + end + + def self.info(versions) + versions.inject(+"---\n") do |output, version| + output << version.to_line << "\n" + end + end +end diff --git a/spec/support/vendor/compact_index/lib/compact_index/dependency.rb b/spec/support/vendor/compact_index/lib/compact_index/dependency.rb new file mode 100644 index 000000000000..3c67e4f9b838 --- /dev/null +++ b/spec/support/vendor/compact_index/lib/compact_index/dependency.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module VendoredCompactIndex + Dependency = Struct.new(:gem, :version, :platform, :checksum) do + def version_and_platform + if platform.nil? || platform == "ruby" + version + else + "#{version}-#{platform}" + end + end + end +end diff --git a/spec/support/vendor/compact_index/lib/compact_index/gem.rb b/spec/support/vendor/compact_index/lib/compact_index/gem.rb new file mode 100644 index 000000000000..bfe6150ec59b --- /dev/null +++ b/spec/support/vendor/compact_index/lib/compact_index/gem.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module VendoredCompactIndex + Gem = Struct.new(:name, :versions) do + def <=>(other) + name <=> other.name + end + end +end diff --git a/spec/support/vendor/compact_index/lib/compact_index/gem_version.rb b/spec/support/vendor/compact_index/lib/compact_index/gem_version.rb new file mode 100644 index 000000000000..192eb4d7a828 --- /dev/null +++ b/spec/support/vendor/compact_index/lib/compact_index/gem_version.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module VendoredCompactIndex + module GemVersionMethods + def version_token + return "#{number}-#{content_address}" if content_address? + + if platform.nil? || platform == "ruby" + number + else + "#{number}-#{platform}" + end + end + + def <=>(other) + number_comp = number <=> other.number + + if number_comp.zero? + [platform, ruby_abi, content_address].compact <=> + [other.platform, other.ruby_abi, other.content_address].compact + else + number_comp + end + end + + def to_line + line = "#{version_token} #{deps_line}|checksum:#{checksum}" + line << ",ruby:#{ruby_version_line}" if ruby_version && ruby_version != ">= 0" + line << ",rubygems:#{rubygems_version_line}" if rubygems_version && rubygems_version != ">= 0" + line << ",platform:= #{platform}" if content_address? + line + end + + private + + def content_address? + !content_address.nil? && !content_address.empty? + end + + def ruby_version_line + join_multiple(ruby_version) + end + + def rubygems_version_line + join_multiple(rubygems_version) + end + + def deps_line + return "" if dependencies.nil? + + dependencies.map do |d| + [d[:gem], join_multiple(d.version_and_platform)].join(":") + end.join(",") + end + + def join_multiple(requirements) + requirements = requirements.split(", ") + requirements.sort! + requirements.join("&") + end + end + + GemVersionV2 = Struct.new(:number, :platform, :checksum, :info_checksum, + :dependencies, :ruby_version, :rubygems_version, + :created_at, :ruby_abi, :content_address) do + include GemVersionMethods + + def to_line + line = super + line << ",created_at:#{created_at}" if created_at + line + end + end +end diff --git a/spec/support/vendor/compact_index/lib/compact_index/versions_file.rb b/spec/support/vendor/compact_index/lib/compact_index/versions_file.rb new file mode 100644 index 000000000000..3e1c65f4215a --- /dev/null +++ b/spec/support/vendor/compact_index/lib/compact_index/versions_file.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "time" +require "date" +require "digest" + +module VendoredCompactIndex + class VersionsFile + def initialize(file = nil) + @path = file || "/versions.list" + end + + def contents(gems = nil, args = {}) + gems = calculate_info_checksums(gems) if args.delete(:calculate_info_checksums) { false } + + raise ArgumentError, "Unknown options: #{args.keys.join(', ')}" unless args.empty? + + File.read(@path).tap do |out| + out << gem_lines(gems) if gems + end + end + + def updated_at + created_at_header(@path) || Time.at(0).utc.to_datetime + end + + def create(gems, timestamp = Time.now.iso8601) + gems.sort! + create_from_sorted(gems, timestamp) + end + + def create_from_sorted(gems, timestamp = Time.now.iso8601) + File.open(@path, "w") do |io| + io.write "created_at: #{timestamp}\n---\n" + write_gem_lines(io, gems) + end + end + + private + + def gem_lines(gems) + lines = +"" + write_gem_lines(lines, gems) + lines + end + + def write_gem_lines(io, gems) + gems.each do |gem| + version_numbers = gem.versions.map(&:version_token).join(",") + io << gem.name << + " " << version_numbers << + " #{gem.versions.last.info_checksum}\n" + end + end + + def calculate_info_checksums(gems) + gems.each do |gem| + info_checksum = Digest::MD5.hexdigest(VendoredCompactIndex.info(gem[:versions])) + gem[:versions].last[:info_checksum] = info_checksum + end + end + + def created_at_header(path) + return unless File.exist? path + + File.open(path) do |file| + file.each_line do |line| + line.match(/created_at: (.*)\n|---\n/) do |match| + return match[1] && DateTime.parse(match[1]) + end + end + end + + nil + end + end +end diff --git a/spec/support/vendored_compact_index.rb b/spec/support/vendored_compact_index.rb new file mode 100644 index 000000000000..dcf0e5cd3e44 --- /dev/null +++ b/spec/support/vendored_compact_index.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# Bundler specs load this code from the compact-index artifice, including +# inside spawned Bundler processes, so it must have no side effects beyond +# defining the VendoredCompactIndex constants; in particular it must not +# touch $LOAD_PATH or load any other spec setup code. +# +# The vendored copy under spec/support/vendor/compact_index/ is rubygems.org's +# `lib/compact_index/`, renamed to the VendoredCompactIndex namespace so it can +# never collide with the V1-only CompactIndex constants that +# rubygems-generate_index loads for Gem::Indexer. Refresh it with +# `rake vendor:compact_index`. +# +require_relative "vendor/compact_index/lib/compact_index" unless defined?(VendoredCompactIndex) diff --git a/test/rubygems/helper.rb b/test/rubygems/helper.rb index 1fe8577b4fc9..1f45375c4ab9 100644 --- a/test/rubygems/helper.rb +++ b/test/rubygems/helper.rb @@ -868,7 +868,7 @@ def quick_gem(name, version = "2") # Builds a gem from +spec+ and places it in File.join @gemhome, # 'cache'. Automatically creates files based on +spec.files+ - def util_build_gem(spec) + def util_build_gem(spec, ruby_abi: nil) dir = spec.gem_dir FileUtils.mkdir_p dir @@ -882,12 +882,14 @@ def util_build_gem(spec) end end + built_gem_name = nil use_ui Gem::MockGemUi.new do - Gem::Package.build spec + built_gem_name = Gem::Package.build spec, false, false, nil, ruby_abi end - cache = spec.cache_file + cache = File.join File.dirname(spec.cache_file), File.basename(built_gem_name) FileUtils.mv File.basename(cache), cache + cache end end @@ -1003,13 +1005,32 @@ def util_spec(name, version = 2, deps = nil, *files) # :yields: specification spec end + ## + # Creates a content-addressable spec for compact index testing. Requires + # either +ruby_abi+ (sets +required_ruby_version+ to "~> X.Y.0") or an + # explicit +required_ruby_version+. No gem file is built. + + def util_ca_spec(name, version, content_address, ruby_abi: nil, platform: "x86_64-linux", required_ruby_version: nil, &block) + unless ruby_abi || required_ruby_version + raise ArgumentError, "util_ca_spec requires either ruby_abi or required_ruby_version" + end + + util_spec(name, version) do |s| + s.platform = Gem::Platform.new(platform) + s.content_address = content_address + s.required_ruby_version = required_ruby_version || "~> #{ruby_abi}.0" + yield(s) if block + end + end + ## # Creates a gem with +name+, +version+ and +deps+. The specification will - # be yielded before gem creation for customization. The gem will be placed - # in File.join @tempdir, 'gems'. The specification and .gem file - # location are returned. + # be yielded before gem creation for customization. When +ruby_abi+ is set, + # the gem is built using a content-addressable file name for that Ruby ABI. + # The gem will be placed in File.join @tempdir, 'gems'. The + # specification and .gem file location are returned. - def util_gem(name, version, deps = nil, &block) + def util_gem(name, version, deps = nil, ruby_abi: nil, &block) if deps block = proc do |s| deps.keys.each do |n| @@ -1020,11 +1041,11 @@ def util_gem(name, version, deps = nil, &block) spec = quick_gem(name, version, &block) - util_build_gem spec + built_gem_path = util_build_gem spec, ruby_abi: ruby_abi - cache_file = File.join @tempdir, "gems", "#{spec.original_name}.gem" + cache_file = File.join @tempdir, "gems", File.basename(built_gem_path) FileUtils.mkdir_p File.dirname cache_file - FileUtils.mv spec.cache_file, cache_file + FileUtils.mv built_gem_path, cache_file FileUtils.rm spec.spec_file spec.loaded_from = nil @@ -1032,6 +1053,31 @@ def util_gem(name, version, deps = nil, &block) [spec, cache_file] end + ## + # Builds a platform gem and serves it through compact index as a + # content-addressable gem. Returns the specification, gem path, and content + # address. + + def util_setup_content_addressable_compact_index_gem(name, version, platform: "x86_64-linux", required_ruby_version: ">= 3.0", &block) + spec, gem_path = util_gem(name, version) do |s| + s.platform = platform + s.required_ruby_version = required_ruby_version + yield(s) if block + end + + content_address = Digest::SHA256.file(gem_path).hexdigest[0, 8] + ca_gem_path = File.join(File.dirname(gem_path), "#{spec.name}-#{spec.version}-#{content_address}.gem") + FileUtils.cp gem_path, ca_gem_path + spec.content_address = content_address + + util_setup_compact_index spec + @fetcher.data["#{@gem_repo}quick/Marshal.#{Gem.marshal_version}/#{spec.full_name}.gemspec.rz"] = util_zip(Marshal.dump(spec)) + add_to_fetcher spec, ca_gem_path + Gem::SpecFetcher.fetcher = nil + + [spec, ca_gem_path, content_address] + end + ## # Gzips +data+. @@ -1221,7 +1267,7 @@ def util_setup_compact_index(*specs, created_at: {}) info_body << util_compact_index_info_line(spec, created_at[spec.original_name]) << "\n" end - versions_list = by_name[name].map {|spec| spec.original_name.delete_prefix("#{spec.name}-") }.join(",") + versions_list = by_name[name].map {|spec| spec.content_address ? "#{spec.version}-#{spec.content_address}" : spec.original_name.delete_prefix("#{spec.name}-") }.join(",") versions_body << "#{name} #{versions_list} #{Digest::MD5.hexdigest(info_body)}\n" names_body << "#{name}\n" @@ -1243,7 +1289,11 @@ def util_setup_compact_index(*specs, created_at: {}) # A compact index info file line for +spec+, including v2 metadata. def util_compact_index_info_line(spec, created_at = nil) - version = spec.original_name.delete_prefix("#{spec.name}-") + version = if spec.content_address + "#{spec.version}-#{spec.content_address}" + else + spec.original_name.delete_prefix("#{spec.name}-") + end dependencies = spec.runtime_dependencies.map do |dependency| "#{dependency.name}:#{util_compact_index_requirement(dependency.requirement)}" @@ -1256,6 +1306,9 @@ def util_compact_index_info_line(spec, created_at = nil) unless spec.required_rubygems_version.nil? || spec.required_rubygems_version.none? metadata << ",rubygems:#{util_compact_index_requirement(spec.required_rubygems_version)}" end + if spec.content_address + metadata << ",platform:= #{spec.platform}" + end metadata << ",created_at:#{created_at}" if created_at "#{version} #{dependencies}|#{metadata}" diff --git a/test/rubygems/test_gem_commands_build_command.rb b/test/rubygems/test_gem_commands_build_command.rb index cd88421c0754..2214e6e2ce57 100644 --- a/test/rubygems/test_gem_commands_build_command.rb +++ b/test/rubygems/test_gem_commands_build_command.rb @@ -28,7 +28,7 @@ def setup @cmd = Gem::Commands::BuildCommand.new end - def test_handle_options + def test_handle_options_force_strict_platform @cmd.handle_options %w[--force --strict] assert @cmd.options[:force] @@ -37,6 +37,41 @@ def test_handle_options assert_includes Gem.platforms, Gem::Platform.local end + def test_options_ruby_abi + gem = util_spec "platformed_gem" do |s| + s.license = "AGPL-3.0-only" + s.files = ["README.md"] + s.platform = "arm64-darwin" + s.required_ruby_version = "~> 3.4.0" + end + + gemspec_file = File.join(@tempdir, gem.spec_name) + + File.open gemspec_file, "w" do |gs| + gs.write gem.to_ruby + end + + @cmd.handle_options [gemspec_file, "--ruby-abi", "3.4"] + assert_equal "3.4", @cmd.options[:ruby_abi] + + use_ui @ui do + Dir.chdir @tempdir do + @cmd.execute + end + end + + files = Dir[File.join(@tempdir, "platformed_gem-2-*.gem")] + assert_equal 1, files.size + assert_match(/\Aplatformed_gem-2-[0-9a-f]{8}\.gem\z/, File.basename(files.first)) + + output = @ui.output.split "\n" + assert_equal " Successfully built RubyGem", output.shift + assert_equal " Name: platformed_gem", output.shift + assert_equal " Version: 2", output.shift + assert_match(/\A File: platformed_gem-2-[0-9a-f]{8}\.gem\z/, output.shift) + assert_equal [], output + end + def test_options_filename gemspec_file = File.join(@tempdir, @gem.spec_name) @@ -70,6 +105,7 @@ def test_handle_options_defaults refute @cmd.options[:force] refute @cmd.options[:strict] assert_nil @cmd.options[:output] + assert_nil @cmd.options[:ruby_abi] end def test_execute @@ -84,6 +120,131 @@ def test_execute util_test_build_gem @gem end + def test_ruby_abi_rejects_ruby_platform + gem = util_spec "some_gem" do |s| + s.license = "AGPL-3.0-only" + s.files = ["README.md"] + end + + gemspec_file = File.join(@tempdir, gem.spec_name) + File.open gemspec_file, "w" do |gs| + gs.write gem.to_ruby + end + + @cmd.handle_options [gemspec_file, "--ruby-abi", "3.4"] + error = assert_raise(ArgumentError) do + use_ui @ui do + Dir.chdir @tempdir do + @cmd.execute + end + end + end + assert_match(/no platform or a Ruby platform has been set/, error.message) + end + + def test_ruby_abi_rejects_mismatched_required_ruby_version + gem = util_spec "platformed_gem" do |s| + s.license = "AGPL-3.0-only" + s.files = ["README.md"] + s.platform = "arm64-darwin" + s.required_ruby_version = "~> 3.3.0" + end + + gemspec_file = File.join(@tempdir, gem.spec_name) + File.open gemspec_file, "w" do |gs| + gs.write gem.to_ruby + end + + @cmd.handle_options [gemspec_file, "--ruby-abi", "3.4"] + error = assert_raise(ArgumentError) do + use_ui @ui do + Dir.chdir @tempdir do + @cmd.execute + end + end + end + assert_match(/Cannot build gem for Ruby ABI 3\.4 because required_ruby_version/, error.message) + end + + def test_ruby_abi_defaults_required_ruby_version_when_unset + gem = util_spec "platformed_gem" do |s| + s.license = "AGPL-3.0-only" + s.files = ["README.md"] + s.platform = "arm64-darwin" + end + + gemspec_file = File.join(@tempdir, gem.spec_name) + File.open gemspec_file, "w" do |gs| + gs.write gem.to_ruby + end + + @cmd.handle_options [gemspec_file, "--ruby-abi", "3.4"] + use_ui @ui do + Dir.chdir @tempdir do + @cmd.execute + end + end + + files = Dir[File.join(@tempdir, "platformed_gem-2-*.gem")] + assert_equal 1, files.size + spec = Gem::Package.new(files.first).spec + assert_equal Gem::Requirement.new("~> 3.4.0"), spec.required_ruby_version + end + + def test_ruby_abi_produces_deterministic_content_address + gemspec = lambda do + gem = util_spec "platformed_gem" do |s| + s.license = "AGPL-3.0-only" + s.files = ["README.md"] + s.platform = "arm64-darwin" + s.required_ruby_version = "~> 3.4.0" + end + + gemspec_file = File.join(@tempdir, gem.spec_name) + File.open gemspec_file, "w" do |gs| + gs.write gem.to_ruby + end + + @cmd.handle_options [gemspec_file, "--ruby-abi", "3.4"] + use_ui @ui do + Dir.chdir @tempdir do + @cmd.execute + end + end + + Dir[File.join(@tempdir, "platformed_gem-2-*.gem")].first + end + + first_build = gemspec.call + second_build = gemspec.call + + assert_equal File.basename(first_build), File.basename(second_build) + end + + def test_ruby_abi_with_output_raises + gem = util_spec "platformed_gem" do |s| + s.license = "AGPL-3.0-only" + s.files = ["README.md"] + s.platform = "arm64-darwin" + s.required_ruby_version = "~> 3.4.0" + end + + gemspec_file = File.join(@tempdir, gem.spec_name) + File.open gemspec_file, "w" do |gs| + gs.write gem.to_ruby + end + + @cmd.handle_options [gemspec_file, "--ruby-abi", "3.4", "--output", "test.gem"] + error = assert_raise(ArgumentError) do + use_ui @ui do + Dir.chdir @tempdir do + @cmd.execute + end + end + end + assert_match(/Cannot specify both a Ruby ABI and an output file name/, error.message) + end + def test_execute_platform gemspec_file = File.join(@tempdir, @gem.spec_name) diff --git a/test/rubygems/test_gem_commands_dependency_command.rb b/test/rubygems/test_gem_commands_dependency_command.rb index 48fe2f8e8da8..2111d6e7d4b7 100644 --- a/test/rubygems/test_gem_commands_dependency_command.rb +++ b/test/rubygems/test_gem_commands_dependency_command.rb @@ -188,6 +188,53 @@ def test_execute_remote assert_equal "", @stub_ui.error end + def test_execute_remote_content_addressable_compact_index_gem + spec_fetcher {} + util_set_arch "x86_64-linux" + + _spec, _gem_path, content_address = util_setup_content_addressable_compact_index_gem( + "ca_dependency", + "1.0.0", + platform: "x86_64-linux", + required_ruby_version: "~> 3.3.0" + ) do |s| + s.add_runtime_dependency "dep_tophat", "~> 1.0" + end + + @cmd.options[:args] = %w[ca_dependency] + @cmd.options[:domain] = :remote + + use_ui @stub_ui do + @cmd.execute + end + + assert_equal "Gem ca_dependency-1.0.0-#{content_address} (Platform: x86_64-linux Ruby ABI: 3.3)\n dep_tophat (~> 1.0)\n\n", @stub_ui.output + assert_equal "", @stub_ui.error + end + + def test_execute_remote_platform_compact_index_gem + spec_fetcher {} + util_set_arch "x86_64-linux" + + spec = util_spec "platform_dependency", "1.0.0" do |s| + s.platform = "x86_64-linux" + s.add_runtime_dependency "dep_tophat", "~> 1.0" + end + util_setup_compact_index spec + write_marshalled_gemspecs spec + Gem::SpecFetcher.fetcher = nil + + @cmd.options[:args] = %w[platform_dependency] + @cmd.options[:domain] = :remote + + use_ui @stub_ui do + @cmd.execute + end + + assert_equal "Gem platform_dependency-1.0.0-x86_64-linux\n dep_tophat (~> 1.0)\n\n", @stub_ui.output + assert_equal "", @stub_ui.error + end + def test_execute_remote_version @fetcher = Gem::FakeFetcher.new Gem::RemoteFetcher.fetcher = @fetcher diff --git a/test/rubygems/test_gem_commands_fetch_command.rb b/test/rubygems/test_gem_commands_fetch_command.rb index e673e391fe45..295dd939836a 100644 --- a/test/rubygems/test_gem_commands_fetch_command.rb +++ b/test/rubygems/test_gem_commands_fetch_command.rb @@ -68,6 +68,44 @@ def test_execute_prerelease "#{a2.full_name} not fetched") end + def test_execute_content_addressable_compact_index_gem + spec_fetcher {} + util_set_arch "x86_64-linux" + + spec, _gem_path, content_address = util_setup_content_addressable_compact_index_gem( + "ca_fetch", + "1.0.0", + platform: "x86_64-linux" + ) + + @cmd.options[:args] = %w[ca_fetch] + + execute_with_exit_code + + assert_path_exist File.join(@tempdir, "ca_fetch-1.0.0-#{content_address}.gem") + assert_path_not_exist File.join(@tempdir, "ca_fetch-1.0.0-x86_64-linux.gem") + assert_equal "ca_fetch-1.0.0-#{content_address}", spec.full_name + end + + def test_execute_platform_compact_index_gem + spec_fetcher {} + util_set_arch "x86_64-linux" + + spec, gem_path = util_gem "platform_fetch", "1.0.0" do |s| + s.platform = "x86_64-linux" + end + util_setup_compact_index spec + write_marshalled_gemspecs spec + add_to_fetcher spec, gem_path + Gem::SpecFetcher.fetcher = nil + + @cmd.options[:args] = %w[platform_fetch] + + execute_with_exit_code + + assert_path_exist File.join(@tempdir, "platform_fetch-1.0.0-x86_64-linux.gem") + end + def test_execute_platform a2_spec, a2 = util_gem("a", "2") diff --git a/test/rubygems/test_gem_commands_info_command.rb b/test/rubygems/test_gem_commands_info_command.rb index dab7cfb836b7..3bd6d802f5eb 100644 --- a/test/rubygems/test_gem_commands_info_command.rb +++ b/test/rubygems/test_gem_commands_info_command.rb @@ -42,6 +42,255 @@ def test_execute assert_match "", @ui.error end + def test_execute_remote_unscoped_content_addressable_gems_do_not_fetch_metadata + spec_fetcher {} + + spec_a = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") do |s| + s.summary = "summary a" + s.homepage = "http://example.com" + s.authors = ["A User"] + end + spec_b = util_ca_spec("b", "1", "fedcba98", ruby_abi: "3.4", platform: "arm64-darwin") do |s| + s.summary = "summary b" + s.homepage = "http://example.com" + s.authors = ["B User"] + end + util_setup_compact_index(spec_a, spec_b) + + path = "#{@gem_repo}quick/Marshal.#{Gem.marshal_version}/a-1-abcdef12.gemspec.rz" + @fetcher.data[path] = Zlib::Deflate.deflate(Marshal.dump(spec_a)) + path = "#{@gem_repo}quick/Marshal.#{Gem.marshal_version}/b-1-fedcba98.gemspec.rz" + @fetcher.data[path] = Zlib::Deflate.deflate(Marshal.dump(spec_b)) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[--remote] + + use_ui @ui do + @cmd.execute + end + + assert_include @ui.output, "a (1)" + assert_include @ui.output, "b (1)" + refute_match "Ruby ABI", @ui.output + refute @fetcher.requests.any? {|req| req.path.start_with?("/info/") } + end + + def test_execute_remote_content_addressable_gem_displays_real_platform_and_ruby_abi + spec_fetcher {} + + spec = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") do |s| + s.summary = "this is a summary" + s.homepage = "http://example.com" + s.authors = ["A User"] + end + util_setup_compact_index(spec) + + path = "#{@gem_repo}quick/Marshal.#{Gem.marshal_version}/a-1-abcdef12.gemspec.rz" + @fetcher.data[path] = Zlib::Deflate.deflate(Marshal.dump(spec)) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[a --remote] + + use_ui @ui do + @cmd.execute + end + + assert_include @ui.output, "Platforms:\n" + assert_include @ui.output, " x86_64-linux Ruby ABI: 3.3\n" + refute_match "abcdef12", @ui.output + end + + def test_execute_remote_content_addressable_gem_displays_ruby_abis_next_to_their_platforms + spec_fetcher {} + + spec = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") do |s| + s.summary = "this is a summary" + s.homepage = "http://example.com" + s.authors = ["A User"] + end + spec_musl = util_ca_spec("a", "1", "fedcba98", ruby_abi: "3.4", platform: "x86_64-linux-musl") do |s| + s.summary = "this is a summary" + s.homepage = "http://example.com" + s.authors = ["A User"] + end + util_setup_compact_index(spec, spec_musl) + + path = "#{@gem_repo}quick/Marshal.#{Gem.marshal_version}/a-1-abcdef12.gemspec.rz" + @fetcher.data[path] = Zlib::Deflate.deflate(Marshal.dump(spec)) + path = "#{@gem_repo}quick/Marshal.#{Gem.marshal_version}/a-1-fedcba98.gemspec.rz" + @fetcher.data[path] = Zlib::Deflate.deflate(Marshal.dump(spec_musl)) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[a --remote] + + use_ui @ui do + @cmd.execute + end + + assert_include @ui.output, "Platforms:\n" + assert_include @ui.output, " x86_64-linux Ruby ABI: 3.3\n" + assert_include @ui.output, " x86_64-linux-musl Ruby ABI: 3.4\n" + refute_match "Ruby ABIs: 3.3, 3.4", @ui.output + refute_match "abcdef12", @ui.output + refute_match "fedcba98", @ui.output + end + + def test_execute_remote_content_addressable_and_platform_gems_display_together + spec_fetcher {} + + spec_v1 = util_spec "a", "1" do |s| + s.platform = "x86_64-linux" + s.summary = "this is a summary" + s.homepage = "http://example.com" + s.authors = ["A User"] + end + spec_v2 = util_ca_spec("a", "2", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") do |s| + s.summary = "this is a summary" + s.homepage = "http://example.com" + s.authors = ["A User"] + end + spec_v3 = util_ca_spec("a", "3", "fedcba98", ruby_abi: "3.4", platform: "arm64-darwin") do |s| + s.summary = "this is a summary" + s.homepage = "http://example.com" + s.authors = ["A User"] + end + util_setup_compact_index(spec_v1, spec_v2, spec_v3) + + path = "#{@gem_repo}quick/Marshal.#{Gem.marshal_version}/a-3-fedcba98.gemspec.rz" + @fetcher.data[path] = Zlib::Deflate.deflate(Marshal.dump(spec_v3)) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[a --remote --all] + + use_ui @ui do + @cmd.execute + end + + assert_include @ui.output, "a (3, 2, 1)" + assert_include @ui.output, "Platforms:\n" + assert_include @ui.output, " 1: x86_64-linux\n" + assert_include @ui.output, " 2: x86_64-linux Ruby ABI: 3.3\n" + assert_include @ui.output, " 3: arm64-darwin Ruby ABI: 3.4\n" + refute_match "abcdef12", @ui.output + refute_match "fedcba98", @ui.output + end + + def test_execute_remote_content_addressable_gem_displays_multiple_ruby_abis_on_same_platform + spec_fetcher {} + + spec = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") do |s| + s.summary = "this is a summary" + s.homepage = "http://example.com" + s.authors = ["A User"] + end + spec_other = util_ca_spec("a", "1", "fedcba98", ruby_abi: "3.4", platform: "x86_64-linux") do |s| + s.summary = "this is a summary" + s.homepage = "http://example.com" + s.authors = ["A User"] + end + util_setup_compact_index(spec, spec_other) + + path = "#{@gem_repo}quick/Marshal.#{Gem.marshal_version}/a-1-abcdef12.gemspec.rz" + @fetcher.data[path] = Zlib::Deflate.deflate(Marshal.dump(spec)) + path = "#{@gem_repo}quick/Marshal.#{Gem.marshal_version}/a-1-fedcba98.gemspec.rz" + @fetcher.data[path] = Zlib::Deflate.deflate(Marshal.dump(spec_other)) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[a --remote] + + use_ui @ui do + @cmd.execute + end + + assert_include @ui.output, "Platforms:\n" + assert_include @ui.output, "x86_64-linux Ruby ABI: 3.3, 3.4\n" + refute_match "abcdef12", @ui.output + refute_match "fedcba98", @ui.output + end + + def test_execute_remote_content_addressable_platform_and_source_gems_display_together + spec_fetcher {} + + spec_v1 = util_spec "a", "1" do |s| + s.platform = "x86_64-linux" + s.summary = "this is a summary" + s.homepage = "http://example.com" + s.authors = ["A User"] + end + spec_v2 = util_ca_spec("a", "2", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") do |s| + s.summary = "this is a summary" + s.homepage = "http://example.com" + s.authors = ["A User"] + end + spec_v3 = util_ca_spec("a", "3", "fedcba98", ruby_abi: "3.4", platform: "arm64-darwin") do |s| + s.summary = "this is a summary" + s.homepage = "http://example.com" + s.authors = ["A User"] + end + spec_v4 = util_spec "a", "4" do |s| + s.summary = "this is a summary" + s.homepage = "http://example.com" + s.authors = ["A User"] + end + util_setup_compact_index(spec_v1, spec_v2, spec_v3, spec_v4) + + path = "#{@gem_repo}quick/Marshal.#{Gem.marshal_version}/a-3-fedcba98.gemspec.rz" + @fetcher.data[path] = Zlib::Deflate.deflate(Marshal.dump(spec_v3)) + path = "#{@gem_repo}quick/Marshal.#{Gem.marshal_version}/a-4.gemspec.rz" + @fetcher.data[path] = Zlib::Deflate.deflate(Marshal.dump(spec_v4)) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[a --remote --all] + + use_ui @ui do + @cmd.execute + end + + assert_include @ui.output, "a (4, 3, 2, 1)" + assert_include @ui.output, "Platforms:\n" + assert_include @ui.output, " 1: x86_64-linux\n" + assert_include @ui.output, " 2: x86_64-linux Ruby ABI: 3.3\n" + assert_include @ui.output, " 3: arm64-darwin Ruby ABI: 3.4\n" + refute_match " 4:", @ui.output + refute_match "abcdef12", @ui.output + refute_match "fedcba98", @ui.output + end + + def test_execute_remote_platform_gem_displays_version_once_for_multiple_platforms + spec_fetcher {} + + spec_e1 = util_spec "e", "1" do |s| + s.platform = "x86_64-linux" + s.summary = "summary e" + s.homepage = "http://example.com" + s.authors = ["E User"] + end + + spec_e2 = util_spec "e", "1" do |s| + s.platform = "arm64-darwin" + s.summary = "summary e" + s.homepage = "http://example.com" + s.authors = ["E User"] + end + + util_setup_compact_index(spec_e1, spec_e2) + + path = "#{@gem_repo}quick/Marshal.#{Gem.marshal_version}/e-1-x86_64-linux.gemspec.rz" + @fetcher.data[path] = Zlib::Deflate.deflate(Marshal.dump(spec_e1)) + path = "#{@gem_repo}quick/Marshal.#{Gem.marshal_version}/e-1-arm64-darwin.gemspec.rz" + @fetcher.data[path] = Zlib::Deflate.deflate(Marshal.dump(spec_e2)) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[e --remote] + + use_ui @ui do + @cmd.execute + end + + assert_include @ui.output, "e (1)" + assert_include @ui.output, "Platforms: arm64-darwin, x86_64-linux\n" + end + def test_execute_with_version_flag spec_fetcher do |fetcher| fetcher.spec "coolgem", "1.0" diff --git a/test/rubygems/test_gem_commands_list_command.rb b/test/rubygems/test_gem_commands_list_command.rb index 0b52b54e7748..ec22dfb0700e 100644 --- a/test/rubygems/test_gem_commands_list_command.rb +++ b/test/rubygems/test_gem_commands_list_command.rb @@ -31,6 +31,179 @@ def test_execute_installed assert_equal "", @ui.error end + def test_execute_remote_unscoped_content_addressable_gems_do_not_fetch_metadata + spec_fetcher {} + + a = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") + b = util_ca_spec("b", "1", "fedcba98", ruby_abi: "3.4", platform: "arm64-darwin") + util_setup_compact_index(a, b) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[--remote] + + use_ui @ui do + @cmd.execute + end + + assert_include @ui.output, "a (1 abcdef12)" + assert_include @ui.output, "b (1 fedcba98)" + refute_match "Ruby ABI", @ui.output + refute @fetcher.requests.any? {|req| req.path.start_with?("/info/") } + end + + def test_execute_remote_unscoped_all_content_addressable_gems_do_not_fetch_metadata + spec_fetcher {} + + a1 = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") + a2 = util_ca_spec("a", "2", "fedcba98", ruby_abi: "3.4", platform: "arm64-darwin") + util_setup_compact_index(a1, a2) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[--remote --all] + + use_ui @ui do + @cmd.execute + end + + assert_include @ui.output, "a (2 fedcba98, 1 abcdef12)" + refute_match "Ruby ABI", @ui.output + refute @fetcher.requests.any? {|req| req.path.start_with?("/info/") } + end + + def test_execute_remote_content_addressable_gem_displays_real_platform_and_ruby_abi + spec_fetcher {} + + a = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") + b = util_ca_spec("b", "1", "fedcba98", ruby_abi: "3.4", platform: "x86_64-linux") + util_setup_compact_index(a, b) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[a --remote] + + use_ui @ui do + @cmd.execute + end + + assert_include @ui.output, "a (1 Platform: x86_64-linux, Ruby ABI: 3.3)" + refute_match "abcdef12", @ui.output + refute @fetcher.requests.any? {|req| req.path.end_with?("/info/b") } + end + + def test_execute_remote_content_addressable_gems_displays_ruby_abis_next_to_their_platforms + spec_fetcher {} + + a1 = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") + a2 = util_ca_spec("a", "1", "fedcba98", ruby_abi: "3.4", platform: "arm64-darwin") + util_setup_compact_index(a1, a2) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[a --remote] + + use_ui @ui do + @cmd.execute + end + + expected = <<~OUTPUT.chomp + a (1 Platform: arm64-darwin, Ruby ABI: 3.4 + 1 Platform: x86_64-linux, Ruby ABI: 3.3) + OUTPUT + + assert_include @ui.output, expected + refute_match "abcdef12", @ui.output + refute_match "fedcba98", @ui.output + end + + def test_execute_remote_content_addressable_gems_displays_multiple_ruby_abis_on_the_same_line + spec_fetcher {} + + a1 = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") + a2 = util_ca_spec("a", "1", "fedcba98", ruby_abi: "3.4", platform: "x86_64-linux") + util_setup_compact_index(a1, a2) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[a --remote] + + use_ui @ui do + @cmd.execute + end + + assert_include @ui.output, "a (1 Platform: x86_64-linux, Ruby ABI: 3.3, 3.4)" + refute_match "abcdef12", @ui.output + refute_match "fedcba98", @ui.output + end + + def test_execute_remote_content_addressable_gems_displays_multiple_versions_on_separate_lines + spec_fetcher {} + + a1 = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") + a2 = util_ca_spec("a", "2", "fedcba98", ruby_abi: "3.4", platform: "x86_64-linux") + a3 = util_ca_spec("a", "3", "12345678", ruby_abi: "3.4", platform: "arm64-darwin") + util_setup_compact_index(a1, a2, a3) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[a --remote] + + use_ui @ui do + @cmd.execute + end + + expected = <<~OUTPUT.chomp + a (3 Platform: arm64-darwin, Ruby ABI: 3.4 + 2 Platform: x86_64-linux, Ruby ABI: 3.4 + 1 Platform: x86_64-linux, Ruby ABI: 3.3) + OUTPUT + + assert_include @ui.output, expected + refute_match "abcdef12", @ui.output + refute_match "fedcba98", @ui.output + refute_match "12345678", @ui.output + end + + def test_execute_remote_content_addressable_and_platform_gems_display_together + spec_fetcher {} + + a1 = util_spec("a", 1) {|s| s.platform = Gem::Platform.new("x86_64-linux") } + a2 = util_ca_spec("a", "2", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") + a3 = util_ca_spec("a", "3", "fedcba98", ruby_abi: "3.4", platform: "arm64-darwin") + a4 = util_spec("a", 4) + util_setup_compact_index(a1, a2, a3, a4) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[a --remote --all] + + use_ui @ui do + @cmd.execute + end + + expected = <<~OUTPUT.chomp + a (4 + 3 Platform: arm64-darwin, Ruby ABI: 3.4 + 2 Platform: x86_64-linux, Ruby ABI: 3.3 + 1 Platform: x86_64-linux) + OUTPUT + + assert_include @ui.output, expected + refute_match "abcdef12", @ui.output + refute_match "fedcba98", @ui.output + end + + def test_execute_remote_platform_gem_displays_version_once_for_multiple_platforms + spec_fetcher {} + + e1 = util_spec("e", 1) {|s| s.platform = Gem::Platform.new("x86_64-linux") } + e2 = util_spec("e", 1) {|s| s.platform = Gem::Platform.new("arm64-darwin") } + util_setup_compact_index(e1, e2) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[e --remote] + + use_ui @ui do + @cmd.execute + end + + assert_include @ui.output, "e (1 arm64-darwin x86_64-linux)" + end + def test_execute_normal_gem_shadowing_default_gem c1_default = new_default_spec "c", 1 install_default_gems c1_default diff --git a/test/rubygems/test_gem_commands_outdated_command.rb b/test/rubygems/test_gem_commands_outdated_command.rb index 4f88aef0f03b..b95f1c05dcb8 100644 --- a/test/rubygems/test_gem_commands_outdated_command.rb +++ b/test/rubygems/test_gem_commands_outdated_command.rb @@ -81,6 +81,33 @@ def test_execute_cooldown_annotates_newer_version_within_period assert_equal "", @ui.error end + def test_execute_cooldown_embargoes_content_addressable_tuple + util_set_arch "x86_64-linux" + + spec_fetcher do |fetcher| + fetcher.gem "ca_cooldown", "1.0.0" do |s| + s.platform = "x86_64-linux" + end + end + + ca_spec = util_ca_spec "ca_cooldown", "2.0.0", "abcdef12", + ruby_abi: Gem.ruby_version.segments.first(2).join("."), + platform: "x86_64-linux" + util_setup_compact_index ca_spec, created_at: { + ca_spec.original_name => util_cooldown_time(1), + } + Gem::SpecFetcher.fetcher = nil + + @cmd.options[:cooldown] = 7 + + use_ui @ui do + @cmd.execute + end + + assert_equal "ca_cooldown (1.0.0 < 2.0.0 (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) diff --git a/test/rubygems/test_gem_commands_push_command.rb b/test/rubygems/test_gem_commands_push_command.rb index f8bb09d60062..77b7c4a3eaf1 100644 --- a/test/rubygems/test_gem_commands_push_command.rb +++ b/test/rubygems/test_gem_commands_push_command.rb @@ -102,6 +102,376 @@ def test_execute_host @fetcher.last_request["Content-Type"] end + def test_handle_options_platform_and_ruby_abi + @cmd.handle_options %w[--platform arm64-darwin --ruby-abi 3.4 demo.gem] + + assert_equal "arm64-darwin", @cmd.options[:platform] + assert_equal "3.4", @cmd.options[:ruby_abi] + assert_equal ["demo.gem"], @cmd.options[:args] + end + + def test_execute_with_platform_selector_selects_matching_gem + _, matching_path = util_gem "platform-match", "1.0.0", ruby_abi: "3.4" do |spec| + spec.files = ["lib/platform-match.rb"] + spec.platform = "arm64-darwin" + end + _, other_path = util_gem "platform-other", "1.0.0", ruby_abi: "3.4" do |spec| + spec.files = ["lib/platform-other.rb"] + spec.platform = "x86_64-linux" + end + + @response = "Successfully registered gem: platform-match (1.0.0)" + @fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK") + + @cmd.options[:args] = [other_path, matching_path] + @cmd.options[:platform] = "arm64-darwin" + + @cmd.execute + + assert_equal Gem::Net::HTTP::Post, @fetcher.last_request.class + assert_equal Gem.read_binary(matching_path), @fetcher.last_request.body + end + + def test_execute_with_ruby_selector_selects_matching_gem + _, other_path = util_gem "ruby-other", "1.0.0", ruby_abi: "3.3" do |spec| + spec.files = ["lib/ruby-other.rb"] + spec.platform = "arm64-darwin" + end + _, matching_path = util_gem "ruby-match", "1.0.0", ruby_abi: "3.4" do |spec| + spec.files = ["lib/ruby-match.rb"] + spec.platform = "arm64-darwin" + end + + @response = "Successfully registered gem: ruby-match (1.0.0)" + @fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK") + + @cmd.options[:args] = [other_path, matching_path] + @cmd.options[:ruby_abi] = "3.4" + + @cmd.execute + + assert_equal Gem::Net::HTTP::Post, @fetcher.last_request.class + assert_equal Gem.read_binary(matching_path), @fetcher.last_request.body + end + + def test_execute_with_selectors_skips_invalid_gem_package + invalid_path = File.join @tempdir, "invalid.gem" + File.binwrite invalid_path, "not a gem" + _, matching_path = util_gem "skip-invalid", "1.0.0", ruby_abi: "3.4" do |spec| + spec.files = ["lib/skip-invalid.rb"] + spec.platform = "arm64-darwin" + end + + @response = "Successfully registered gem: skip-invalid (1.0.0)" + @fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK") + + @cmd.options[:args] = [invalid_path, matching_path] + @cmd.options[:platform] = "arm64-darwin" + + use_ui @ui do + @cmd.execute + end + + assert_match(/Skipping #{Regexp.escape(invalid_path)}: package metadata is missing/, @ui.error) + assert_equal Gem::Net::HTTP::Post, @fetcher.last_request.class + assert_equal Gem.read_binary(matching_path), @fetcher.last_request.body + end + + def test_execute_with_platform_and_ruby_selectors_selects_matching_gem + _, wrong_platform_path = util_gem "both-wrong-platform", "1.0.0", ruby_abi: "3.4" do |spec| + spec.files = ["lib/both-wrong-platform.rb"] + spec.platform = "x86_64-linux" + end + _, wrong_ruby_path = util_gem "both-wrong-ruby", "1.0.0", ruby_abi: "3.3" do |spec| + spec.files = ["lib/both-wrong-ruby.rb"] + spec.platform = "arm64-darwin" + end + _, matching_path = util_gem "both-match", "1.0.0", ruby_abi: "3.4" do |spec| + spec.files = ["lib/both-match.rb"] + spec.platform = "arm64-darwin" + end + + @response = "Successfully registered gem: both-match (1.0.0)" + @fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK") + + @cmd.options[:args] = [wrong_platform_path, wrong_ruby_path, matching_path] + @cmd.options[:platform] = "arm64-darwin" + @cmd.options[:ruby_abi] = "3.4" + + @cmd.execute + + assert_equal Gem::Net::HTTP::Post, @fetcher.last_request.class + assert_equal Gem.read_binary(matching_path), @fetcher.last_request.body + end + + def test_execute_with_same_name_version_platform_selects_matching_ruby_abi + _, ruby_33_path = util_gem "same-target", "1.0.0", ruby_abi: "3.3" do |spec| + spec.files = ["lib/same-target.rb"] + spec.platform = "arm64-darwin" + end + _, ruby_34_path = util_gem "same-target", "1.0.0", ruby_abi: "3.4" do |spec| + spec.files = ["lib/same-target.rb"] + spec.platform = "arm64-darwin" + end + + @response = "Successfully registered gem: same-target (1.0.0)" + @fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK") + + @cmd.options[:args] = [ruby_33_path, ruby_34_path] + @cmd.options[:platform] = "arm64-darwin" + @cmd.options[:ruby_abi] = "3.4" + + @cmd.execute + + assert_equal Gem::Net::HTTP::Post, @fetcher.last_request.class + assert_equal Gem.read_binary(ruby_34_path), @fetcher.last_request.body + end + + def test_execute_with_non_and_content_addressable_candidates_selects_content_addressable_for_ruby_abi + _, non_content_addressable_path = util_gem "mixed-target", "1.0.0" do |spec| + spec.platform = "arm64-darwin" + spec.required_ruby_version = ">= 3.1" + end + _, content_addressable_path = util_gem "mixed-target", "1.0.0", ruby_abi: "3.4" do |spec| + spec.files = ["lib/mixed-target.rb"] + spec.platform = "arm64-darwin" + end + + @response = "Successfully registered gem: mixed-target (1.0.0)" + @fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK") + + @cmd.options[:args] = [non_content_addressable_path, content_addressable_path] + @cmd.options[:platform] = "arm64-darwin" + @cmd.options[:ruby_abi] = "3.4" + + @cmd.execute + + assert_equal Gem::Net::HTTP::Post, @fetcher.last_request.class + assert_equal Gem.read_binary(content_addressable_path), @fetcher.last_request.body + end + + def test_execute_with_ruby_abi_selector_does_not_match_non_content_addressable_ruby_requirement + _, gem_path = util_gem "non-content-addressable-ruby", "1.0.0" do |spec| + spec.platform = "arm64-darwin" + spec.required_ruby_version = ">= 3.1" + end + + @cmd.options[:args] = [gem_path] + @cmd.options[:platform] = "arm64-darwin" + @cmd.options[:ruby_abi] = "3.4" + + error = assert_raise Gem::CommandLineError do + @cmd.execute + end + + assert_equal "No gem matched platform arm64-darwin and Ruby ABI 3.4", error.message + end + + def test_execute_with_selectors_raises_when_no_gems_match + _, gem_path = util_gem "no-match", "1.0.0", ruby_abi: "3.4" do |spec| + spec.files = ["lib/no-match.rb"] + spec.platform = "arm64-darwin" + end + + @cmd.options[:args] = [gem_path] + @cmd.options[:platform] = "x86_64-linux" + @cmd.options[:ruby_abi] = "3.4" + + error = assert_raise Gem::CommandLineError do + @cmd.execute + end + + assert_equal "No gem matched platform x86_64-linux and Ruby ABI 3.4", error.message + end + + def test_execute_with_selectors_raises_when_multiple_gems_match + _, first_path = util_gem "ambiguous-one", "1.0.0", ruby_abi: "3.4" do |spec| + spec.files = ["lib/ambiguous-one.rb"] + spec.platform = "arm64-darwin" + end + _, second_path = util_gem "ambiguous-two", "1.0.0", ruby_abi: "3.4" do |spec| + spec.files = ["lib/ambiguous-two.rb"] + spec.platform = "arm64-darwin" + end + + @cmd.options[:args] = [first_path, second_path] + @cmd.options[:platform] = "arm64-darwin" + @cmd.options[:ruby_abi] = "3.4" + + error = assert_raise Gem::CommandLineError do + @cmd.execute + end + + assert_match "Multiple gems matched platform arm64-darwin and Ruby ABI 3.4", error.message + assert_match first_path, error.message + assert_match second_path, error.message + end + + def test_execute_with_platform_selector_raises_when_multiple_ruby_abis_match + _, ruby_33_path = util_gem "ambiguous-target", "1.0.0", ruby_abi: "3.3" do |spec| + spec.files = ["lib/ambiguous-target.rb"] + spec.platform = "arm64-darwin" + end + _, ruby_34_path = util_gem "ambiguous-target", "1.0.0", ruby_abi: "3.4" do |spec| + spec.files = ["lib/ambiguous-target.rb"] + spec.platform = "arm64-darwin" + end + + @cmd.options[:args] = [ruby_33_path, ruby_34_path] + @cmd.options[:platform] = "arm64-darwin" + + error = assert_raise Gem::CommandLineError do + @cmd.execute + end + + assert_match "Multiple gems matched platform arm64-darwin", error.message + assert_match ruby_33_path, error.message + assert_match ruby_34_path, error.message + assert_match "Specify --ruby-abi with one of: 3.3, 3.4", error.message + end + + def test_execute_with_ruby_abi_selector_raises_when_multiple_platforms_match + _, arm_path = util_gem "ambiguous-platform", "1.0.0", ruby_abi: "3.4" do |spec| + spec.files = ["lib/ambiguous-platform.rb"] + spec.platform = "arm64-darwin" + end + _, linux_path = util_gem "ambiguous-platform", "1.0.0", ruby_abi: "3.4" do |spec| + spec.files = ["lib/ambiguous-platform.rb"] + spec.platform = "x86_64-linux" + end + + @cmd.options[:args] = [arm_path, linux_path] + @cmd.options[:ruby_abi] = "3.4" + + error = assert_raise Gem::CommandLineError do + @cmd.execute + end + + assert_match "Multiple gems matched Ruby ABI 3.4", error.message + assert_match arm_path, error.message + assert_match linux_path, error.message + assert_match "Specify --platform with one of: arm64-darwin, x86_64-linux", error.message + end + + def test_execute_with_platform_selector_suggests_exact_filename_for_gem_without_ruby_abi + _, non_content_addressable_path = util_gem "ambiguous-non-content-addressable", "1.0.0" do |spec| + spec.platform = "arm64-darwin" + spec.required_ruby_version = ">= 3.1" + end + _, content_addressable_path = util_gem "ambiguous-non-content-addressable", "1.0.0", ruby_abi: "3.4" do |spec| + spec.files = ["lib/ambiguous-non-content-addressable.rb"] + spec.platform = "arm64-darwin" + end + + @cmd.options[:args] = [non_content_addressable_path, content_addressable_path] + @cmd.options[:platform] = "arm64-darwin" + + error = assert_raise Gem::CommandLineError do + @cmd.execute + end + + assert_match "Multiple gems matched platform arm64-darwin", error.message + assert_match non_content_addressable_path, error.message + assert_match content_addressable_path, error.message + assert_match "Specify --ruby-abi with one of: 3.4", error.message + assert_match "To push a gem without a Ruby ABI, pass the exact filename.", error.message + end + + def test_execute_without_selectors_still_rejects_multiple_gems + _, other_path = util_gem "extra-gem", "1.0.0", ruby_abi: "3.4" do |spec| + spec.files = ["lib/extra-gem.rb"] + spec.platform = "arm64-darwin" + end + + @cmd.options[:args] = [@path, other_path] + + error = assert_raise Gem::CommandLineError do + @cmd.execute + end + + assert_match "Too many gem names", error.message + end + + def test_execute_with_both_selectors_raises_when_multiple_gems_match_without_suggestion + _, first_path = util_gem "dual-ambiguous-one", "1.0.0", ruby_abi: "3.4" do |spec| + spec.files = ["lib/dual-ambiguous-one.rb"] + spec.platform = "arm64-darwin" + end + _, second_path = util_gem "dual-ambiguous-two", "1.0.0", ruby_abi: "3.4" do |spec| + spec.files = ["lib/dual-ambiguous-two.rb"] + spec.platform = "arm64-darwin" + end + + @cmd.options[:args] = [first_path, second_path] + @cmd.options[:platform] = "arm64-darwin" + @cmd.options[:ruby_abi] = "3.4" + + error = assert_raise Gem::CommandLineError do + @cmd.execute + end + + assert_match "Multiple gems matched platform arm64-darwin and Ruby ABI 3.4", error.message + assert_match first_path, error.message + assert_match second_path, error.message + refute_match(/Specify/, error.message) + end + + def test_execute_with_both_selectors_selects_single_matching_gem + _, matching_path = util_gem "dual-match", "1.0.0", ruby_abi: "3.4" do |spec| + spec.files = ["lib/dual-match.rb"] + spec.platform = "arm64-darwin" + end + + @response = "Successfully registered gem: dual-match (1.0.0)" + @fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK") + + @cmd.options[:args] = [matching_path] + @cmd.options[:platform] = "arm64-darwin" + @cmd.options[:ruby_abi] = "3.4" + + @cmd.execute + + assert_equal Gem::Net::HTTP::Post, @fetcher.last_request.class + assert_equal Gem.read_binary(matching_path), @fetcher.last_request.body + end + + def test_execute_with_platform_selector_selects_single_non_content_addressable_gem + _, non_content_addressable_path = util_gem "non-content-addressable-only", "1.0.0" do |spec| + spec.platform = "arm64-darwin" + spec.required_ruby_version = ">= 3.1" + end + + @response = "Successfully registered gem: non-content-addressable-only (1.0.0)" + @fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK") + + @cmd.options[:args] = [non_content_addressable_path] + @cmd.options[:platform] = "arm64-darwin" + + @cmd.execute + + assert_equal Gem::Net::HTTP::Post, @fetcher.last_request.class + assert_equal Gem.read_binary(non_content_addressable_path), @fetcher.last_request.body + end + + def test_execute_with_ruby_abi_selector_rejects_source_gem + _, source_path = util_gem "source-ruby", "1.0.0" do |spec| + spec.files = ["lib/source-ruby.rb"] + spec.required_ruby_version = "~> 3.4.0" + end + + @response = "Successfully registered gem: source-ruby (1.0.0)" + @fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK") + + @cmd.options[:args] = [source_path] + @cmd.options[:ruby_abi] = "3.4" + + error = assert_raise(Gem::CommandLineError) do + @cmd.execute + end + + assert_match(/No gem matched/, error.message) + end + def test_execute_attestation @response = "Successfully registered gem: freewill (1.0.0)" @fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK") diff --git a/test/rubygems/test_gem_commands_search_command.rb b/test/rubygems/test_gem_commands_search_command.rb index 47aefa0cf75b..8d036b2af1b9 100644 --- a/test/rubygems/test_gem_commands_search_command.rb +++ b/test/rubygems/test_gem_commands_search_command.rb @@ -13,4 +13,201 @@ def setup def test_initialize assert_equal :remote, @cmd.defaults[:domain] end + + def test_execute_unscoped_content_addressable_gems_do_not_fetch_metadata + spec_fetcher {} + + spec_a = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") + spec_b = util_ca_spec("b", "1", "fedcba98", ruby_abi: "3.4", platform: "arm64-darwin") + util_setup_compact_index(spec_a, spec_b) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options [] + + use_ui @ui do + @cmd.execute + end + + assert_include @ui.output, "a (1 abcdef12)" + assert_include @ui.output, "b (1 fedcba98)" + refute_match "Ruby ABI", @ui.output + refute @fetcher.requests.any? {|req| req.path.start_with?("/info/") } + end + + def test_execute_unscoped_all_content_addressable_gems_do_not_fetch_metadata + spec_fetcher {} + + spec_a1 = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") + spec_a2 = util_ca_spec("a", "2", "fedcba98", ruby_abi: "3.4", platform: "arm64-darwin") + util_setup_compact_index(spec_a1, spec_a2) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[--all] + + use_ui @ui do + @cmd.execute + end + + assert_include @ui.output, "a (2 fedcba98, 1 abcdef12)" + refute_match "Ruby ABI", @ui.output + refute @fetcher.requests.any? {|req| req.path.start_with?("/info/") } + end + + def test_execute_content_addressable_gem_displays_real_platform_and_ruby_abi + spec_fetcher {} + + spec = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") + util_setup_compact_index(spec) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[a] + + use_ui @ui do + @cmd.execute + end + + assert_include @ui.output, "a (1 Platform: x86_64-linux, Ruby ABI: 3.3)" + refute_match "abcdef12", @ui.output + end + + def test_execute_content_addressable_and_platform_gems_display_together + spec_fetcher {} + + a1 = util_spec("a", 1) {|s| s.platform = Gem::Platform.new("x86_64-linux") } + a2 = util_ca_spec("a", "2", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") + a3 = util_ca_spec("a", "3", "fedcba98", ruby_abi: "3.4", platform: "arm64-darwin") + util_setup_compact_index(a1, a2, a3) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[a --all] + + use_ui @ui do + @cmd.execute + end + + expected = <<~OUTPUT.chomp + a (3 Platform: arm64-darwin, Ruby ABI: 3.4 + 2 Platform: x86_64-linux, Ruby ABI: 3.3 + 1 Platform: x86_64-linux) + OUTPUT + + assert_include @ui.output, expected + refute_match "abcdef12", @ui.output + refute_match "fedcba98", @ui.output + end + + def test_execute_content_addressable_gems_displays_ruby_abis_next_to_their_platforms + spec_fetcher {} + + spec_a = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") + spec_b = util_ca_spec("a", "1", "fedcba98", ruby_abi: "3.4", platform: "arm64-darwin") + util_setup_compact_index(spec_a, spec_b) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[a] + + use_ui @ui do + @cmd.execute + end + + expected = <<~OUTPUT.chomp + a (1 Platform: arm64-darwin, Ruby ABI: 3.4 + 1 Platform: x86_64-linux, Ruby ABI: 3.3) + OUTPUT + + assert_include @ui.output, expected + refute_match "abcdef12", @ui.output + refute_match "fedcba98", @ui.output + end + + def test_execute_content_addressable_gems_displays_multiple_ruby_abis_on_the_same_line + spec_fetcher {} + + spec_a = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") + spec_b = util_ca_spec("a", "1", "fedcba98", ruby_abi: "3.4", platform: "x86_64-linux") + util_setup_compact_index(spec_a, spec_b) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[a] + + use_ui @ui do + @cmd.execute + end + + assert_include @ui.output, "a (1 Platform: x86_64-linux, Ruby ABI: 3.3, 3.4)" + refute_match "abcdef12", @ui.output + refute_match "fedcba98", @ui.output + end + + def test_execute_content_addressable_gems_displays_multiple_versions_on_separate_lines + spec_fetcher {} + + spec_a = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") + spec_b = util_ca_spec("a", "2", "fedcba98", ruby_abi: "3.4", platform: "x86_64-linux") + spec_c = util_ca_spec("a", "3", "12345678", ruby_abi: "3.4", platform: "arm64-darwin") + util_setup_compact_index(spec_a, spec_b, spec_c) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[a] + + use_ui @ui do + @cmd.execute + end + + expected = <<~OUTPUT.chomp + a (3 Platform: arm64-darwin, Ruby ABI: 3.4 + 2 Platform: x86_64-linux, Ruby ABI: 3.4 + 1 Platform: x86_64-linux, Ruby ABI: 3.3) + OUTPUT + + assert_include @ui.output, expected + refute_match "abcdef12", @ui.output + refute_match "fedcba98", @ui.output + refute_match "12345678", @ui.output + end + + def test_execute_platform_gem_displays_version_once_for_multiple_platforms + spec_fetcher {} + + e1 = util_spec("e", 1) {|s| s.platform = Gem::Platform.new("x86_64-linux") } + e2 = util_spec("e", 1) {|s| s.platform = Gem::Platform.new("arm64-darwin") } + util_setup_compact_index(e1, e2) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[e] + + use_ui @ui do + @cmd.execute + end + + assert_include @ui.output, "e (1 arm64-darwin x86_64-linux)" + end + + def test_execute_content_addressable_platform_and_source_gems_display_together + spec_fetcher {} + + a1 = util_spec("a", 1) {|s| s.platform = Gem::Platform.new("x86_64-linux") } + a2 = util_ca_spec("a", "2", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") + a3 = util_ca_spec("a", "3", "fedcba98", ruby_abi: "3.4", platform: "arm64-darwin") + a4 = util_spec("a", 4) + util_setup_compact_index(a1, a2, a3, a4) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[a --all] + + use_ui @ui do + @cmd.execute + end + + expected = <<~OUTPUT.chomp + a (4 + 3 Platform: arm64-darwin, Ruby ABI: 3.4 + 2 Platform: x86_64-linux, Ruby ABI: 3.3 + 1 Platform: x86_64-linux) + OUTPUT + + assert_include @ui.output, expected + refute_match "abcdef12", @ui.output + refute_match "fedcba98", @ui.output + end end diff --git a/test/rubygems/test_gem_commands_update_command.rb b/test/rubygems/test_gem_commands_update_command.rb index 9d15406bd13f..38126e78d1c3 100644 --- a/test/rubygems/test_gem_commands_update_command.rb +++ b/test/rubygems/test_gem_commands_update_command.rb @@ -42,6 +42,67 @@ def test_execute assert_empty out end + def test_execute_content_addressable_compact_index_gem + util_set_arch "x86_64-linux" + + spec_fetcher do |fetcher| + fetcher.gem "ca_update", "0.9.0" do |s| + s.platform = "x86_64-linux" + end + end + + _spec, _gem_path, content_address = util_setup_content_addressable_compact_index_gem( + "ca_update", + "1.0.0", + platform: "x86_64-linux" + ) + + @cmd.options[:args] = %w[ca_update] + + use_ui @ui do + @cmd.execute + end + + out = @ui.output.split "\n" + assert_equal "Updating installed gems", out.shift + assert_equal "Updating ca_update", out.shift + assert_equal "Gems updated: ca_update", out.shift + assert_empty out + + assert_path_exist File.join(@gemhome, "specifications", "ca_update-1.0.0-#{content_address}.gemspec") + end + + def test_execute_platform_compact_index_gem + util_set_arch "x86_64-linux" + + spec_fetcher do |fetcher| + fetcher.gem "platform_update", "0.9.0" do |s| + s.platform = "x86_64-linux" + end + end + + spec, gem_path = util_gem "platform_update", "1.0.0" do |s| + s.platform = "x86_64-linux" + end + util_setup_compact_index spec + add_to_fetcher spec, gem_path + Gem::SpecFetcher.fetcher = nil + + @cmd.options[:args] = %w[platform_update] + + use_ui @ui do + @cmd.execute + end + + out = @ui.output.split "\n" + assert_equal "Updating installed gems", out.shift + assert_equal "Updating platform_update", out.shift + assert_equal "Gems updated: platform_update", out.shift + assert_empty out + + assert_path_exist File.join(@gemhome, "specifications", "platform_update-1.0.0-x86_64-linux.gemspec") + end + def test_execute_compact_index spec_fetcher do |fetcher| fetcher.gem "b", 1 @@ -116,6 +177,39 @@ def test_execute_cooldown_falls_back_to_older_version assert_path_not_exist File.join(@gemhome, "specifications", "b-3.gemspec") end + def test_execute_cooldown_skips_content_addressable_tuple + util_set_arch "x86_64-linux" + + spec_fetcher do |fetcher| + fetcher.gem "ca_cooldown", "1.0.0" do |s| + s.platform = "x86_64-linux" + end + end + + ca_spec = util_ca_spec "ca_cooldown", "2.0.0", "abcdef12", + ruby_abi: Gem.ruby_version.segments.first(2).join("."), + platform: "x86_64-linux" + util_setup_compact_index ca_spec, created_at: { + ca_spec.original_name => util_cooldown_time(1), + } + Gem::SpecFetcher.fetcher = 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 "Nothing to update", out.shift + assert_equal "The following gem versions were skipped by the cooldown setting:", out.shift + assert_match(/\A \* ca_cooldown 2\.0\.0 \(available in \d+ days\), resolved 1\.0\.0 instead\z/, out.shift) + assert_empty out + assert_path_not_exist File.join(@gemhome, "specifications", "ca_cooldown-2.0.0-abcdef12.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) diff --git a/test/rubygems/test_gem_commands_yank_command.rb b/test/rubygems/test_gem_commands_yank_command.rb index 457a0e65c8a9..006beab66ca4 100644 --- a/test/rubygems/test_gem_commands_yank_command.rb +++ b/test/rubygems/test_gem_commands_yank_command.rb @@ -27,17 +27,18 @@ def teardown end def test_handle_options - @cmd.handle_options %w[a --version 1.0 --platform x86-darwin -k KEY --host HOST] + @cmd.handle_options %w[a --version 1.0 --platform x86-darwin --ruby-abi 3.4 -k KEY --host HOST] assert_equal %w[a], @cmd.options[:args] assert_equal :KEY, @cmd.options[:key] assert_equal "HOST", @cmd.options[:host] assert_nil @cmd.options[:platform] + assert_equal "3.4", @cmd.options[:ruby_abi] assert_equal req("= 1.0"), @cmd.options[:version] end def test_handle_options_missing_argument - %w[-v --version -p --platform].each do |option| + %w[-v --version -p --platform --ruby-abi].each do |option| assert_raise Gem::OptionParser::MissingArgument do @cmd.handle_options %W[a #{option}] end @@ -68,6 +69,78 @@ def test_execute assert_equal [yank_uri], @fetcher.paths end + def test_execute_with_ruby_abi_sends_platform_and_ruby_abi_to_yank_api + original_platforms = Gem.platforms.dup + yank_uri = "http://example/api/v1/gems/yank" + @fetcher.data[yank_uri] = HTTPResponseFactory.create(body: "Successfully yanked", code: 200, msg: "OK") + + @cmd.options[:args] = %w[a] + @cmd.options[:version] = req("= 1.0") + @cmd.options[:ruby_abi] = "3.4" + Gem.platforms = [Gem::Platform::RUBY, Gem::Platform.new("x86_64-linux")] + @cmd.options[:added_platform] = true + + use_ui @ui do + @cmd.execute + end + + body = @fetcher.last_request.body.split("&").sort + assert_equal %w[gem_name=a platform=x86_64-linux ruby_abi=3.4 version=1.0], body + assert_match(/Successfully yanked/, @ui.output) + assert_equal [yank_uri], @fetcher.paths + ensure + Gem.platforms = original_platforms + end + + def test_execute_with_ruby_abi_without_platform_sends_ruby_abi_to_yank_api + yank_uri = "http://example/api/v1/gems/yank" + @fetcher.data[yank_uri] = HTTPResponseFactory.create( + body: "The platform param is required when ruby_abi is specified.", + code: 400, + msg: "Bad Request" + ) + + @cmd.options[:args] = %w[a] + @cmd.options[:version] = req("= 1.0") + @cmd.options[:ruby_abi] = "3.4" + + use_ui @ui do + @cmd.execute + end + + body = @fetcher.last_request.body.split("&").sort + assert_equal %w[gem_name=a ruby_abi=3.4 version=1.0], body + assert_match(/The platform param is required when ruby_abi is specified/, @ui.output) + assert_equal [yank_uri], @fetcher.paths + end + + def test_execute_with_ruby_abi_and_platform_no_matching_gem_displays_error + original_platforms = Gem.platforms.dup + yank_uri = "http://example/api/v1/gems/yank" + @fetcher.data[yank_uri] = HTTPResponseFactory.create( + body: "The version 1.0 (x86_64-linux) (Ruby ABI 3.9) does not exist.", + code: 404, + msg: "Not Found" + ) + + @cmd.options[:args] = %w[a] + @cmd.options[:version] = req("= 1.0") + @cmd.options[:ruby_abi] = "3.9" + Gem.platforms = [Gem::Platform::RUBY, Gem::Platform.new("x86_64-linux")] + @cmd.options[:added_platform] = true + + use_ui @ui do + @cmd.execute + end + + body = @fetcher.last_request.body.split("&").sort + assert_equal %w[gem_name=a platform=x86_64-linux ruby_abi=3.9 version=1.0], body + assert_match(/The version 1\.0 \(x86_64-linux\) \(Ruby ABI 3\.9\) does not exist/, @ui.output) + assert_equal [yank_uri], @fetcher.paths + ensure + Gem.platforms = original_platforms + end + def test_execute_with_otp_success response_fail = "You have enabled multifactor authentication but your request doesn't have the correct OTP code. Please check it and retry." yank_uri = "http://example/api/v1/gems/yank" diff --git a/test/rubygems/test_gem_compact_index_client.rb b/test/rubygems/test_gem_compact_index_client.rb index d0d6998d1606..528694ad1b62 100644 --- a/test/rubygems/test_gem_compact_index_client.rb +++ b/test/rubygems/test_gem_compact_index_client.rb @@ -43,6 +43,15 @@ def setup @client = Gem::CompactIndexClient.new(File.join(@tempdir, "compact_index"), @fetcher) end + def test_info_platform_is_a_deprecated_alias_of_info_suffix + deprecated = Warning[:deprecated] + Warning[:deprecated] = false + + assert_equal Gem::CompactIndexClient::INFO_SUFFIX, Gem::CompactIndexClient::INFO_PLATFORM + ensure + Warning[:deprecated] = deprecated + end + def test_names assert_equal %w[a b], @client.names end @@ -60,7 +69,7 @@ def test_info_returns_parsed_info_arrays assert_equal 2, info.size assert_equal "a", info.last[Gem::CompactIndexClient::INFO_NAME] assert_equal "1.1.0", info.last[Gem::CompactIndexClient::INFO_VERSION] - assert_nil info.last[Gem::CompactIndexClient::INFO_PLATFORM] + assert_nil info.last[Gem::CompactIndexClient::INFO_SUFFIX] assert_includes info.last[Gem::CompactIndexClient::INFO_REQS], ["created_at", ["2026-06-05T10:30:45Z"]] end @@ -69,7 +78,7 @@ def test_dependencies assert_equal 2, dependencies.size assert_equal "b", dependencies.last.first[Gem::CompactIndexClient::INFO_NAME] - assert_equal "java", dependencies.last.first[Gem::CompactIndexClient::INFO_PLATFORM] + assert_equal "java", dependencies.last.first[Gem::CompactIndexClient::INFO_SUFFIX] end def test_latest_version diff --git a/test/rubygems/test_gem_content_address.rb b/test/rubygems/test_gem_content_address.rb new file mode 100644 index 000000000000..a935946304b8 --- /dev/null +++ b/test/rubygems/test_gem_content_address.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require_relative "helper" +require "rubygems/content_address" + +class TestGemContentAddress < Gem::TestCase + def test_match + assert Gem::ContentAddress.match?("78be552b") + refute Gem::ContentAddress.match?("x86_64-linux") + refute Gem::ContentAddress.match?(nil) + refute Gem::ContentAddress.match?("") + refute Gem::ContentAddress.match?(0xabcdef12) + refute Gem::ContentAddress.match?(:abcdef12) + refute Gem::ContentAddress.match?("abcdef12 ") + refute Gem::ContentAddress.match?(" abcdef12") + end + + def test_match_boundary_lengths + assert Gem::ContentAddress.match?("a" * 8) + assert Gem::ContentAddress.match?("a" * 64) + refute Gem::ContentAddress.match?("a" * 7) + refute Gem::ContentAddress.match?("a" * 65) + end + + def test_match_rejects_uppercase + refute Gem::ContentAddress.match?("ABCDEF12") + end + + def test_applicable_with_required_ruby_version_and_platform + spec = Gem::Specification.new "a", 1 + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + assert Gem::ContentAddress.applicable?(spec) + end + + def test_applicable_without_required_ruby_version + spec = Gem::Specification.new "a", 1 + spec.platform = "x86_64-linux" + refute Gem::ContentAddress.applicable?(spec) + end + + def test_applicable_with_ruby_platform + spec = Gem::Specification.new "a", 1 + spec.required_ruby_version = ">= 3.0" + refute Gem::ContentAddress.applicable?(spec) + end + + def test_applicable_with_nil_platform + spec = Gem::Specification.new "a", 1 + spec.required_ruby_version = ">= 3.0" + spec.platform = nil + refute Gem::ContentAddress.applicable?(spec) + end + + def test_content_addressed_with_eligible_spec_and_valid_address + spec = Gem::Specification.new "a", 1 + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + spec.content_address = "abcdef12" + assert Gem::ContentAddress.content_addressed?(spec) + end + + def test_content_addressed_with_eligible_spec_and_no_address + spec = Gem::Specification.new "a", 1 + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + refute Gem::ContentAddress.content_addressed?(spec) + end + + def test_content_addressed_with_eligible_spec_and_invalid_address + spec = Gem::Specification.new "a", 1 + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + spec.content_address = "x86_64-linux" + refute Gem::ContentAddress.content_addressed?(spec) + end + + def test_content_addressed_with_ineligible_spec_and_valid_address + spec = Gem::Specification.new "a", 1 + spec.content_address = "abcdef12" + refute Gem::ContentAddress.content_addressed?(spec) + end + + def test_ruby_abi_specificity_match_ranks_content_addressed_spec_for_that_ruby_first + spec = Gem::Specification.new "a", 1 + spec.platform = "x86_64-linux" + spec.required_ruby_version = "~> 3.4.0" + spec.content_address = "abcdef12" + + assert_equal 0, Gem::ContentAddress.ruby_abi_specificity_match(spec, Gem::Version.new("3.4.1")) + end + + def test_ruby_abi_specificity_match_ranks_non_content_addressed_spec_second + spec = Gem::Specification.new "a", 1 + spec.platform = "x86_64-linux" + spec.required_ruby_version = "~> 3.3.0" + + assert_equal 1, Gem::ContentAddress.ruby_abi_specificity_match(spec, Gem::Version.new("3.4.1")) + end + + def test_ruby_abi_specificity_match_ranks_content_addressed_spec_for_another_ruby_last + spec = Gem::Specification.new "a", 1 + spec.platform = "x86_64-linux" + spec.required_ruby_version = "~> 3.3.0" + spec.content_address = "abcdef12" + + assert_equal 2, Gem::ContentAddress.ruby_abi_specificity_match(spec, Gem::Version.new("3.4.1")) + end + + def test_ruby_abi_specificity_match_defaults_to_the_running_ruby + spec = Gem::Specification.new "a", 1 + spec.platform = "x86_64-linux" + spec.required_ruby_version = "~> 3.4.0" + spec.content_address = "abcdef12" + + assert_equal Gem::ContentAddress.ruby_abi_specificity_match(spec, Gem.ruby_version), + Gem::ContentAddress.ruby_abi_specificity_match(spec) + end +end diff --git a/test/rubygems/test_gem_dependency_installer.rb b/test/rubygems/test_gem_dependency_installer.rb index 27fad9513536..685455068f64 100644 --- a/test/rubygems/test_gem_dependency_installer.rb +++ b/test/rubygems/test_gem_dependency_installer.rb @@ -373,7 +373,7 @@ def test_install_dependency_existing_extension end e1_gem = e1.cache_file - _, f1_gem = util_gem "f", "1", "e" => nil + _, f1_gem = util_gem "f", "1", { "e" => nil } Gem::Installer.at(e1_gem).install FileUtils.rm_r e1.extension_dir @@ -394,7 +394,7 @@ def test_install_dependency_existing_extension def test_install_dependency_old _, e1_gem = util_gem "e", "1" - _, f1_gem = util_gem "f", "1", "e" => nil + _, f1_gem = util_gem "f", "1", { "e" => nil } _, f2_gem = util_gem "f", "2" FileUtils.mv e1_gem, @tempdir @@ -424,6 +424,29 @@ def test_install_local assert_equal %w[a-1], inst.installed_gems.map(&:full_name) end + def test_install_local_by_name_preserves_content_address + ruby_abi = Gem.ruby_version.segments.first(2).join(".") + util_set_RUBY_VERSION "#{ruby_abi}.0", 0, RUBY_REVISION, "ruby #{ruby_abi}.0" + _spec, ca_gem = util_gem("ca", "1.0.0", ruby_abi: ruby_abi) do |spec| + spec.platform = Gem::Platform.local + end + FileUtils.mv ca_gem, @tempdir + moved_gem = File.join(@tempdir, File.basename(ca_gem)) + address = Gem::Package.new(moved_gem).content_address + inst = nil + Dir.chdir @tempdir do + inst = Gem::DependencyInstaller.new(domain: :local) + source = Gem::Source::Local.new + local_spec = source.find_all_gems("ca", Gem::Requirement.default).first + + assert_equal(address, local_spec.content_address) + inst.install("ca") + end + assert_equal(address, inst.installed_gems.first.content_address) + ensure + util_restore_RUBY_VERSION + end + def test_install_local_prerelease util_setup_gems @@ -507,7 +530,7 @@ def test_install_local_dependency_no_network_for_target_gem end def test_install_compact_index_api - a1, a1_gem = util_gem "a", 1, "b" => ">= 1" + a1, a1_gem = util_gem "a", 1, { "b" => ">= 1" } b1, b1_gem = util_gem "b", 1 util_setup_compact_index a1, b1 diff --git a/test/rubygems/test_gem_installer.rb b/test/rubygems/test_gem_installer.rb index 5bd5bf89f05d..741eed1ea1d5 100644 --- a/test/rubygems/test_gem_installer.rb +++ b/test/rubygems/test_gem_installer.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative "installer_test_case" +require "digest" class TestGemInstaller < Gem::InstallerTestCase def setup @@ -1030,6 +1031,247 @@ def test_install_dir_takes_precedence_to_user_install assert_path_not_exist File.join(Gem.user_dir, "gems", @spec.full_name) end + def test_install_assigns_content_address_from_filename + _, a_gem = util_gem("a", 2) do |spec| + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + end + + digest = Digest::SHA256.file(a_gem).hexdigest + address = digest[0, 8] + dir = File.dirname(a_gem) + filename = File.join(dir, "a-2-#{address}.gem") + FileUtils.cp a_gem, filename + installer = Gem::Installer.at filename, install_dir: @gemhome, force: true + # deliberately memoize the directory before installation, to prove the installation recalculates + installer.gem_dir + spec = installer.install + + assert_equal address, spec.content_address + assert_equal "a-2-#{address}", spec.full_name + assert_path_exist File.join(@gemhome, "gems", "a-2-#{address}") + assert_path_exist File.join(@gemhome, "cache", "a-2-#{address}.gem") + assert_path_exist File.join(@gemhome, "specifications", "a-2-#{address}.gemspec") + end + + def test_install_raises_for_mismatched_content_address + _, a_gem = util_gem("a", 2) do |spec| + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + end + dir = File.dirname(a_gem) + filename = File.join(dir, "a-2-deadbeef.gem") + FileUtils.cp a_gem, filename + installer = Gem::Installer.at filename, install_dir: @gemhome, force: true + + e = assert_raise Gem::InstallError do + installer.install + end + + assert_match(/content address mismatch/, e.message) + end + + def test_non_content_addressed_gems_install_as_expected + _, a_gem = util_gem "a", 2 + installer = Gem::Installer.at a_gem, install_dir: @gemhome, force: true + spec = installer.install + + assert_nil spec.content_address + assert_equal "a-2", spec.full_name + assert_path_exist File.join(@gemhome, "gems", "a-2") + assert_path_exist File.join(@gemhome, "specifications", "a-2.gemspec") + end + + def test_numeric_version_not_treated_as_content_address + _, a_gem = util_gem "a", "20240101" + installer = Gem::Installer.at a_gem, install_dir: @gemhome, force: true + spec = installer.install + + assert_nil spec.content_address + assert_equal "a-20240101", spec.full_name + assert_path_exist File.join(@gemhome, "gems", "a-20240101") + assert_path_exist File.join(@gemhome, "specifications", "a-20240101.gemspec") + end + + def test_normal_gem_with_hex_suffix_is_not_content_addressed + _, a_gem = util_gem "a", 2 do |spec| + spec.platform = Gem::Platform.new("x86-linux-deadbeef") + end + installer = Gem::Installer.at a_gem, install_dir: @gemhome, force: true + spec = installer.install + + assert_nil spec.content_address + assert_equal "a-2-x86-linux-deadbeef", spec.full_name + end + + def test_content_address_not_set_without_required_ruby_version_and_platform + _, a_gem = util_gem "a", 2 + digest = Digest::SHA256.file(a_gem).hexdigest + address = digest[0, 8] + dir = File.dirname(a_gem) + filename = File.join(dir, "a-2-#{address}.gem") + FileUtils.cp a_gem, filename + installer = Gem::Installer.at filename, install_dir: @gemhome, force: true + spec = installer.install + + assert_nil spec.content_address + assert_equal "a-2", spec.full_name + end + + def test_hex_suffix_without_matching_spec_prefix_is_not_content_addressed + _, a_gem = util_gem "a", 2 + + digest = Digest::SHA256.file(a_gem).hexdigest + address = digest[0, 8] + dir = File.dirname(a_gem) + filename = File.join(dir, "wrong_name-2-#{address}.gem") + FileUtils.cp a_gem, filename + installer = Gem::Installer.at filename, install_dir: @gemhome, force: true + spec = installer.install + + assert_nil spec.content_address + assert_equal "a-2", spec.full_name + end + + def test_content_address_not_set_with_only_required_ruby_version + _, a_gem = util_gem("a", 2) do |spec| + spec.required_ruby_version = ">= 3.0" + end + digest = Digest::SHA256.file(a_gem).hexdigest + address = digest[0, 8] + dir = File.dirname(a_gem) + filename = File.join(dir, "a-2-#{address}.gem") + FileUtils.cp a_gem, filename + installer = Gem::Installer.at filename, install_dir: @gemhome, force: true + spec = installer.install + + assert_nil spec.content_address + end + + def test_content_address_not_set_with_only_platform + _, a_gem = util_gem("a", 2) do |spec| + spec.platform = "x86_64-linux" + end + digest = Digest::SHA256.file(a_gem).hexdigest + address = digest[0, 8] + dir = File.dirname(a_gem) + filename = File.join(dir, "a-2-#{address}.gem") + FileUtils.cp a_gem, filename + installer = Gem::Installer.at filename, install_dir: @gemhome, force: true + spec = installer.install + + assert_nil spec.content_address + end + + def test_require_works_after_content_addressed_install + source_spec, a_gem = util_gem("a", 2) do |spec| + spec.files = ["lib/ca_activation_test.rb"] + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + end + FileUtils.rm_rf source_spec.gem_dir + + digest = Digest::SHA256.file(a_gem).hexdigest + address = digest[0, 8] + dir = File.dirname(a_gem) + filename = File.join(dir, "a-2-#{address}.gem") + FileUtils.cp a_gem, filename + + installer = Gem::Installer.at filename, install_dir: @gemhome, force: true + installer.install + + Gem::Specification.reset + + assert require "ca_activation_test" + end + + def test_reinstalling_content_addressed_gem_is_idempotent + source_spec, a_gem = util_gem("a", 2) do |spec| + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + end + FileUtils.rm_rf source_spec.gem_dir + + digest = Digest::SHA256.file(a_gem).hexdigest + address = digest[0, 8] + dir = File.dirname(a_gem) + filename = File.join(dir, "a-2-#{address}.gem") + FileUtils.cp a_gem, filename + installer = Gem::Installer.at filename, install_dir: @gemhome, force: true + installer.install + + installer2 = Gem::Installer.at filename, install_dir: @gemhome, force: true + spec2 = installer2.install + + assert_equal "a-2-#{address}", spec2.full_name + assert_path_exist File.join(@gemhome, "gems", "a-2-#{address}") + assert_path_exist File.join(@gemhome, "specifications", "a-2-#{address}.gemspec") + assert_equal 1, Dir[File.join(@gemhome, "gems", "a-2*")].size + assert_equal 1, Dir[File.join(@gemhome, "specifications", "a-2*.gemspec")].size + end + + def test_install_assigns_content_address_from_filename_with_full_sha + _, a_gem = util_gem("a", 2) do |spec| + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + end + + digest = Digest::SHA256.file(a_gem).hexdigest + dir = File.dirname(a_gem) + filename = File.join(dir, "a-2-#{digest}.gem") + FileUtils.cp a_gem, filename + installer = Gem::Installer.at filename, install_dir: @gemhome, force: true + spec = installer.install + + assert_equal digest, spec.content_address + assert_equal "a-2-#{digest}", spec.full_name + assert_path_exist File.join(@gemhome, "gems", "a-2-#{digest}") + assert_path_exist File.join(@gemhome, "cache", "a-2-#{digest}.gem") + assert_path_exist File.join(@gemhome, "specifications", "a-2-#{digest}.gemspec") + end + + def test_two_content_addressed_gems_with_same_name_version_coexist + _, gem1 = util_gem("a", 2) do |spec| + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + spec.summary = "variant 1" + end + gem1_backup = File.join(@tempdir, "gem1_backup.gem") + FileUtils.cp gem1, gem1_backup + + _, gem2 = util_gem("a", 2) do |spec| + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + spec.summary = "variant 2" + end + + FileUtils.rm_rf File.join(@gemhome, "gems", "a-2-x86_64-linux") + FileUtils.rm_rf File.join(@gemhome, "specifications", "a-2-x86_64-linux.gemspec") + Gem::Specification.reset + + dir = File.dirname(gem1) + digest1 = Digest::SHA256.file(gem1_backup).hexdigest + digest2 = Digest::SHA256.file(gem2).hexdigest + refute_equal digest1, digest2 + + address1 = digest1[0, 8] + address2 = digest2[0, 8] + file1 = File.join(dir, "a-2-#{address1}.gem") + file2 = File.join(dir, "a-2-#{address2}.gem") + FileUtils.cp gem1_backup, file1 + FileUtils.cp gem2, file2 + + Gem::Installer.at(file1, install_dir: @gemhome, force: true).install + Gem::Installer.at(file2, install_dir: @gemhome, force: true).install + + assert_path_exist File.join(@gemhome, "gems", "a-2-#{address1}") + assert_path_exist File.join(@gemhome, "gems", "a-2-#{address2}") + assert_path_exist File.join(@gemhome, "specifications", "a-2-#{address1}.gemspec") + assert_path_exist File.join(@gemhome, "specifications", "a-2-#{address2}.gemspec") + assert_equal 2, Dir[File.join(@gemhome, "gems", "a-2-*")].size + assert_equal 2, Dir[File.join(@gemhome, "specifications", "a-2-*.gemspec")].size + end + def test_install installer = util_setup_installer diff --git a/test/rubygems/test_gem_name_tuple.rb b/test/rubygems/test_gem_name_tuple.rb index 4876737c83db..7d6b4fd32438 100644 --- a/test/rubygems/test_gem_name_tuple.rb +++ b/test/rubygems/test_gem_name_tuple.rb @@ -46,11 +46,108 @@ def test_platform_normalization assert_equal a.hash, b.hash end + def test_content_addressable_metadata + n = Gem::NameTuple.new "a", Gem::Version.new(1), "x86_64-linux", content_address: "abcdef12", ruby_abi: "3.3" + + assert_equal "abcdef12", n.content_address + assert_equal "3.3", n.ruby_abi + assert_equal "a-1-abcdef12", n.full_name + end + + def test_to_a_includes_content_addressable_metadata + tuple = Gem::NameTuple.new "a", Gem::Version.new(1), "x86_64-linux", content_address: "abcdef12", ruby_abi: "3.3" + + assert_equal 5, tuple.to_a.length + assert_equal ["a", Gem::Version.new(1), "x86_64-linux", "abcdef12", "3.3"], tuple.to_a + end + + def test_to_a_excludes_nil_content_addressable_metadata + tuple = Gem::NameTuple.new "a", Gem::Version.new(1), "x86_64-linux" + + assert_equal 3, tuple.to_a.length + assert_equal ["a", Gem::Version.new(1), "x86_64-linux"], tuple.to_a + end + + def test_to_basic_excludes_content_addressable_metadata + tuples = [ + Gem::NameTuple.new("a", Gem::Version.new(1), "x86_64-linux", content_address: "abcdef12", ruby_abi: "3.3"), + Gem::NameTuple.new("b", Gem::Version.new(2), "ruby"), + ] + + basic = Gem::NameTuple.to_basic tuples + + assert_equal [["a", Gem::Version.new(1), "x86_64-linux"], + ["b", Gem::Version.new(2), "ruby"]], basic + basic.each {|row| assert_equal 3, row.length } + end + + def test_from_list_serialized_form_omits_content_addressable_metadata + serialized = Gem::NameTuple.from_list([["a", Gem::Version.new(1), "x86_64-linux"]]).first + assert_equal "a", serialized.name + assert_equal Gem::Version.new(1), serialized.version + assert_equal "x86_64-linux", serialized.platform + assert_nil serialized.content_address + assert_nil serialized.ruby_abi + end + + def test_from_list_full_form_preserves_content_addressable_metadata + full = Gem::NameTuple.from_list([["a", Gem::Version.new(1), "x86_64-linux", "abcdef12", "3.3"]]).first + assert_equal "a", full.name + assert_equal Gem::Version.new(1), full.version + assert_equal "x86_64-linux", full.platform + assert_equal "abcdef12", full.content_address + assert_equal "3.3", full.ruby_abi + end + + def test_from_list_passes_name_tuple_objects_through + original = Gem::NameTuple.new "a", Gem::Version.new(1), "x86_64-linux", content_address: "abcdef12", ruby_abi: "3.3" + rebuilt = Gem::NameTuple.from_list([original]).first + assert_equal original, rebuilt + assert_equal "abcdef12", rebuilt.content_address + assert_equal "3.3", rebuilt.ruby_abi + end + + def test_from_list_raises_for_invalid_array_length + error = assert_raise(ArgumentError) do + Gem::NameTuple.from_list([["a", Gem::Version.new(1)]]) + end + assert_match "Expected a 3- or 5-element tuple, got 2", error.message + end + + def test_from_list_raises_for_non_array_input + error = assert_raise(ArgumentError) do + Gem::NameTuple.from_list(["not an array"]) + end + assert_match "Expected a Gem::NameTuple or Array, got String", error.message + end + + def test_non_content_addressable_tuple_does_not_store_nil_content_addressable_metadata_ivars + n = Gem::NameTuple.new "a", Gem::Version.new(1), "x86_64-linux" + + refute_includes n.instance_variables, :@content_address + refute_includes n.instance_variables, :@ruby_abi + assert_nil n.content_address + assert_nil n.ruby_abi + end + + def test_sort_mixed_non_content_addressable_and_content_addressable_tuples + non_content_addressable = Gem::NameTuple.new "a", Gem::Version.new(1), "x86_64-linux" + content_addressable = Gem::NameTuple.new "a", Gem::Version.new(1), "x86_64-linux", content_address: "abcdef12", ruby_abi: "3.3" + + assert_equal [non_content_addressable, content_addressable], [content_addressable, non_content_addressable].sort + end + def test_spec_name n = Gem::NameTuple.new "a", Gem::Version.new(0), "ruby" assert_equal "a-0.gemspec", n.spec_name end + def test_content_addressable_spec_name + n = Gem::NameTuple.new "a", Gem::Version.new(1), "x86_64-linux", content_address: "abcdef12", ruby_abi: "3.3" + + assert_equal "a-1-abcdef12.gemspec", n.spec_name + end + def test_spaceship a = Gem::NameTuple.new "a", Gem::Version.new(0), Gem::Platform::RUBY a_p = Gem::NameTuple.new "a", Gem::Version.new(0), Gem::Platform.local @@ -61,6 +158,9 @@ def test_spaceship def test_deconstruct name_tuple = Gem::NameTuple.new "rails", Gem::Version.new("7.0.0"), "ruby" assert_equal ["rails", Gem::Version.new("7.0.0"), "ruby"], name_tuple.deconstruct + + ca_tuple = Gem::NameTuple.new "rails", Gem::Version.new("7.0.0"), "x86_64-linux", content_address: "abcdef12", ruby_abi: "3.3" + assert_equal ["rails", Gem::Version.new("7.0.0"), "x86_64-linux", "abcdef12", "3.3"], ca_tuple.deconstruct end def test_deconstruct_keys @@ -94,4 +194,35 @@ def test_pattern_matching_hash end assert_equal "7.0.0", result end + + def test_hash_distinguishes_content_addressable_variants + base = Gem::NameTuple.new "a", Gem::Version.new(1), "x86_64-linux" + ca1 = Gem::NameTuple.new "a", Gem::Version.new(1), "x86_64-linux", content_address: "abcdef12", ruby_abi: "3.3" + ca2 = Gem::NameTuple.new "a", Gem::Version.new(1), "x86_64-linux", content_address: "deadbeef", ruby_abi: "3.3" + + assert_equal [ca1.name, ca1.version, ca1.platform, ca1.content_address, ca1.ruby_abi].hash, ca1.hash + refute_equal base.hash, ca1.hash + refute_equal ca1.hash, ca2.hash + end + + def test_array_equality_backward_compatible_for_non_content_addressable + tuple = Gem::NameTuple.new "a", Gem::Version.new(1), "x86_64-linux" + + assert_equal tuple, ["a", Gem::Version.new(1), "x86_64-linux"] + end + + def test_array_equality_requires_full_form_for_content_addressable + tuple = Gem::NameTuple.new "a", Gem::Version.new(1), "x86_64-linux", content_address: "abcdef12", ruby_abi: "3.3" + + refute_equal tuple, ["a", Gem::Version.new(1), "x86_64-linux"] + assert_equal tuple, ["a", Gem::Version.new(1), "x86_64-linux", "abcdef12", "3.3"] + end + + def test_content_addressable_tuples_with_different_addresses_are_distinct + first = Gem::NameTuple.new "a", Gem::Version.new(1), "x86_64-linux", content_address: "abcdef12", ruby_abi: "3.3" + second = Gem::NameTuple.new "a", Gem::Version.new(1), "x86_64-linux", content_address: "12345678", ruby_abi: "3.4" + + refute_equal first, second + refute_equal first.hash, second.hash + end end diff --git a/test/rubygems/test_gem_package.rb b/test/rubygems/test_gem_package.rb index 7b8ac4736d2f..fe5505a70afb 100644 --- a/test/rubygems/test_gem_package.rb +++ b/test/rubygems/test_gem_package.rb @@ -236,6 +236,242 @@ def test_add_files_symlink assert_equal [{ "lib/code_sym.rb" => "code.rb" }, { "lib/code_sym2.rb" => "../lib/code.rb" }], symlinks end + def test_ruby_abi_creates_content_addressed_file + spec = Gem::Specification.new "platformed", "1" + spec.summary = "platformed" + spec.authors = "platformed" + spec.files = ["lib/code.rb"] + spec.platform = "arm64-darwin" + spec.required_ruby_version = Gem::Requirement.new("~> 3.4.0") + + FileUtils.mkdir "lib" + + File.open "lib/code.rb", "w" do |io| + io.write "# lib/code.rb" + end + + built_file = Gem::Package.build(spec, false, false, nil, "3.4") + + assert_path_not_exist spec.file_name + assert_path_exist built_file + assert_match(/\Aplatformed-1-[0-9a-f]{8}\.gem\z/, built_file) + end + + def test_ruby_abi_built_gem_preserves_derived_metadata + spec = Gem::Specification.new "platformed", "1" + spec.summary = "platformed" + spec.authors = "platformed" + spec.files = ["lib/code.rb"] + spec.platform = "arm64-darwin" + spec.required_ruby_version = Gem::Requirement.new("~> 3.4.0") + + FileUtils.mkdir "lib" + + File.open "lib/code.rb", "w" do |io| + io.write "# lib/code.rb" + end + + built_file = Gem::Package.build(spec, false, false, nil, "3.4") + + loaded_spec = Gem::Package.new(built_file).spec + assert_equal "platformed", loaded_spec.name + assert_equal Gem::Version.new("1"), loaded_spec.version + assert_equal Gem::Platform.new("arm64-darwin"), loaded_spec.platform + assert_equal Gem::Requirement.new("~> 3.4.0"), loaded_spec.required_ruby_version + assert_equal "3.4", loaded_spec.ruby_abi + end + + def test_required_ruby_version_unchanged_after_successful_matching_build + spec = Gem::Specification.new "platformed", "1" + spec.summary = "platformed" + spec.authors = "platformed" + spec.files = ["lib/code.rb"] + spec.platform = "arm64-darwin" + spec.required_ruby_version = Gem::Requirement.new("~> 3.4.0") + + FileUtils.mkdir "lib" + + File.open "lib/code.rb", "w" do |io| + io.write "# lib/code.rb" + end + + original_rrv = spec.required_ruby_version + + Gem::Package.build(spec, false, false, nil, "3.4") + + assert_equal original_rrv, spec.required_ruby_version + assert_equal Gem::Requirement.new("~> 3.4.0"), spec.required_ruby_version + end + + def test_ruby_abi_not_passed_does_not_create_content_addressed_file + spec = Gem::Specification.new "platformed", "1" + spec.summary = "platformed" + spec.authors = "platformed" + spec.files = ["lib/code.rb"] + spec.platform = "arm64-darwin" + spec.required_ruby_version = Gem::Requirement.new("~> 3.4.0") + + FileUtils.mkdir "lib" + + File.open "lib/code.rb", "w" do |io| + io.write "# lib/code.rb" + end + + built_file = Gem::Package.build(spec) + + assert_path_exist built_file + assert_equal("platformed-1-arm64-darwin.gem", built_file) + end + + def test_required_ruby_version_is_set_by_ruby_abi_if_default + spec = Gem::Specification.new "platformed", "1" + spec.summary = "platformed" + spec.authors = "platformed" + spec.files = ["lib/code.rb"] + spec.platform = "arm64-darwin" + spec.required_ruby_version = Gem::Requirement.default + + FileUtils.mkdir "lib" + + File.open "lib/code.rb", "w" do |io| + io.write "# lib/code.rb" + end + + built_file = Gem::Package.build(spec, false, false, nil, "3.4") + + assert_path_exist built_file + assert_match(/\Aplatformed-1-[0-9a-f]{8}\.gem\z/, built_file) + assert_equal Gem::Requirement.new("~> 3.4.0"), spec.required_ruby_version + end + + def test_required_ruby_version_is_not_modified_if_build_fails + spec = Gem::Specification.new "platformed", "1" + spec.summary = "platformed" + spec.files = ["lib/code.rb"] + spec.platform = "arm64-darwin" + spec.required_ruby_version = Gem::Requirement.default + + FileUtils.mkdir "lib" + + File.open "lib/code.rb", "w" do |io| + io.write "# lib/code.rb" + end + + # missing authors makes validation during the build raise + assert_raise Gem::InvalidSpecificationException do + Gem::Package.build(spec, false, false, nil, "3.4") + end + + assert_equal Gem::Requirement.default, spec.required_ruby_version + end + + def test_raise_if_required_ruby_version_conflicts_with_ruby_abi + spec = Gem::Specification.new "platformed", "1" + spec.summary = "platformed" + spec.authors = "platformed" + spec.files = ["lib/code.rb"] + spec.platform = "arm64-darwin" + spec.required_ruby_version = Gem::Requirement.new("~> 3.5.0") + + FileUtils.mkdir "lib" + + File.open "lib/code.rb", "w" do |io| + io.write "# lib/code.rb" + end + + e = assert_raise ArgumentError do + Gem::Package.build(spec, false, false, nil, "3.4") + end + + assert_match "Cannot build gem for Ruby ABI 3.4 because required_ruby_version is set to ~> 3.5.0", e.message + assert_match "Please set required_ruby_version to \"~> 3.4.0\"", e.message + assert_equal Gem::Requirement.new("~> 3.5.0"), spec.required_ruby_version + end + + def test_raise_if_ruby_abi_is_not_in_x_y_format + spec = Gem::Specification.new "platformed", "1" + spec.summary = "platformed" + spec.authors = "platformed" + spec.files = ["lib/code.rb"] + spec.platform = "arm64-darwin" + spec.required_ruby_version = Gem::Requirement.new("~> 3.4.0") + + FileUtils.mkdir "lib" + + File.open "lib/code.rb", "w" do |io| + io.write "# lib/code.rb" + end + + e = assert_raise ArgumentError do + Gem::Package.build(spec, false, false, nil, "3.4.5") + end + + assert_match "Ruby ABI must be in X.Y format", e.message + end + + def test_raise_if_spec_is_non_platformed_but_ruby_abi_is_passed + spec = Gem::Specification.new "non-platformed", "1" + spec.summary = "non-platformed" + spec.authors = "non-platformed" + spec.files = ["lib/code.rb"] + spec.required_ruby_version = Gem::Requirement.new("~> 3.4") + + FileUtils.mkdir "lib" + + File.open "lib/code.rb", "w" do |io| + io.write "# lib/code.rb" + end + + e = assert_raise ArgumentError do + Gem::Package.build(spec, false, false, nil, "3.4") + end + + assert_match "no platform or a Ruby platform has been set", e.message + end + + def test_explicit_output_keeps_requested_filename + spec = Gem::Specification.new "explicit", "1" + spec.summary = "explicit" + spec.authors = "explicit" + spec.files = ["lib/code.rb"] + spec.platform = "arm64-darwin" + spec.required_ruby_version = Gem::Requirement.new("~> 3.4.0") + + FileUtils.mkdir "lib" + + File.open "lib/code.rb", "w" do |io| + io.write "# lib/code.rb" + end + + built_file = Gem::Package.build(spec, false, false, "explicit-output.gem") + + assert_path_exist built_file + assert_equal("explicit-output.gem", built_file) + end + + def test_explicit_output_and_ruby_abi_raises + spec = Gem::Specification.new "explicit", "1" + spec.summary = "explicit" + spec.authors = "explicit" + spec.files = ["lib/code.rb"] + spec.platform = "arm64-darwin" + spec.required_ruby_version = Gem::Requirement.default + + FileUtils.mkdir "lib" + + File.open "lib/code.rb", "w" do |io| + io.write "# lib/code.rb" + end + + e = assert_raise ArgumentError do + Gem::Package.build(spec, false, false, "explicit-output.gem", "3.4") + end + + assert_match "Cannot specify both a Ruby ABI and an output file name", e.message + assert_equal Gem::Requirement.default, spec.required_ruby_version + assert_path_not_exist "explicit-output.gem" + end + def test_build spec = Gem::Specification.new "build", "1" spec.summary = "build" diff --git a/test/rubygems/test_gem_package_task.rb b/test/rubygems/test_gem_package_task.rb index 6f322ad61e16..f03af2a3a39f 100644 --- a/test/rubygems/test_gem_package_task.rb +++ b/test/rubygems/test_gem_package_task.rb @@ -43,6 +43,47 @@ def test_gem_package RakeFileUtils.verbose_flag = original_rake_fileutils_verbosity end + def test_moves_filename_returned_by_build + gem = Gem::Specification.new do |g| + g.name = "pkgr" + g.version = "1.2.3" + g.platform = "arm64-darwin" + g.required_ruby_version = "~> 3.4.0" + + g.authors = %w[author] + g.files = %w[x] + g.summary = "summary" + end + + Rake.application = Rake::Application.new + + pkg = Gem::PackageTask.new(gem) do |p| + p.package_files << "y" + end + + assert_equal %w[x y], pkg.package_files + + Dir.chdir @tempdir do + FileUtils.touch "x" + FileUtils.touch "y" + + built_gem_file = "pkgr-1.2.3-01234567.gem" + + Gem::Package.stub :build, ->(_) { + FileUtils.touch built_gem_file + built_gem_file + } do + Rake.application["package"].invoke + end + + built_files = Dir["pkg/pkgr-1.2.3-*.gem"] + + assert_equal 1, built_files.length + assert_equal "pkg/pkgr-1.2.3-01234567.gem", built_files.first + assert_path_not_exist "pkg/pkgr-1.2.3-arm64-darwin.gem" + end + end + def test_gem_package_prints_to_stdout_by_default gem = Gem::Specification.new do |g| g.name = "pkgr" diff --git a/test/rubygems/test_gem_remote_fetcher.rb b/test/rubygems/test_gem_remote_fetcher.rb index c35da2fc5ae2..399ee9c3c5be 100644 --- a/test/rubygems/test_gem_remote_fetcher.rb +++ b/test/rubygems/test_gem_remote_fetcher.rb @@ -125,6 +125,29 @@ def test_download assert File.exist?(a1_cache_gem) end + def test_download_and_install_content_addressed_gem + require "digest" + + ca_spec, ca_gem = util_gem "a", "1" do |s| + s.required_ruby_version = ">= 3.0" + s.platform = "x86_64-linux" + end + + address = Digest::SHA256.file(ca_gem).hexdigest[0, 10] + ca_spec.content_address = address + gem_data = File.binread ca_gem + gem_url = "http://gems.example.com/gems/a-1-#{address}.gem" + fetcher = fake_fetcher(gem_url, gem_data) + + gem_path = fetcher.download(ca_spec, "http://gems.example.com") + installed_spec = Gem::Installer.at(gem_path, install_dir: @gemhome, force: true).install + + assert_equal gem_url, fetcher.paths.last + assert_equal address, installed_spec.content_address + assert_equal "a-1-#{address}", installed_spec.full_name + assert_path_exist installed_spec.full_gem_path + end + def test_download_with_auth a1_data = File.open @a1_gem, "rb", &:read a1_url = "http://user:password@gems.example.com/gems/a-1.gem" diff --git a/test/rubygems/test_gem_resolver.rb b/test/rubygems/test_gem_resolver.rb index 84ede36b6c85..c251899b488e 100644 --- a/test/rubygems/test_gem_resolver.rb +++ b/test/rubygems/test_gem_resolver.rb @@ -322,6 +322,102 @@ def test_picks_best_platform assert_resolves_to [a2_p1.spec], res end + def test_prefers_content_addressed_gem_for_same_platform + ca_spec = util_spec "a", "1" + spec = util_spec "a", "1" + + ca_spec.platform = Gem::Platform.local + ca_spec.content_address = "abc1234567" + spec.platform = Gem::Platform.local + + s = set(spec, ca_spec) + dependency = make_dep "a" + resolver = Gem::Resolver.new([dependency], s) + assert_resolves_to [ca_spec], resolver + end + + def test_falls_back_to_non_content_addressable_when_content_addressed_gem_requires_other_rubygems_version + ca_spec = util_spec "a", "1" + non_content_addressable_spec = util_spec "a", "1" + + ca_spec.platform = Gem::Platform.local + ca_spec.content_address = "abc1234567" + ca_spec.required_rubygems_version = ">= 999" + non_content_addressable_spec.platform = Gem::Platform.local + + s = set(non_content_addressable_spec, ca_spec) + dependency = make_dep "a" + resolver = Gem::Resolver.new([dependency], s) + assert_resolves_to [non_content_addressable_spec], resolver + end + + def test_falls_back_to_source_when_content_addressed_gem_requires_other_ruby + ca_spec = util_spec "a", "1" + source_spec = util_spec "a", "1" + + ca_spec.platform = Gem::Platform.local + ca_spec.content_address = "abc1234567" + ca_spec.required_ruby_version = ">= 999" + source_spec.platform = Gem::Platform::RUBY + + s = set(source_spec, ca_spec) + dependency = make_dep "a" + resolver = Gem::Resolver.new([dependency], s) + assert_resolves_to [source_spec], resolver + end + + def test_falls_back_to_non_content_addressable_before_source_when_content_addressed_gem_requires_other_ruby + ca_spec = util_spec "a", "1" + non_content_addressable_spec = util_spec "a", "1" + source_spec = util_spec "a", "1" + + ca_spec.platform = Gem::Platform.local + ca_spec.content_address = "abc1234567" + ca_spec.required_ruby_version = ">= 999" + non_content_addressable_spec.platform = Gem::Platform.local + source_spec.platform = Gem::Platform::RUBY + + s = set(source_spec, non_content_addressable_spec, ca_spec) + dependency = make_dep "a" + resolver = Gem::Resolver.new([dependency], s) + assert_resolves_to [non_content_addressable_spec], resolver + end + + def test_prefers_compatible_content_addressed_gem_when_multiple_abis_available + current_abi = "#{Gem.ruby_version.segments[0]}.#{Gem.ruby_version.segments[1]}" + + ca_compatible = util_spec "a", "1" + ca_incompatible = util_spec "a", "1" + + ca_compatible.platform = Gem::Platform.local + ca_compatible.content_address = "abc1234567" + ca_compatible.required_ruby_version = "~> #{current_abi}.0" + ca_incompatible.platform = Gem::Platform.local + ca_incompatible.content_address = "def1234567" + ca_incompatible.required_ruby_version = "~> 999.0.0" + + s = set(ca_incompatible, ca_compatible) + dependency = make_dep "a" + resolver = Gem::Resolver.new([dependency], s) + assert_resolves_to [ca_compatible], resolver + end + + def test_raises_when_only_content_addressed_gem_is_incompatible + ca_spec = util_spec "a", "1" + + ca_spec.platform = Gem::Platform.local + ca_spec.content_address = "abc1234567" + ca_spec.required_ruby_version = "~> 999.0.0" + + s = set(ca_spec) + dependency = make_dep "a" + resolver = Gem::Resolver.new([dependency], s) + + assert_raise Gem::DependencyResolutionError do + resolver.resolve + end + end + def test_does_not_pick_musl_variants_on_non_musl_linux util_set_arch "aarch64-linux" do is = Gem::Resolver::IndexSpecification diff --git a/test/rubygems/test_gem_resolver_api_set.rb b/test/rubygems/test_gem_resolver_api_set.rb index af855f1692ad..5df6eed82ea6 100644 --- a/test/rubygems/test_gem_resolver_api_set.rb +++ b/test/rubygems/test_gem_resolver_api_set.rb @@ -38,7 +38,7 @@ def test_find_all data = [ { name: "a", number: "1", - platform: "ruby", + suffix: "ruby", dependencies: [] }, ] @@ -55,17 +55,32 @@ def test_find_all assert_equal expected, set.find_all(a_dep) end + def test_find_all_content_addressed + spec_fetcher + + a_spec = util_ca_spec("a", "1", "ab12345678", ruby_abi: "3.3", platform: Gem::Platform.local.to_s) + util_setup_compact_index(a_spec) + + set = Gem::Resolver::APISet.new @dep_uri + a_dep = Gem::Resolver::DependencyRequest.new dep("a"), nil + spec = set.find_all(a_dep).first + + assert_equal "ab12345678", spec.content_address + assert_equal Gem::Platform.local, spec.platform + assert Gem::ContentAddress.match?(spec.content_address) + end + def test_find_all_prereleases spec_fetcher data = [ { name: "a", number: "1", - platform: "ruby", + suffix: "ruby", dependencies: [] }, { name: "a", number: "2.a", - platform: "ruby", + suffix: "ruby", dependencies: [] }, ] @@ -90,7 +105,7 @@ def test_find_all_cache data = [ { name: "a", number: "1", - platform: "ruby", + suffix: "ruby", dependencies: [] }, ] diff --git a/test/rubygems/test_gem_resolver_api_specification.rb b/test/rubygems/test_gem_resolver_api_specification.rb index 44d2ee254a37..d3fe3917ed0e 100644 --- a/test/rubygems/test_gem_resolver_api_specification.rb +++ b/test/rubygems/test_gem_resolver_api_specification.rb @@ -8,7 +8,7 @@ def test_initialize data = { name: "rails", number: "3.0.3", - platform: Gem::Platform.local.to_s, + suffix: Gem::Platform.local.to_s, dependencies: [ ["bundler", "~> 1.0"], ["railties", "= 3.0.3"], @@ -30,12 +30,64 @@ def test_initialize assert_nil spec.created_at end + def test_initialize_content_address + set = Gem::Resolver::APISet.new + data = { + name: "rails", + number: "3.0.3", + suffix: "abc1234567", + dependencies: [], + requirements: { platform: ["= #{Gem::Platform.local}"] }, + } + + spec = Gem::Resolver::APISpecification.new set, data + + assert_equal "abc1234567", spec.content_address + assert_equal Gem::Platform.local, spec.platform + assert Gem::ContentAddress.match?(spec.content_address) + assert_equal "abc1234567", spec.spec.content_address + end + + def test_initialize_does_not_treat_non_content_address_suffix_as_content_addressed + set = Gem::Resolver::APISet.new + data = { + name: "rails", + number: "3.0.3", + suffix: Gem::Platform.local.to_s, + dependencies: [], + requirements: { platform: ["= #{Gem::Platform.local}"] }, + } + + spec = Gem::Resolver::APISpecification.new set, data + + assert_nil spec.content_address + refute Gem::ContentAddress.match?(spec.content_address) + assert_equal Gem::Platform.local, spec.platform + end + + def test_content_addressed_specs_with_different_addresses_are_distinct + set = Gem::Resolver::APISet.new + data = { + name: "rails", + number: "3.0.3", + suffix: "abc1234567", + dependencies: [], + requirements: { platform: ["= #{Gem::Platform.local}"] }, + } + + first = Gem::Resolver::APISpecification.new set, data + second = Gem::Resolver::APISpecification.new set, data.merge(suffix: "def1234567") + + refute_equal first, second + refute_equal first.hash, second.hash + end + def test_initialize_created_at set = Gem::Resolver::APISet.new data = { name: "rails", number: "3.0.3", - platform: "ruby", + suffix: "ruby", dependencies: [], requirements: { created_at: ["2026-06-05T10:30:45Z"] }, } @@ -50,7 +102,7 @@ def test_initialize_created_at_invalid data = { name: "rails", number: "3.0.3", - platform: "ruby", + suffix: "ruby", dependencies: [], requirements: { created_at: ["not a timestamp"] }, } @@ -65,7 +117,7 @@ def test_initialize_created_at_non_iso8601 data = { name: "rails", number: "3.0.3", - platform: "ruby", + suffix: "ruby", dependencies: [], requirements: { created_at: ["2026"] }, } @@ -133,7 +185,7 @@ def test_fetch_development_dependencies data = { name: "rails", number: "3.0.3", - platform: "ruby", + suffix: "ruby", dependencies: [ ["bundler", "~> 1.0"], ["railties", "= 3.0.3"], @@ -155,12 +207,42 @@ def test_fetch_development_dependencies assert_equal expected, spec.dependencies end + def test_fetch_development_dependencies_for_content_addressed_spec + fetched_tuple = nil + fetched_spec = util_spec "rails", "3.0.3" do |s| + s.add_development_dependency "a", "= 1" + end + + source = Object.new + source.define_singleton_method(:fetch_spec) do |tuple| + fetched_tuple = tuple + fetched_spec + end + + set = Gem::Resolver::APISet.new + set.instance_variable_set :@source, source + data = { + name: "rails", + number: "3.0.3", + suffix: "abc1234567", + dependencies: [], + requirements: { platform: ["= #{Gem::Platform.local}"] }, + } + + spec = Gem::Resolver::APISpecification.new set, data + + spec.fetch_development_dependencies + + assert_equal "rails-3.0.3-abc1234567.gemspec", fetched_tuple.spec_name + assert_equal [Gem::Dependency.new("a", "= 1", :development)], spec.dependencies + end + def test_installable_platform_eh set = Gem::Resolver::APISet.new data = { name: "a", number: "1", - platform: "ruby", + suffix: "ruby", dependencies: [], } @@ -171,7 +253,7 @@ def test_installable_platform_eh data = { name: "b", number: "1", - platform: "cpu-other_platform-1", + suffix: "cpu-other_platform-1", dependencies: [], } @@ -182,7 +264,7 @@ def test_installable_platform_eh data = { name: "c", number: "1", - platform: Gem::Platform.local.to_s, + suffix: Gem::Platform.local.to_s, dependencies: [], } @@ -196,7 +278,7 @@ def test_source data = { name: "a", number: "1", - platform: "ruby", + suffix: "ruby", dependencies: [], } @@ -215,7 +297,7 @@ def test_spec data = { name: "a", number: "1", - platform: "ruby", + suffix: "ruby", dependencies: [], } @@ -239,7 +321,7 @@ def test_spec_jruby_platform data = { name: "j", number: "1", - platform: "jruby", + suffix: "jruby", dependencies: [], } diff --git a/test/rubygems/test_gem_resolver_index_specification.rb b/test/rubygems/test_gem_resolver_index_specification.rb index ed9475f0cf81..3f09844f8f92 100644 --- a/test/rubygems/test_gem_resolver_index_specification.rb +++ b/test/rubygems/test_gem_resolver_index_specification.rb @@ -32,6 +32,21 @@ def test_initialize_platform assert_equal Gem::Platform.local, spec.platform end + def test_content_addressed_specs_with_different_addresses_are_distinct + set = Gem::Resolver::IndexSet.new + source = Gem::Source::Local.new + version = Gem::Version.new "3.0.3" + first = Gem::Resolver::IndexSpecification.new( + set, "rails", version, source, Gem::Platform.local, content_address: "abc1234567" + ) + second = Gem::Resolver::IndexSpecification.new( + set, "rails", version, source, Gem::Platform.local, content_address: "def1234567" + ) + + refute_equal first, second + refute_equal first.hash, second.hash + end + def test_install spec_fetcher do |fetcher| fetcher.gem "a", 2 diff --git a/test/rubygems/test_gem_safe_marshal.rb b/test/rubygems/test_gem_safe_marshal.rb index 1937c02be0cb..c34d8570c6ec 100644 --- a/test/rubygems/test_gem_safe_marshal.rb +++ b/test/rubygems/test_gem_safe_marshal.rb @@ -344,6 +344,41 @@ def test_rational end end + def test_name_tuple_unmarshal_content_addressable_metadata + tuple = Gem::NameTuple.new( + "a", + Gem::Version.new("1"), + "x86_64-linux", + content_address: "abcdef12", + ruby_abi: "3.3" + ) + + unmarshalled_tuple = Gem::SafeMarshal.safe_load(Marshal.dump(tuple)) + + assert_equal "a", unmarshalled_tuple.name + assert_equal Gem::Version.new("1"), unmarshalled_tuple.version + assert_equal "x86_64-linux", unmarshalled_tuple.platform + assert_equal "abcdef12", unmarshalled_tuple.content_address + assert_equal "3.3", unmarshalled_tuple.ruby_abi + assert_equal "a-1-abcdef12", unmarshalled_tuple.full_name + end + + def test_name_tuple_unmarshal_legacy_payload_without_content_addressable_metadata + tuple = Gem::NameTuple.allocate + tuple.instance_variable_set :@name, "a" + tuple.instance_variable_set :@version, Gem::Version.new("1") + tuple.instance_variable_set :@platform, "x86_64-linux" + + unmarshalled_tuple = Gem::SafeMarshal.safe_load(Marshal.dump(tuple)) + + assert_equal "a", unmarshalled_tuple.name + assert_equal Gem::Version.new("1"), unmarshalled_tuple.version + assert_equal "x86_64-linux", unmarshalled_tuple.platform + assert_nil unmarshalled_tuple.content_address + assert_nil unmarshalled_tuple.ruby_abi + assert_equal "a-1-x86_64-linux", unmarshalled_tuple.full_name + end + def test_gem_spec_unmarshall_license spec = Gem::Specification.new do |s| s.name = "hi" diff --git a/test/rubygems/test_gem_source.rb b/test/rubygems/test_gem_source.rb index e63a5e61fa7c..2bbb55360765 100644 --- a/test/rubygems/test_gem_source.rb +++ b/test/rubygems/test_gem_source.rb @@ -168,6 +168,109 @@ def test_load_specs_compact_index assert File.exist?(File.join(cache_dir, "versions")), "versions cache file does not exist" end + def test_load_specs_compact_index_content_addressable_metadata + spec = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") + util_setup_compact_index(spec) + + specs = @source.load_specs(:released) + refute @fetcher.requests.any? {|req| req.path.end_with?("/info/a") } + + spec = @source.decode_content_addressable_tuples(specs).first + + assert_equal "a-1-abcdef12", spec.full_name + assert_equal "x86_64-linux", spec.platform + assert_equal "abcdef12", spec.content_address + assert_equal "3.3", spec.ruby_abi + end + + def test_load_specs_compact_index_skips_content_addressable_rows_without_metadata + versions_body = +"created_at: 2026-01-01T00:00:00Z\n---\na 1-abcdef12 0000\n" + versions_response = util_compact_index_response(versions_body) + versions_response.uri = Gem::URI("#{@gem_repo}versions") + @fetcher.data["#{@gem_repo}versions"] = versions_response + @fetcher.data["#{@gem_repo}info/a"] = util_compact_index_response("---\n") + + specs = @source.load_specs(:released) + + assert_empty @source.decode_content_addressable_tuples(specs) + end + + def test_load_specs_compact_index_skips_content_addressable_rows_without_required_platform + versions_body = +"created_at: 2026-01-01T00:00:00Z\n---\na 1-abcdef12 0000\n" + versions_response = util_compact_index_response(versions_body) + versions_response.uri = Gem::URI("#{@gem_repo}versions") + @fetcher.data["#{@gem_repo}versions"] = versions_response + @fetcher.data["#{@gem_repo}info/a"] = util_compact_index_response("---\n1-abcdef12 |checksum:123,ruby:~> 3.3.0\n") + + specs = @source.load_specs(:released) + + assert_empty @source.decode_content_addressable_tuples(specs) + end + + def test_load_specs_compact_index_does_not_infer_ruby_abi_from_broad_ruby_requirement + spec = util_ca_spec("a", "1", "abcdef12", required_ruby_version: ">= 3.3", platform: "x86_64-linux") + util_setup_compact_index(spec) + + spec = @source.decode_content_addressable_tuples(@source.load_specs(:released)).first + + assert_equal "x86_64-linux", spec.platform + assert_equal "abcdef12", spec.content_address + assert_nil spec.ruby_abi + end + + def test_load_specs_compact_index_latest_keeps_content_addressable_ruby_abi_variants + a1 = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") + a2 = util_ca_spec("a", "1", "fedcba98", ruby_abi: "3.4", platform: "x86_64-linux") + util_setup_compact_index(a1, a2) + + specs = @source.decode_content_addressable_tuples(@source.load_specs(:latest)) + + assert_equal %w[a-1-abcdef12 a-1-fedcba98], specs.map(&:full_name).sort + assert_equal %w[3.3 3.4], specs.map(&:ruby_abi).sort + end + + def test_decode_content_addressable_tuples_latest_groups_by_gem_name_before_platform + aa = util_ca_spec("aa", "7.1", "aaaa1111", ruby_abi: "3.3", platform: "x86_64-linux") + ab = util_ca_spec("ab", "1.5", "bbbb2222", ruby_abi: "3.3", platform: "x86_64-linux") + ac = util_ca_spec("ac", "6.4", "cccc3333", ruby_abi: "3.3", platform: "x86_64-linux") + ad = util_ca_spec("ad", "1.16", "dddd4444", ruby_abi: "3.3", platform: "x86_64-linux") + util_setup_compact_index(aa, ab, ac, ad) + + specs = @source.decode_content_addressable_tuples(@source.load_specs(:released), latest: true) + + assert_equal %w[aa-7.1-aaaa1111 ab-1.5-bbbb2222 ac-6.4-cccc3333 ad-1.16-dddd4444], specs.map(&:full_name).sort + end + + def test_load_specs_compact_index_decodes_mixed_content_addressable_and_platform_entries + a1_ca = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") + a1_platform = util_spec("a", "1") {|s| s.platform = Gem::Platform.new("x86_64-linux") } + util_setup_compact_index(a1_ca, a1_platform) + + specs = @source.load_specs(:released) + decoded = @source.decode_content_addressable_tuples(specs) + + assert_equal 2, decoded.size + ca_spec = decoded.find {|s| s.content_address == "abcdef12" } + platform_spec = decoded.find {|s| s.content_address.nil? } + assert_equal "a-1-abcdef12", ca_spec.full_name + assert_equal "x86_64-linux", ca_spec.platform + assert_equal "3.3", ca_spec.ruby_abi + assert_equal "a-1-x86_64-linux", platform_spec.full_name + assert_nil platform_spec.ruby_abi + end + + def test_load_specs_compact_index_skips_content_addressable_rows_without_ruby_field + versions_body = +"created_at: 2026-01-01T00:00:00Z\n---\na 1-abcdef12 0000\n" + versions_response = util_compact_index_response(versions_body) + versions_response.uri = Gem::URI("#{@gem_repo}versions") + @fetcher.data["#{@gem_repo}versions"] = versions_response + @fetcher.data["#{@gem_repo}info/a"] = util_compact_index_response("---\n1-abcdef12 |checksum:123,platform:= x86_64-linux\n") + + specs = @source.decode_content_addressable_tuples(@source.load_specs(:released)) + + assert_empty specs + end + def test_load_specs_compact_index_latest_per_platform a1 = util_spec "a", "1" a2_java = util_spec "a", "2" do |s| @@ -231,6 +334,16 @@ def test_created_at assert_nil @source.created_at("c", v(1)) end + def test_created_at_for_tuple_uses_content_address + ca_spec = util_ca_spec "a", "1", "abcdef12", ruby_abi: "3.3" + util_setup_compact_index ca_spec, created_at: { + ca_spec.original_name => "2026-06-05T10:30:45Z", + } + + assert_nil @source.created_at("a", v(1), "x86_64-linux") + assert_equal Time.utc(2026, 6, 5, 10, 30, 45), @source.created_at_for_tuple(ca_spec.name_tuple) + end + def test_created_at_file_uri source = Gem::Source.new "file:///tmp/gems" diff --git a/test/rubygems/test_gem_source_local.rb b/test/rubygems/test_gem_source_local.rb index 606217362967..123411cf6868 100644 --- a/test/rubygems/test_gem_source_local.rb +++ b/test/rubygems/test_gem_source_local.rb @@ -25,6 +25,19 @@ def test_load_specs_released @sl.load_specs(:released).sort end + def test_load_specs_ignores_content_address_mismatch + _spec, ca_gem = util_gem("ca", "1.0.0", ruby_abi: "3.4") do |spec| + spec.required_ruby_version = "~> 3.4.0" + spec.platform = Gem::Platform.local + end + address = Gem::Package.new(ca_gem).content_address + mismatched_address = address.start_with?("0") ? "1#{address[1..]}" : "0#{address[1..]}" + FileUtils.mv ca_gem, File.join(@tempdir, "ca-1.0.0-#{mismatched_address}.gem") + + assert_equal [@a.name_tuple, @b.name_tuple].sort, + @sl.load_specs(:released).sort + end + def test_load_specs_prerelease assert_equal [@ap.name_tuple], @sl.load_specs(:prerelease) end diff --git a/test/rubygems/test_gem_spec_fetcher.rb b/test/rubygems/test_gem_spec_fetcher.rb index 1f7b5984c335..59d90a5cb5a7 100644 --- a/test/rubygems/test_gem_spec_fetcher.rb +++ b/test/rubygems/test_gem_spec_fetcher.rb @@ -122,6 +122,87 @@ def test_spec_for_dependency_platform spec_names end + def test_decode_content_addressable_tuples_decodes_source_tuples + spec_fetcher + + ca_spec = util_ca_spec "a", "1", "abcdef12", ruby_abi: "3.3" + util_setup_compact_index ca_spec + + ca_tuple = Gem::NameTuple.new("a", v(1), "abcdef12", content_address: "abcdef12") + ruby_tuple = tuple("b", v(1), "ruby") + + decoded = @sf.decode_content_addressable_tuples([[ca_tuple, @source], [ruby_tuple, @source]]) + + decoded_ca_tuple, decoded_ca_source = decoded.find {|decoded_tuple,| decoded_tuple.name == "a" } + decoded_ruby_tuple, decoded_ruby_source = decoded.find {|decoded_tuple,| decoded_tuple.name == "b" } + + assert_equal @source, decoded_ca_source + assert_equal "a-1-abcdef12", decoded_ca_tuple.full_name + assert_equal "x86_64-linux", decoded_ca_tuple.platform + assert_equal "abcdef12", decoded_ca_tuple.content_address + assert_equal "3.3", decoded_ca_tuple.ruby_abi + + assert_equal @source, decoded_ruby_source + assert_equal ruby_tuple, decoded_ruby_tuple + end + + def test_decode_content_addressable_tuples_does_not_decode_non_content_addressable_gems + source = Object.new + original = [[tuple("a", v(1), "ruby"), source]] + + assert_equal original, @sf.decode_content_addressable_tuples(original) + end + + def test_search_for_dependency_decodes_content_addressable_tuples + spec_fetcher + util_set_arch "x86_64-linux" + + ruby_abi = Gem.ruby_version.segments.first(2).join(".") + other_abi = "#{Gem.ruby_version.segments[0] + 1}.0" + compatible = util_ca_spec "a", "1", "abcdef12", ruby_abi: ruby_abi + incompatible = util_ca_spec "a", "1", "fedcba98", ruby_abi: other_abi + util_setup_compact_index compatible, incompatible + + tuples, errors = @sf.search_for_dependency Gem::Dependency.new("a") + + assert_empty errors + assert_equal 1, tuples.length + + tuple, source = tuples.first + assert_equal @source, source + assert_equal "a-1-abcdef12", tuple.full_name + assert_equal "x86_64-linux", tuple.platform + assert_equal "abcdef12", tuple.content_address + assert_equal ruby_abi, tuple.ruby_abi + end + + def test_spec_for_dependency_preserves_content_address_from_tuple + spec_fetcher + util_set_arch "x86_64-linux" + + ruby_abi = Gem.ruby_version.segments.first(2).join(".") + ca_spec = util_ca_spec "a", "1", "abcdef12", ruby_abi: ruby_abi + util_setup_compact_index ca_spec + + fetched_spec = util_spec "a", "1" do |s| + s.platform = "x86_64-linux" + s.required_ruby_version = ">= 3.0" + end + refute fetched_spec.content_address + @fetcher.data["#{@gem_repo}quick/Marshal.#{Gem.marshal_version}/#{ca_spec.spec_name}.rz"] = util_zip(Marshal.dump(fetched_spec)) + + dep = Gem::Dependency.new "a" + specs_and_sources, errors = @sf.spec_for_dependency dep + + assert_empty errors + assert_equal 1, specs_and_sources.length + + spec, source = specs_and_sources.first + assert_equal @source, source + assert_equal "abcdef12", spec.content_address + assert_equal "a-1-abcdef12", spec.full_name + end + def test_spec_for_dependency_mismatched_platform util_set_arch "hrpa-989" diff --git a/test/rubygems/test_gem_specification.rb b/test/rubygems/test_gem_specification.rb index c63e68be47dd..9757885f251e 100644 --- a/test/rubygems/test_gem_specification.rb +++ b/test/rubygems/test_gem_specification.rb @@ -1907,6 +1907,47 @@ def test_full_gem_path_double_slash assert_equal expected, @a1.full_gem_path end + def test_ruby_abi_derived_from_required_ruby_version + spec = Gem::Specification.new + spec.required_ruby_version = "~> 3.4.0" + assert_equal "3.4", spec.ruby_abi + end + + def test_ruby_abi_returns_nil_for_pessimistic_requirement_without_patch_segment + spec = Gem::Specification.new + spec.required_ruby_version = "~> 3.4" + assert_nil spec.ruby_abi + end + + def test_ruby_abi_returns_nil_for_pessimistic_requirement_with_nonzero_patch_segment + spec = Gem::Specification.new + spec.required_ruby_version = "~> 3.4.1" + assert_nil spec.ruby_abi + end + + def test_ruby_abi_returns_nil_for_non_single_ruby_abi_requirement + spec = Gem::Specification.new + spec.required_ruby_version = ["< 3.4", ">= 3.2"] + assert_nil spec.ruby_abi + end + + def test_ruby_abi_returns_nil_for_non_single_ruby_abi_requirement_with_dev_version + spec = Gem::Specification.new + spec.required_ruby_version = "~> 3.4.0.dev" + assert_nil spec.ruby_abi + end + + def test_ruby_abi_returns_nil_for_non_pessimistic_operator + spec = Gem::Specification.new + spec.required_ruby_version = ">= 3.4.0" + assert_nil spec.ruby_abi + end + + def test_ruby_abi_returns_nil_for_default_required_ruby_version + spec = Gem::Specification.new + assert_nil spec.ruby_abi + end + def test_full_name assert_equal "a-1", @a1.full_name @@ -1925,6 +1966,15 @@ def test_full_name assert_equal "a-1-x86-darwin-8", @a1.full_name end + def test_content_addressable_full_name + @a1 = Gem::Specification.new "a", 1 + @a1.required_ruby_version = ">= 3.0" + @a1.platform = "x86_64-linux" + @a1.content_address = "abcdef12" + assert_equal "a-1-abcdef12", @a1.full_name + assert_equal "x86_64-linux", @a1.platform.to_s + end + def test_full_name_windows test_cases = { "i386-mswin32" => "a-1-x86-mswin32-60", @@ -1951,6 +2001,21 @@ def test_hash refute_equal @a1.hash, @a2.hash end + def test_content_addressable_specs_are_distinct + first = Gem::Specification.new "a", 1 + first.required_ruby_version = ">= 3.0" + first.platform = "arm64-darwin" + first.content_address = "abcdef12" + + second = Gem::Specification.new "a", 1 + second.required_ruby_version = ">= 3.0" + second.platform = "arm64-darwin" + second.content_address = "12345678" + + refute_equal first, second + assert_equal 2, [first, second].uniq.size + end + def test_installed_by_version assert_equal v(0), @a1.installed_by_version @@ -2344,6 +2409,31 @@ def test_to_ruby assert_equal @a2, same_spec end + def test_to_ruby_content_addressable + spec = Gem::Specification.new "a", 1 + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + spec.content_address = "abcdef12" + spec.extensions = ["ext/a/extconf.rb"] + + ruby_code = spec.to_ruby + + expected_stub = <<~STUB.chomp + # stub: a 1 abcdef12 lib + # stub: ext/a/extconf.rb + # stub-target: platform=x86_64-linux + STUB + + assert_includes ruby_code, expected_stub + assert_includes ruby_code, "if s.respond_to? :content_address=" + + same_spec = eval ruby_code + + assert_equal "abcdef12", same_spec.content_address + assert_equal "x86_64-linux", same_spec.platform.to_s + assert_equal "a-1-abcdef12", same_spec.full_name + end + def test_to_ruby_with_rsa_key require "rubygems/openssl" pend "openssl is missing" unless defined?(OpenSSL::PKey::RSA) diff --git a/test/rubygems/test_gem_stub_specification.rb b/test/rubygems/test_gem_stub_specification.rb index 744ffa7d059e..1aa3b6532436 100644 --- a/test/rubygems/test_gem_stub_specification.rb +++ b/test/rubygems/test_gem_stub_specification.rb @@ -23,6 +23,34 @@ def test_initialize assert @foo.stubbed? end + def test_initialize_with_target + stub = stub_with_target + + assert_equal "stub_with_target", stub.name + assert_equal v(2), stub.version + assert_equal Gem::Platform.new("x86_64-linux"), stub.platform + assert_equal [stub.extension_dir, "lib"], stub.require_paths + assert_equal %w[ext/stub_with_target/extconf.rb], stub.extensions + assert_equal "ab12345678", stub.content_address + assert_equal "stub_with_target-2-ab12345678", stub.full_name + end + + def test_initialize_hex_suffix_without_target_is_not_content_addressable + stub = stub_without_target + + assert_nil stub.content_address + assert_equal Gem::Platform.new("ab12345678"), stub.platform + assert_equal "stub_without_target-2-ab12345678", stub.full_name + end + + def test_content_addressable_stubs_are_distinct + first = stub_with_target "ab12345678" + second = stub_with_target "cd12345678" + + refute_equal first, second + assert_equal 2, [first, second].uniq.size + end + def test_initialize_extension stub = stub_with_extension @@ -291,6 +319,54 @@ def stub_without_version end end + def stub_with_target(content_address = "ab12345678") + spec = File.join @gemhome, "specifications", "stub_with_target-#{content_address}.gemspec" + File.open spec, "w" do |io| + io.write <<~STUB + # -*- encoding: utf-8 -*- + # stub: stub_with_target 2 #{content_address} lib + # stub: ext/stub_with_target/extconf.rb + # stub-target: platform=x86_64-linux + + Gem::Specification.new do |s| + s.name = 'stub_with_target' + s.version = Gem::Version.new '2' + end + STUB + + io.flush + + stub = Gem::StubSpecification.gemspec_stub io.path, @gemhome, File.join(@gemhome, "gems") + + yield stub if block_given? + + return stub + end + end + + def stub_without_target(suffix = "ab12345678") + spec = File.join @gemhome, "specifications", "stub_without_target-#{suffix}.gemspec" + File.open spec, "w" do |io| + io.write <<~STUB + # -*- encoding: utf-8 -*- + # stub: stub_without_target 2 #{suffix} lib + + Gem::Specification.new do |s| + s.name = 'stub_without_target' + s.version = Gem::Version.new '2' + end + STUB + + io.flush + + stub = Gem::StubSpecification.gemspec_stub io.path, @gemhome, File.join(@gemhome, "gems") + + yield stub if block_given? + + return stub + end + end + def stub_with_extension spec = File.join @gemhome, "specifications", "stub_e-2.gemspec" File.open spec, "w" do |io| diff --git a/test/rubygems/test_gem_version_option.rb b/test/rubygems/test_gem_version_option.rb index 8b6e14fc4263..c737553c0eff 100644 --- a/test/rubygems/test_gem_version_option.rb +++ b/test/rubygems/test_gem_version_option.rb @@ -24,6 +24,26 @@ def test_add_version_option assert @cmd.handles?(%w[--version >1]) end + def test_add_ruby_abi_option + @cmd.add_ruby_abi_option + + @cmd.handle_options %w[--ruby-abi 3.4] + + assert_equal "3.4", @cmd.options[:ruby_abi] + end + + def test_ruby_abi_option_rejects_invalid_format + @cmd.add_ruby_abi_option + + ["3", "3.4.1", "abc", "3.x"].each do |invalid| + error = assert_raise Gem::OptionParser::InvalidArgument do + @cmd.handle_options ["--ruby-abi", invalid] + end + + assert_match(/Ruby ABI must be in X.Y format/, error.message) + end + end + def test_enables_prerelease @cmd.add_version_option