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 1bbde61965d6..a51b2ab94035 100644 --- a/Manifest.txt +++ b/Manifest.txt @@ -356,6 +356,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/cooldown_settings.rb diff --git a/Rakefile b/Rakefile index ae9874da96d2..674ec74ed87f 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 = "30703392778df8f2fb63d4cc1f45a64ffbeb7629" + 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/checksum.rb b/lib/bundler/checksum.rb index ce05818bb079..ead2e5ae6eb5 100644 --- a/lib/bundler/checksum.rb +++ b/lib/bundler/checksum.rb @@ -187,83 +187,98 @@ def inspect # However, if the new checksum is from a different source, we register like normal. # This ensures a mismatch error where there are multiple top level sources # that contain the same gem with different checksums. + # The store is keyed by full name rather than lock name because a + # content-addressable build and the ordinary platform build of the same + # name, version, and platform share a lock name while being different + # files with different checksums. def replace(spec, checksum) return unless checksum - lock_name = spec.lock_name + full_name = spec.full_name @store_mutex.synchronize do - existing = fetch_checksum(lock_name, checksum.algo) + existing = fetch_checksum(full_name, checksum.algo) if !existing || existing.same_source?(checksum) - store_checksum(lock_name, checksum) + store_checksum(full_name, checksum) else - merge_checksum(lock_name, checksum, existing) + merge_checksum(full_name, checksum, existing, spec.lock_name) end end end def missing?(spec) - @store[spec.lock_name].nil? + @store[spec.full_name].nil? end def empty?(spec) return false unless spec.source.is_a?(Bundler::Source::Rubygems) - @store[spec.lock_name].empty? + @store[spec.full_name].empty? end def register(spec, checksum) - register_checksum(spec.lock_name, checksum) + register_checksum(spec.full_name, checksum, spec.lock_name) end def merge!(other) - other.store.each do |lock_name, checksums| + other.store.each do |full_name, checksums| checksums.each do |_algo, checksum| - register_checksum(lock_name, checksum) + register_checksum(full_name, checksum) end end end def to_lock(spec) lock_name = spec.lock_name - checksums = @store[lock_name] - if checksums&.any? - "#{lock_name} #{checksums.values.map(&:to_lock).sort.join(",")}" + checksums = checksums_to_lock(platform_full_name(spec)) + if checksums + "#{lock_name} #{checksums}" else lock_name end end + def checksums_to_lock(full_name) + checksums = @store[full_name] + return unless checksums&.any? + + checksums.values.map(&:to_lock).sort.join(",") + end + private - def register_checksum(lock_name, checksum) + def platform_full_name(spec) + Gem::NameTuple.new(spec.name, spec.version, spec.platform).full_name + end + + def register_checksum(full_name, checksum, display_name = full_name) @store_mutex.synchronize do if checksum - existing = fetch_checksum(lock_name, checksum.algo) + existing = fetch_checksum(full_name, checksum.algo) if existing - merge_checksum(lock_name, checksum, existing) + merge_checksum(full_name, checksum, existing, display_name) else - store_checksum(lock_name, checksum) + store_checksum(full_name, checksum) end else - init_checksum(lock_name) + init_checksum(full_name) end end end - def merge_checksum(lock_name, checksum, existing) - existing.merge!(checksum) || raise(ChecksumMismatchError.new(lock_name, existing, checksum)) + def merge_checksum(full_name, checksum, existing, display_name = full_name) + existing.merge!(checksum) || raise(ChecksumMismatchError.new(display_name, existing, checksum)) end - def store_checksum(lock_name, checksum) - init_checksum(lock_name)[checksum.algo] = checksum + def store_checksum(full_name, checksum) + init_checksum(full_name)[checksum.algo] = checksum end - def init_checksum(lock_name) - @store[lock_name] ||= {} + def init_checksum(full_name) + @store[full_name] ||= {} end - def fetch_checksum(lock_name, algo) - @store[lock_name]&.fetch(algo, nil) + def fetch_checksum(full_name, algo) + @store[full_name]&.fetch(algo, nil) end end end diff --git a/lib/bundler/endpoint_specification.rb b/lib/bundler/endpoint_specification.rb index 9be06aac47c0..fa4b843d23f7 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.content_addressed_row?(suffix, @required_platform, @required_ruby_version) + @content_address = suffix + @platform = @required_platform + @required_rubygems_version ||= Gem::Requirement.default + else + @platform = Gem::Platform.new(suffix) + end end def insecurely_materialized? @@ -147,11 +156,13 @@ 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 - "#{base_dir}/specifications/#{full_name}.gemspec" + File.join(Gem::SpecificationRecord.specification_dir_for(self, base_dir), + "#{full_name}.gemspec") end def parse_metadata(data) @@ -183,6 +194,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 @@ -215,5 +228,12 @@ def parse_created_at(value) def build_dependency(name, requirements) Dependency.new(name, requirements) end + + def required_platform_from(value) + value = value.to_s + return if value.empty? + + Gem::Platform.new(value) + 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..409249aea938 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.content_addressed?(self, validate_ruby_abi: false) + "#{@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) @@ -116,6 +119,16 @@ def satisfies?(dependency) @name == dependency.name && effective_requirement.satisfied_by?(Gem::Version.new(@version)) end + ## + # Assigns the content address parsed from the lockfile's CONTENT + # ADDRESSES section. The full name embeds the content address, so its + # memoization must be invalidated. + + def content_address=(value) + @content_address = value + @full_name = nil + end + def to_lock out = String.new out << " #{lock_name}\n" @@ -192,6 +205,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_generator.rb b/lib/bundler/lockfile_generator.rb index f2b9afb388f6..5fecaf1bbf39 100644 --- a/lib/bundler/lockfile_generator.rb +++ b/lib/bundler/lockfile_generator.rb @@ -19,6 +19,7 @@ def generate! add_sources add_platforms add_dependencies + add_content_addresses add_checksums add_locked_ruby_version add_bundled_with @@ -66,10 +67,31 @@ def add_dependencies end end + def add_content_addresses + content_addresses = definition.resolve.filter_map do |spec| + next unless Gem::ContentAddress.content_addressed?(spec, validate_ruby_abi: false) + + line = "#{spec.lock_name} #{spec.content_address}" + + if definition.locked_checksums + checksums = spec.source.checksum_store.checksums_to_lock(spec.full_name) + line += " #{checksums}" if checksums + end + + line + end + + add_section("CONTENT ADDRESSES", content_addresses) unless content_addresses.empty? + end + def add_checksums return unless definition.locked_checksums - checksums = definition.resolve.map do |spec| - spec.source.checksum_store.to_lock(spec) + checksums = definition.resolve.filter_map do |spec| + line = spec.source.checksum_store.to_lock(spec) + + next if line == spec.lock_name && Gem::ContentAddress.content_addressed?(spec, validate_ruby_abi: false) + + line end add_section("CHECKSUMS", checksums + bundler_checksum) diff --git a/lib/bundler/lockfile_parser.rb b/lib/bundler/lockfile_parser.rb index 852fc631f3b1..94610f3c8db1 100644 --- a/lib/bundler/lockfile_parser.rb +++ b/lib/bundler/lockfile_parser.rb @@ -41,6 +41,7 @@ def to_s BUNDLED = "BUNDLED WITH" DEPENDENCIES = "DEPENDENCIES" CHECKSUMS = "CHECKSUMS" + CONTENT_ADDRESSES = "CONTENT ADDRESSES" PLATFORMS = "PLATFORMS" RUBY = "RUBY VERSION" GIT = "GIT" @@ -57,6 +58,7 @@ def to_s Gem::Version.create("1.12") => [RUBY].freeze, Gem::Version.create("1.13") => [PLUGIN].freeze, Gem::Version.create("2.5.0") => [CHECKSUMS].freeze, + Gem::Version.create("4.1.0") => [CONTENT_ADDRESSES].freeze, }.freeze KNOWN_SECTIONS = SECTIONS_BY_VERSION_INTRODUCED.values.flatten!.freeze @@ -140,6 +142,8 @@ def initialize(lockfile, strict: false, lockfile_path: nil) # for all gemfiles that don't already explicitly include the feature. @checksums = true @parse_method = :parse_checksum + elsif line == CONTENT_ADDRESSES + @parse_method = :parse_content_address elsif line == PLATFORMS @parse_method = :parse_platform elsif line == RUBY @@ -227,6 +231,16 @@ def parse_source(line) $ # Line end /xo + NAME_VERSION_CONTENT_ADDRESS = / + ^#{space}{2}(?!#{space}) # Exactly 2 spaces at the start of the line + (.*?) # Name + #{space}\(([^-]*) # Space, followed by version + -(.*)\) # Platform, always present for content-addressable gems + #{space}([0-9a-f]{8,64}) # Content address + (?:#{space}([^ ]+))? # Optional checksums + $ # Line end + /xo + def parse_dependency(line) return unless line =~ NAME_VERSION spaces = $1 @@ -268,7 +282,8 @@ def parse_checksum(line) 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) + full_name = name_tuple.full_name spec = @specs[full_name] if name == "bundler" @@ -280,10 +295,33 @@ def parse_checksum(line) checksums.split(",") do |lock_checksum| column = line.index(lock_checksum) + 1 checksum = Checksum.from_lock(lock_checksum, "#{@lockfile_path}:#{@pos.line}:#{column}") - spec.source.checksum_store.register(spec, checksum) + spec.source.checksum_store.register(name_tuple, checksum) end else - spec.source.checksum_store.register(spec, nil) + spec.source.checksum_store.register(name_tuple, nil) + end + end + + def parse_content_address(line) + return unless line =~ NAME_VERSION_CONTENT_ADDRESS + + name = -$1 + version = Gem::Version.new($2) + platform = Gem::Platform.new($3) + content_address = $4 + checksums = $5 + + spec = @specs[Gem::NameTuple.new(name, version, platform).full_name] + return unless spec + + spec.content_address = content_address + + return unless checksums + + checksums.split(",") do |lock_checksum| + column = line.index(lock_checksum) + 1 + checksum = Checksum.from_lock(lock_checksum, "#{@lockfile_path}:#{@pos.line}:#{column}") + spec.source.checksum_store.register(spec, checksum) end end diff --git a/lib/bundler/match_platform.rb b/lib/bundler/match_platform.rb index 11d510ba6c47..566067c905a0 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,32 @@ 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.content_addressed?(s, validate_ruby_abi: false) } + return matching if addressable.empty? + + compatible = addressable.select(&:matches_current_metadata?) + return compatible if compatible.any? + + non_addressable.any? ? non_addressable : matching + 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 diff --git a/lib/bundler/remote_specification.rb b/lib/bundler/remote_specification.rb index dcaaf6af2e61..cc6372e9e58e 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.content_addressed?(self, validate_ruby_abi: false) + "#{@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..730d1e522b4c 100644 --- a/lib/bundler/rubygems_ext.rb +++ b/lib/bundler/rubygems_ext.rb @@ -13,7 +13,53 @@ # `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.eligible?(spec, validate_ruby_abi: true) + false + end + + def self.content_addressed?(spec, validate_ruby_abi: true) + false + end + + def self.content_addressed_row?(suffix, platform, required_ruby_version = nil, validate_ruby_abi: true) + false + end + end + end + + # Can be removed once RubyGems 4.0.0 support is dropped + class SpecificationRecord + unless respond_to?(:specification_dir_for) + def self.specification_dir_for(spec, base_dir) + File.join(base_dir, "specifications") + end + end + + unless respond_to?(:dirs_from) + def self.dirs_from(paths) + paths.map {|path| File.join(path, "specifications") } + 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 +463,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,6 +473,15 @@ 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 if platform == Gem::Platform::RUBY "#{name} (#{version})" 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..491d81c1d575 100644 --- a/lib/bundler/rubygems_integration.rb +++ b/lib/bundler/rubygems_integration.rb @@ -111,7 +111,7 @@ def gem_cache def spec_cache_dirs @spec_cache_dirs ||= begin - dirs = gem_path.map {|dir| File.join(dir, "specifications") } + dirs = gem_path.flat_map {|dir| Gem::SpecificationRecord.dirs_from([dir]) } dirs << Gem.spec_cache_dir dirs.uniq.select {|dir| File.directory? dir } end @@ -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/runtime.rb b/lib/bundler/runtime.rb index 9d2ba5f1c377..b76e2b3e9830 100644 --- a/lib/bundler/runtime.rb +++ b/lib/bundler/runtime.rb @@ -166,7 +166,9 @@ def clean(dry_run = false) git_cache_dirs = SharedHelpers.glob_files_in_dir("cache/bundler/git/*", Gem.dir) gem_dirs = SharedHelpers.glob_files_in_dir("gems/*", Gem.dir) gem_files = SharedHelpers.glob_files_in_dir("cache/*.gem", Gem.dir) - gemspec_files = SharedHelpers.glob_files_in_dir("specifications/*.gemspec", Gem.dir) + gemspec_files = Gem::SpecificationRecord.dirs_from([Gem.dir]).flat_map do |dir| + SharedHelpers.glob_files_in_dir("*.gemspec", dir) + end extension_dirs = SharedHelpers.glob_files_in_dir("extensions/*/*/*", Gem.dir) + SharedHelpers.glob_files_in_dir("bundler/gems/extensions/*/*/*", Gem.dir) spec_gem_paths = [] # need to keep git sources around diff --git a/lib/bundler/source/rubygems.rb b/lib/bundler/source/rubygems.rb index 26e7b90426c4..897968b3e11b 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 @@ -617,6 +619,7 @@ def rubygems_gem_installer(spec, options) installer = Bundler::RubyGemsGemInstaller.at( path, + content_address: (spec.content_address if Gem::ContentAddress.content_addressed?(spec, validate_ruby_abi: false)), security_policy: Bundler.rubygems.security_policies[Bundler.settings["trust-policy"]], install_dir: rubygems_dir.to_s, bin_dir: Bundler.system_bindir.to_s, 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..0ff3beaea1f8 100644 --- a/lib/rubygems.rb +++ b/lib/rubygems.rb @@ -978,6 +978,13 @@ def self.ruby_version @ruby_version = Gem::Version.new version end + ## + # The ABI scope for the currently running Ruby. + + def self.ruby_abi + ruby_version.segments.first(2).join(".") + end + ## # A Gem::Version for the currently running RubyGems @@ -1136,11 +1143,22 @@ def self.load_plugin_files(plugins) # :nodoc: end ## - # Find rubygems plugin files in the standard location and load them + # Find rubygems plugin files in the standard location and load them. + # At most one stub is loaded per gem: a stub in the running Ruby's ABI + # directory wins over a root stub of the same name, since it was + # installed specifically for this Ruby. def self.load_plugins Gem.path.each do |gem_path| - load_plugin_files Gem::Util.glob_files_in_dir("*#{Gem.plugin_suffix_pattern}", plugindir(gem_path)) + abi_plugin_dir = File.join(plugindir(gem_path), ruby_abi) + abi_plugins = Gem::Util.glob_files_in_dir("*#{Gem.plugin_suffix_pattern}", abi_plugin_dir) + abi_plugin_names = abi_plugins.map {|plugin| File.basename(plugin) } + + root_plugins = Gem::Util.glob_files_in_dir("*#{Gem.plugin_suffix_pattern}", plugindir(gem_path)) + root_plugins.reject! {|plugin| abi_plugin_names.include?(File.basename(plugin)) } + + load_plugin_files root_plugins + load_plugin_files abi_plugins end end @@ -1411,6 +1429,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 f1a04fc180e8..85402a127531 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/contents_command.rb b/lib/rubygems/commands/contents_command.rb index d665646317d7..3040e65330c1 100644 --- a/lib/rubygems/commands/contents_command.rb +++ b/lib/rubygems/commands/contents_command.rb @@ -198,7 +198,7 @@ def spec_for(name) def specification_directories # :nodoc: options[:specdirs].flat_map do |i| - [i, File.join(i, "specifications")] + Gem::SpecificationRecord.dirs_with_abi([i, File.join(i, "specifications")]) end end end 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/lock_command.rb b/lib/rubygems/commands/lock_command.rb index f7fd5ada169b..c4d5cfa66b64 100644 --- a/lib/rubygems/commands/lock_command.rb +++ b/lib/rubygems/commands/lock_command.rb @@ -100,8 +100,8 @@ def execute end def spec_path(gem_full_name) - gemspecs = Gem.path.map do |path| - File.join path, "specifications", "#{gem_full_name}.gemspec" + gemspecs = Gem::SpecificationRecord.dirs_from(Gem.path).map do |spec_dir| + File.join spec_dir, "#{gem_full_name}.gemspec" end gemspecs.find {|path| File.exist? path } 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 ba2cc420c858..5ac3ca4234cb 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", " (FILE must be a JSON sigstore bundle)") do |value, options| @@ -57,7 +67,12 @@ def initialize end def execute - gem_name = get_one_gem_name + gem_name = if options[:platform] || options[:ruby_abi] + 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 @@ -94,6 +109,50 @@ def send_gem(name) private + def resolve_gem_name(names) + platform = options[:platform] && Gem::Platform.new(options[:platform]) + ruby_abi = options[:ruby_abi] + + 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 || spec.platform == platform) && + (!ruby_abi || (Gem::ContentAddress.eligible?(spec) && spec.ruby_abi == ruby_abi)) + 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 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 multiple_matches_message(matches) + message = "Multiple gems matched #{gem_name_selector_description}: #{matches.map(&:first).join(", ")}" + + if options[:platform] && !options[:ruby_abi] + ruby_abis = matches.filter_map {|_, spec| spec.ruby_abi }.uniq.sort + message += "\nSpecify --ruby-abi with one of: #{ruby_abis.join(", ")}" unless ruby_abis.empty? + message += "\nTo push a gem without a Ruby ABI, pass the exact filename." if matches.any? {|_, spec| spec.ruby_abi.nil? } + elsif options[:ruby_abi] && !options[:platform] + platforms = matches.map {|_, spec| spec.platform.to_s }.uniq.sort + message += "\nSpecify --platform with one of: #{platforms.join(", ")}" unless platforms.empty? + end + + message + 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..e815c45191c0 --- /dev/null +++ b/lib/rubygems/content_address.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +## +# Gem::ContentAddress is the single home for content-addressing semantics: +# what an address and a Ruby ABI look like, which specs are eligible, how +# addresses are generated and verified against gem files. +module Gem::ContentAddress + ## + # A content address is 8 to 64 lowercase hexadecimal characters -- a + # prefix of the SHA256 digest of the gem file contents. + + PATTERN = /\A[0-9a-f]{8,64}\z/ + + ## + # A Ruby ABI is a major and minor version pair ("X.Y"). + + RUBY_ABI_PATTERN = /\A\d+\.\d+\z/ + + private_constant :PATTERN, :RUBY_ABI_PATTERN + + ## + # Default number of hexadecimal characters in a generated content address. + + DEFAULT_LENGTH = 8 + + ## + # Whether +value+ is a valid content address (a string of 8-64 + # lowercase hexadecimal characters). This only checks the shape of a + # string: use content_addressed? to ask whether a spec is actually + # content addressed, and file_name_claim to ask whether a file name + # claims an address. + + def self.match?(value) + value.is_a?(String) && PATTERN.match?(value) + end + + ## + # Whether +value+ is a well-formed Ruby ABI ("X.Y"). + + def self.valid_ruby_abi?(value) + value.is_a?(String) && RUBY_ABI_PATTERN.match?(value) + end + + ## + # Derives the Ruby ABI ("X.Y") from +required_ruby_version+. Only a + # single pessimistic requirement with three segments ending in zero + # ("~> X.Y.0") pins an ABI. Returns nil for any other shape. + + def self.ruby_abi_for(required_ruby_version) + return nil if required_ruby_version.nil? + + requirements = required_ruby_version.requirements + return nil if requirements.size != 1 + + op, version = requirements.first + segments = version.segments + return nil if op != "~>" || segments.size != 3 || segments[2] != 0 + return nil unless segments[0].is_a?(Integer) && segments[1].is_a?(Integer) + + segments[0..1].join(".") + end + + ## + # The required_ruby_version that pins +ruby_abi+ ("X.Y" to "~> X.Y.0"). + # Inverse of +ruby_abi_for+. + + def self.ruby_abi_requirement(ruby_abi) + Gem::Requirement.new("~> #{ruby_abi}.0") + end + + ## + # Whether +platform+ is eligible for content addressing: present and + # not the generic RUBY platform. + + def self.platform_eligible?(platform) + !platform.nil? && platform != Gem::Platform::RUBY + end + + ## + # Whether +spec+ is eligible for content addressing. A gem must pin + # its required_ruby_version to a single Ruby ABI ("~> X.Y.0") and + # declare a non-RUBY platform to be content addressed. This makes + # `content_addressed? implies ruby_abi present` structural: no spec + # can count as content addressed without an ABI to scope it by. + + def self.eligible?(spec, validate_ruby_abi: true) + return false unless platform_eligible?(spec.platform) + return true unless validate_ruby_abi + + !ruby_abi_for(spec.required_ruby_version).nil? + end + + ## + # Whether +spec+ is content-addressed: it is eligible for content + # addressing and has a valid content address set. See eligible? for + # when to pass validate_ruby_abi: false. + + def self.content_addressed?(spec, validate_ruby_abi: true) + eligible?(spec, validate_ruby_abi: validate_ruby_abi) && match?(spec.content_address) + end + + ## + # Whether an index row describes a content-addressed gem: an + # address-shaped +suffix+, a pinned +platform+, and a + # +required_ruby_version+ pinning a single Ruby ABI. Rows missing any + # of these must not assign a content address, so specs cannot be + # constructed half content-addressed. See eligible? for when to pass + # validate_ruby_abi: false. + + def self.content_addressed_row?(suffix, platform, required_ruby_version = nil, validate_ruby_abi: true) + return false unless match?(suffix) && platform_eligible?(platform) + return true unless validate_ruby_abi + + !ruby_abi_for(required_ruby_version).nil? + end + + ## + # Whether +spec+'s required_ruby_version permits building for +ruby_abi+: + # an unset or default requirement can still be pinned to the ABI, and + # anything else must already pin exactly that ABI. Used at build time, + # before the requirement is injected, where eligible? would be + # premature. + + def self.ruby_abi_compatible?(spec, ruby_abi) + required_ruby_version = spec.required_ruby_version + return true if required_ruby_version.nil? || required_ruby_version.none? + + ruby_abi_for(required_ruby_version) == ruby_abi + end + + ## + # Generates the content address for +bytes+: the first +length+ + # characters of the hexadecimal SHA256 digest of the contents. + + def self.address_for(bytes, length: DEFAULT_LENGTH) + require "digest" + Digest::SHA256.hexdigest(bytes)[0, length] + end + + ## + # The content address claimed by a gem file name, or nil when the name + # makes no claim. +filename+ is the file's base name without the ".gem" + # extension ("name-version[-suffix]"). A name claims an address when its + # suffix is address-shaped and is not just +spec+'s own platform: a + # platform string that happens to look like hexadecimal (both + # normalized and original spellings) is a platform name, not a claim. + + def self.file_name_claim(filename, spec) + suffix = filename.delete_prefix("#{spec.name}-#{spec.version}-") + return nil if suffix == filename + return nil unless match?(suffix) + return nil if [spec.platform.to_s, spec.original_platform.to_s].include?(suffix) + + suffix + end + + ## + # Verifies the content address claimed by the gem file at +path+ against + # the SHA256 digest of its contents. Returns the verified address, or nil + # when the file name makes no claim. Raises Gem::InstallError when the + # contents do not match the claim, regardless of whether the packaged + # +spec+ is eligible, so swapped contents cannot hide behind an + # ineligible specification. + + def self.verified_file_name_claim(path, spec) + basename = File.basename(path, ".gem") + address = file_name_claim(basename, spec) + return nil unless address + + require "digest" + digest = Digest::SHA256.file(path).hexdigest + unless digest.start_with?(address) + raise Gem::InstallError, "content address mismatch for #{File.basename(path)}" + end + + address + 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/doctor.rb b/lib/rubygems/doctor.rb index 4f26260d836a..114946209b4b 100644 --- a/lib/rubygems/doctor.rb +++ b/lib/rubygems/doctor.rb @@ -115,6 +115,12 @@ def doctor_child(sub_directory, extension) # :nodoc: next if sub_directory == "specifications" && basename == "default" next if sub_directory == "plugins" && Gem.plugin_suffix_regexp =~ basename + if sub_directory == "specifications" && File.directory?(child) && + Gem::ContentAddress.valid_ruby_abi?(ent) + doctor_child(File.join(sub_directory, ent), extension) if ent == Gem.ruby_abi && !File.symlink?(child) + next + end + type = File.directory?(child) ? "directory" : "file" action = if @dry_run diff --git a/lib/rubygems/installer.rb b/lib/rubygems/installer.rb index 2599c0ff6ec8..b8d95df7ae7b 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 @@ -301,7 +308,11 @@ def install say clean_text(spec.post_install_message.to_s) if options[:post_install_message] && !spec.post_install_message.nil? - Gem::Specification.add_spec(spec) unless @install_dir + if incompatible_abi_install? + say "#{spec.full_name} is scoped to Ruby ABI #{spec.ruby_abi} and will not be visible to the running Ruby (ABI #{Gem.ruby_abi})" + else + Gem::Specification.add_spec(spec) unless @install_dir + end load_plugin unless options[:install_plugin] == false @@ -350,9 +361,11 @@ def installed_specs @installed_specs ||= begin specs = [] - Gem::Util.glob_files_in_dir("*.gemspec", File.join(gem_home, "specifications")).each do |path| - spec = Gem::Specification.load path - specs << spec if spec + Gem::SpecificationRecord.dirs_from([gem_home]).each do |dir| + Gem::Util.glob_files_in_dir("*.gemspec", dir).each do |path| + spec = Gem::Specification.load path + specs << spec if spec + end end specs @@ -388,7 +401,7 @@ def installation_satisfies_dependency?(dependency) # def spec_file - File.join gem_home, "specifications", "#{spec.full_name}.gemspec" + File.join Gem::SpecificationRecord.specification_dir_for(spec, gem_home), "#{spec.full_name}.gemspec" end def default_spec_dir @@ -412,7 +425,24 @@ def default_spec_file def write_spec spec.installed_by_version = Gem.rubygems_version - Gem.write_binary(spec_file, spec.to_ruby_for_cache) + spec_file = self.spec_file + spec_dir = File.dirname spec_file + dir_mode = options[:dir_mode] + content_addressed = Gem::ContentAddress.content_addressed?(spec) + + if File.directory? spec_dir + if content_addressed && dir_mode && !File.writable?(spec_dir) + File.chmod(0o755, spec_dir) + end + else + ensure_writable_dir spec_dir + end + + begin + Gem.write_binary(spec_file, spec.to_ruby_for_cache) + ensure + File.chmod(dir_mode, spec_dir) if dir_mode && content_addressed + end end ## @@ -966,6 +996,23 @@ def ensure_writable_dir(dir) # :nodoc: private + def incompatible_abi_install? + Gem::ContentAddress.content_addressed?(spec) && spec.ruby_abi != Gem.ruby_abi + end + + def assign_content_address + address = @package.content_address + expected = options[:content_address] + + if expected && address != expected + raise Gem::InstallError, "content address mismatch for #{spec.full_name}: " \ + "expected #{expected}, got #{address || "no content address"}" + end + + @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/installer_uninstaller_utils.rb b/lib/rubygems/installer_uninstaller_utils.rb index c5c2a52bab35..783ec8ac5754 100644 --- a/lib/rubygems/installer_uninstaller_utils.rb +++ b/lib/rubygems/installer_uninstaller_utils.rb @@ -10,11 +10,16 @@ def regenerate_plugins_for(spec, plugins_dir) require "pathname" - spec.plugins.each do |plugin| - plugin_script_path = File.join plugins_dir, "#{spec.name}_plugin#{File.extname(plugin)}" + plugin_script_dir = plugin_stub_dir_for(spec, plugins_dir) + + FileUtils.mkdir_p plugin_script_dir + remove_plugins_for(spec, plugins_dir) + + plugins.each do |plugin| + plugin_script_path = File.join plugin_script_dir, "#{spec.name}_plugin#{File.extname(plugin)}" File.open plugin_script_path, "wb" do |file| - file.puts "require_relative '#{Pathname.new(plugin).relative_path_from(Pathname.new(plugins_dir))}'" + file.puts "require_relative '#{Pathname.new(plugin).relative_path_from(Pathname.new(plugin_script_dir))}'" end verbose plugin_script_path @@ -23,5 +28,21 @@ def regenerate_plugins_for(spec, plugins_dir) def remove_plugins_for(spec, plugins_dir) FileUtils.rm_f Gem::Util.glob_files_in_dir("#{spec.name}#{Gem.plugin_suffix_pattern}", plugins_dir) + FileUtils.rm_f Gem::Util.glob_files_in_dir("#{spec.name}#{Gem.plugin_suffix_pattern}", ruby_abi_plugin_dir_for(spec, plugins_dir)) + end + + private + + def plugin_stub_dir_for(spec, plugins_dir) + full_spec = spec.to_spec + return plugins_dir unless Gem::ContentAddress.content_addressed?(full_spec) + + File.join plugins_dir, full_spec.ruby_abi + end + + def ruby_abi_plugin_dir_for(spec, plugins_dir) + full_spec = spec.to_spec + ruby_abi = Gem::ContentAddress.content_addressed?(full_spec) ? full_spec.ruby_abi : Gem.ruby_abi + File.join plugins_dir, ruby_abi end end 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..8e4ab4a4b8a9 100644 --- a/lib/rubygems/package.rb +++ b/lib/rubygems/package.rb @@ -129,13 +129,45 @@ 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 minimum RubyGems version that can install content-addressable gems. + # Built into +required_rubygems_version+ so older clients reject skinny + # gems through both the local and remote install paths. + + MINIMUM_RUBYGEMS_VERSION = ">= 4.1.0.a" + + ## + # 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, updates +spec.required_ruby_version+ to + # "~> X.Y.0", and constrains +spec.required_rubygems_version+ to at least + # MINIMUM_RUBYGEMS_VERSION (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" + + io = StringIO.new + io.set_encoding(Encoding::BINARY) + + package = new io + package.spec = spec.dup + gem_file = package.build_content_addressable_file ruby_abi, skip_validation, strict_validation - package = new gem_file - package.spec = spec - package.build skip_validation, strict_validation + spec.required_ruby_version = package.spec.required_ruby_version + spec.required_rubygems_version = package.spec.required_rubygems_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 @@ -216,6 +248,21 @@ 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 + + address = Gem::ContentAddress.verified_file_name_claim(path, spec) + return nil unless address + return nil unless Gem::ContentAddress.eligible?(spec) + + address + end + ## # Adds a checksum for each entry in the gem to checksums.yaml.gz. @@ -315,16 +362,47 @@ 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 scoped to +ruby_abi+ ("X.Y"), 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. + # + # The spec is validated for an ABI-scoped build and its + # +required_ruby_version+ and +required_rubygems_version+ are constrained + # before building, so every gem this method produces is eligible for + # content addressing. + + def build_content_addressable_file(ruby_abi, skip_validation = false, strict_validation = false) + validate_ruby_abi ruby_abi + @spec.required_rubygems_version = normalized_required_rubygems_version(ruby_abi) + @spec.required_ruby_version = Gem::ContentAddress.ruby_abi_requirement(ruby_abi) + + build skip_validation, strict_validation + + bytes = @gem.with_read_io(&:read) + gem_file = "#{@spec.name}-#{@spec.version}-#{Gem::ContentAddress.address_for(bytes)}.gem" + File.binwrite(gem_file, bytes) + + say " File: #{gem_file}" + say " Platform: #{@spec.platform}" + say " Ruby ABI: #{ruby_abi}" + + gem_file + end + ## # A list of file names contained in this gem @@ -478,7 +556,7 @@ def extract_tar_gz(io, destination_dir, pattern = "*") # :nodoc: end end - if dir_mode + if dir_mode && !directories.empty? File.chmod(dir_mode, *directories) end end @@ -641,6 +719,92 @@ def verify private + ## + # The +required_rubygems_version+ for a content-addressable build: the + # spec's requirement raised to at least MINIMUM_RUBYGEMS_VERSION, warning + # if it had to be changed. Raises if the requirement excludes every version + # satisfying that floor, since no RubyGems could install the built gem. + + def normalized_required_rubygems_version(ruby_abi) + minimum = Gem::Requirement.new(MINIMUM_RUBYGEMS_VERSION) + existing = @spec.required_rubygems_version + + return minimum if existing.nil? || existing == Gem::Requirement.default + + floor = minimum.requirements.first.last + + if excludes_rubygems_floor?(existing, floor) + raise ArgumentError, + "Cannot build gem for Ruby ABI #{ruby_abi} because required_rubygems_version is set to #{existing}, " \ + "which excludes RubyGems #{MINIMUM_RUBYGEMS_VERSION} required to install content addressable gems. " \ + "Please remove or loosen the conflicting constraint." + end + + return existing if satisfies_rubygems_floor?(existing, floor) + + preserved = existing.requirements.filter_map do |op, version| + "#{op} #{version}" if ["~>", "<", "<=", "!="].include?(op) + end + + normalized = Gem::Requirement.new([MINIMUM_RUBYGEMS_VERSION, *preserved]) + + alert_warning \ + "required_rubygems_version was changed from \"#{existing}\" to \"#{normalized}\" for this build " \ + "because content addressable gems can only be installed by RubyGems #{MINIMUM_RUBYGEMS_VERSION}." + + normalized + end + + ## + # Whether +requirement+ excludes every RubyGems version satisfying the + # +floor+, so that no RubyGems could install the built gem. + + def excludes_rubygems_floor?(requirement, floor) + capped_below_floor = requirement.requirements.any? do |op, version| + case op + when "<" then version <= floor + when "<=", "=" then version < floor + when "~>" then version.bump <= floor.release + else false + end + end + + return true if capped_below_floor + + !requirement.satisfied_by?(floor) && requirement.requirements.any? do |op, version| + ["<=", "="].include?(op) && version == floor + end + end + + ## + # Whether one of the lower bounds of +requirement+ already guarantees the + # +floor+. + + def satisfies_rubygems_floor?(requirement, floor) + requirement.requirements.any? do |op, version| + case op + when ">=", "~>", "=", ">" then version >= floor + else false + end + end + end + + ## + # Validates that the spec can be built as a content-addressable gem scoped + # to +ruby_abi+ ("X.Y"): the ABI must be well-formed, the spec must declare + # a non-Ruby platform, and any existing +required_ruby_version+ must match + # the ABI. + + def validate_ruby_abi(ruby_abi) + if !Gem::ContentAddress.valid_ruby_abi?(ruby_abi) + raise ArgumentError, "Ruby ABI must be in X.Y format" + elsif !Gem::ContentAddress.platform_eligible?(@spec.platform) + raise ArgumentError, "Cannot build a gem scoped to a single Ruby ABI as no platform or a Ruby platform has been set" + elsif !Gem::ContentAddress.ruby_abi_compatible?(@spec, 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 + ## # Returns the full path for installing +filename+ into +destination_dir+, # which must already be resolved with File.realpath by the caller. 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/remote_fetcher.rb b/lib/rubygems/remote_fetcher.rb index d3ab256029e8..f8f0ae64f086 100644 --- a/lib/rubygems/remote_fetcher.rb +++ b/lib/rubygems/remote_fetcher.rb @@ -161,7 +161,8 @@ def download(spec, source_uri, install_dir = Gem.dir) cache_update_path remote_gem_path, local_gem_path rescue FetchError - raise if spec.original_platform == spec.platform + raise if Gem::ContentAddress.content_addressed?(spec, validate_ruby_abi: false) + raise if spec.original_platform.to_s == spec.platform.to_s alternate_name = "#{spec.original_name}.gem" diff --git a/lib/rubygems/request_set.rb b/lib/rubygems/request_set.rb index 51745296a10c..4c8cfde29f4b 100644 --- a/lib/rubygems/request_set.rb +++ b/lib/rubygems/request_set.rb @@ -518,8 +518,10 @@ def specs end def specs_in(dir) - Gem::Util.glob_files_in_dir("*.gemspec", File.join(dir, "specifications")).map do |g| - Gem::Specification.load g + Gem::SpecificationRecord.dirs_from([dir]).flat_map do |spec_dir| + Gem::Util.glob_files_in_dir("*.gemspec", spec_dir).map do |g| + Gem::Specification.load g + end end end diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb index 4964f5be217e..ffe0e9f60785 100644 --- a/lib/rubygems/resolver.rb +++ b/lib/rubygems/resolver.rb @@ -482,10 +482,15 @@ 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 - # earlier-supplied source. + # Among remaining candidates, prefer a content-addressed candidate + # built for the running Ruby, then the most specific platform, then the + # earlier-supplied source. The Ruby ABI ranks before platform + # specificity so that gem install and bundle install choose the same + # artifact: Bundler prefers compatible content-addressed candidates + # before sorting by platform. candidates.min_by do |s| - [Gem::Platform.platform_specificity_match(s.platform, Gem::Platform.local), + [Gem::ContentAddress.ruby_abi_specificity_match(s), + Gem::Platform.platform_specificity_match(s.platform, Gem::Platform.local), 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..a6a032664267 100644 --- a/lib/rubygems/resolver/api_specification.rb +++ b/lib/rubygems/resolver/api_specification.rb @@ -33,14 +33,13 @@ 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 @dependencies = api_data[:dependencies].map do |name, ver| Gem::Dependency.new(name, ver.split(/\s*,\s*/)).freeze end.freeze @required_ruby_version = Gem::Requirement.new(api_data.dig(:requirements, :ruby)).freeze @required_rubygems_version = Gem::Requirement.new(api_data.dig(:requirements, :rubygems)).freeze @created_at = parse_created_at(api_data.dig(:requirements, :created_at))&.freeze + assign_platform(api_data) end def ==(other) # :nodoc: @@ -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,28 @@ 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.content_addressed_row?(suffix, required_platform, @required_ruby_version) + @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) + platform = Array(requirement).last + return if platform.nil? || platform.empty? + + 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/lock_specification.rb b/lib/rubygems/resolver/lock_specification.rb index 06f912dd8577..b2890b198f8c 100644 --- a/lib/rubygems/resolver/lock_specification.rb +++ b/lib/rubygems/resolver/lock_specification.rb @@ -30,7 +30,11 @@ def initialize(set, name, version, sources, platform) def install(options = {}) destination = options[:install_dir] || Gem.dir - if File.exist? File.join(destination, "specifications", spec.spec_name) + installed = Gem::SpecificationRecord.dirs_from([destination]).any? do |spec_dir| + File.exist? File.join(spec_dir, spec.spec_name) + end + + if installed yield nil return end 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..b940ff972d07 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 ## @@ -105,7 +111,9 @@ def install(options = {}) gem = download options - installer = Gem::Installer.at gem, options + installer = Gem::Installer.at gem, options.merge( + content_address: (spec.content_address if Gem::ContentAddress.content_addressed?(spec, validate_ruby_abi: false)) + ) yield installer if block_given? 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..9f5ce4dc9fa4 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,78 @@ 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] + + ruby_abi = Gem::ContentAddress.ruby_abi_for(Gem::Requirement.new(requirements[:ruby])) + ContentAddressableInfo.new(version, suffix, ruby_abi, 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 = Array(requirement).last.to_s + return if platform.empty? + + platform + 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..4006c4d3f6f3 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_abi + end end diff --git a/lib/rubygems/specification.rb b/lib/rubygems/specification.rb index adee800051fc..e00a70a430b5 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,15 @@ 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 + Gem::ContentAddress.ruby_abi_for(required_ruby_version) + end + ## # Executables included in the gem. # @@ -1247,7 +1263,9 @@ def self.reset # Keeps track of all currently known specifications def self.specification_record - @specification_record ||= Gem::SpecificationRecord.new(dirs) + @specification_record ||= Gem::SpecificationRecord.new( + Gem::SpecificationRecord.dirs_with_abi(dirs) + ) end # DOC: This method needs documented or nodoc'd @@ -1354,7 +1372,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 +1944,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 +1980,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 @@ -2008,13 +2028,19 @@ def initialize_copy(other_spec) def base_dir return Gem.dir unless loaded_from - @base_dir ||= if default_gem? + @base_dir ||= if default_gem? || loaded_from_abi_scoped_spec_dir? File.dirname File.dirname File.dirname loaded_from else File.dirname File.dirname loaded_from end end + def loaded_from_abi_scoped_spec_dir? + !loaded_from.nil? && + Gem::SpecificationRecord.abi_scoped_spec_dir?(File.dirname(loaded_from)) + end + private :loaded_from_abi_scoped_spec_dir? + def inspect # :nodoc: if $DEBUG super @@ -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? @@ -2309,11 +2336,14 @@ def source # :nodoc: end ## - # Returns the full path to the directory containing this spec's - # gemspec file. eg: /usr/local/lib/ruby/gems/1.8/specifications - + # Full path to the directory containing this spec's gemspec file. + # ABI-scoped for content-addressed specs. def spec_dir - @spec_dir ||= File.join base_dir, "specifications" + @spec_dir ||= if loaded_from && Gem::ContentAddress.content_addressed?(self) + File.dirname loaded_from + else + Gem::SpecificationRecord.specification_dir_for(self, base_dir) + end end ## @@ -2380,11 +2410,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 +2426,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/specification_record.rb b/lib/rubygems/specification_record.rb index c7e5cbedb58c..fb22ef0c0a9b 100644 --- a/lib/rubygems/specification_record.rb +++ b/lib/rubygems/specification_record.rb @@ -2,12 +2,50 @@ module Gem class SpecificationRecord + ## + # Specification directories for each path: the flat +specifications+ + # dir and the ABI-scoped +specifications/+ subdir. + def self.dirs_from(paths) - paths.map do |path| - File.join(path, "specifications") + paths.flat_map do |path| + specifications = File.join(path, "specifications") + + [specifications, File.join(specifications, Gem.ruby_abi)] end end + ## + # Whether +dir+ is an ABI-scoped spec dir + # (.../specifications/). Requires the parent to be + # named +specifications+ to avoid false positives. + def self.abi_scoped_spec_dir?(dir) + Gem::ContentAddress.valid_ruby_abi?(File.basename(dir)) && + File.basename(File.dirname(dir)) == "specifications" + end + + ## + # The spec dir for +spec+ under +base_dir+: the ABI-scoped subdir for + # content-addressed specs, the flat +specifications+ dir otherwise. + def self.specification_dir_for(spec, base_dir) + dir = File.join(base_dir, "specifications") + dir = File.join(dir, spec.ruby_abi) if Gem::ContentAddress.content_addressed?(spec) + dir + end + + ## + # Ensure ABI-scoped subdirectories are present in +dirs+. No-op when + # +dirs+ already includes them (e.g. MRI); expands flat-only +dirs+ + + def self.dirs_with_abi(dirs) + dirs.flat_map do |dir| + if File.basename(dir) == "specifications" + [dir, File.join(dir, Gem.ruby_abi)] + else + dir + end + end.uniq + end + def self.from_path(path) new(dirs_from([path])) end @@ -217,6 +255,7 @@ def installed_stubs(pattern) def map_stubs(pattern) @dirs.flat_map do |dir| base_dir = File.dirname dir + base_dir = File.dirname base_dir if self.class.abi_scoped_spec_dir?(dir) gems_dir = File.join base_dir, "gems" Gem::Specification.gemspec_stubs_in(dir, pattern) {|path| yield path, base_dir, gems_dir } end diff --git a/lib/rubygems/stub_specification.rb b/lib/rubygems/stub_specification.rb index 53b337ed8554..e2257290bcf9 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 Gem::ContentAddress.content_addressed_row?(suffix, target_platform, validate_ruby_abi: false) @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..b7423bb9927e 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 Gem::ContentAddress.valid_ruby_abi?(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 207db2fcbf38..b045f750ab37 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,55 @@ 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.4.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 a content-addressed suffix has no ruby metadata" do + let(:suffix) { "abc1234567" } + let(:metadata) { { "platform" => ["arm64-darwin"] } } + + it "does not assign a content address" do + expect(spec.content_address).to be_nil + end + end + + context "when a content-addressed suffix has non-ABI ruby metadata" do + let(:suffix) { "abc1234567" } + let(:metadata) { { "platform" => ["arm64-darwin"], "ruby" => [">= 3.0"] } } + + it "does not assign 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 @@ -152,33 +201,32 @@ def with_tz(tz) 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 } + let(:metadata) { { "ruby" => [">= 3.0"] } } - it "should return the current value when already set on endpoint specification" do - expect(spec.required_ruby_version). eql?(existing_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 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) - - expect(spec.required_ruby_version). eql?(remote_value) + 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 "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 "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_generator_spec.rb b/spec/bundler/lockfile_generator_spec.rb new file mode 100644 index 000000000000..776d7a94be63 --- /dev/null +++ b/spec/bundler/lockfile_generator_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "bundler/lockfile_generator" + +RSpec.describe Bundler::LockfileGenerator do + describe "#add_content_addresses" do + let(:source) { instance_double(Bundler::Source::Rubygems) } + + def lazy_spec(platform, content_address: nil) + Bundler::LazySpecification.new("mygem", Gem::Version.new("1.0"), platform, source, content_address: content_address) + end + + def generated_output(specs) + definition = instance_double(Bundler::Definition, resolve: specs, locked_checksums: false) + generator = described_class.new(definition) + generator.send(:add_content_addresses) + generator.out + end + + it "writes a row the lockfile parser can read back" do + spec = lazy_spec(Gem::Platform.new("x86_64-linux"), content_address: "abcdef12") + output = generated_output([spec]) + + expect(output).to include("CONTENT ADDRESSES\n mygem (1.0-x86_64-linux) abcdef12\n") + expect(" mygem (1.0-x86_64-linux) abcdef12").to match(Bundler::LockfileParser::NAME_VERSION_CONTENT_ADDRESS) + end + + it "does not write a row for a spec with an address on the ruby platform" do + spec = lazy_spec(Gem::Platform::RUBY, content_address: "abcdef12") + + expect(generated_output([spec])).to be_empty + expect(" mygem (1.0) abcdef12").not_to match(Bundler::LockfileParser::NAME_VERSION_CONTENT_ADDRESS) + end + + it "does not write a row for a spec without an address" do + spec = lazy_spec(Gem::Platform.new("x86_64-linux")) + + expect(generated_output([spec])).to be_empty + end + end +end diff --git a/spec/bundler/lockfile_parser_spec.rb b/spec/bundler/lockfile_parser_spec.rb index c92d8909d29e..a50864435d73 100644 --- a/spec/bundler/lockfile_parser_spec.rb +++ b/spec/bundler/lockfile_parser_spec.rb @@ -63,7 +63,7 @@ it "returns the same as > 1.0" do expect(subject).to contain_exactly( - described_class::BUNDLED, described_class::CHECKSUMS, described_class::RUBY, described_class::PLUGIN + described_class::BUNDLED, described_class::CHECKSUMS, described_class::CONTENT_ADDRESSES, described_class::RUBY, described_class::PLUGIN ) end end @@ -73,7 +73,7 @@ it "returns the same as for the release version" do expect(subject).to contain_exactly( - described_class::CHECKSUMS, described_class::RUBY, described_class::PLUGIN + described_class::CHECKSUMS, described_class::CONTENT_ADDRESSES, described_class::RUBY, described_class::PLUGIN ) end end @@ -145,6 +145,74 @@ 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-x86_64-linux) + + PLATFORMS + x86_64-linux + + DEPENDENCIES + mygem + + CONTENT ADDRESSES + mygem (1.0-x86_64-linux) abcdef1234 sha256=abcdef1234f1315d7e7b7e8295184577cc4e969bad6156ac069d02d63f58d82e + + CHECKSUMS + mygem (1.0-x86_64-linux) 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") + expect(spec.full_name).to eq("mygem-1.0-abcdef1234") + end + + it "keeps the platform build's checksum under the lock name and the content-addressable build's checksum under its full name" do + spec = subject.specs.find {|s| s.name == "mygem" } + store = subject.sources.first.checksum_store + + expect(store.to_lock(spec)).to eq("mygem (1.0-x86_64-linux) sha256=814828c34f1315d7e7b7e8295184577cc4e969bad6156ac069d02d63f58d82e8") + expect(store.checksums_to_lock(spec.full_name)).to eq("sha256=abcdef1234f1315d7e7b7e8295184577cc4e969bad6156ac069d02d63f58d82e") + 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..6ee58f2cbef1 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,18 +42,34 @@ 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") end end + + context "when a content address is set with a non-ruby platform" do + subject { described_class.new(name, version, "x86_64-linux", spec_fetcher, content_address: "abc1234567") } + + it "should return the spec name, version, and content address" do + expect(subject.full_name).to eq("foo-1.0.0-abc1234567") + end + end + + context "when a content address is set with the ruby platform" do + subject { described_class.new(name, version, Gem::Platform::RUBY, spec_fetcher, content_address: "abc1234567") } + + it "should ignore the content address" do + expect(subject.full_name).to eq("foo-1.0.0") + end + end end 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 +163,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 +200,21 @@ end end end + + describe "#content_address" do + it "is nil for a hex suffix (content-addressed 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/commands/clean_spec.rb b/spec/commands/clean_spec.rb index 9d8bfa3b59af..5e9cac75c4e7 100644 --- a/spec/commands/clean_spec.rb +++ b/spec/commands/clean_spec.rb @@ -391,6 +391,58 @@ def should_not_have_gems(*gems) expect(vendored_gems("bin/myrackup")).not_to exist end + it "removes orphaned gemspecs from ABI-scoped specification dirs", rubygems: ">= 4.1.0.dev" do + gemfile <<-G + source "https://gem.repo1" + + gem "foo" + G + + bundle_config "path vendor/bundle" + bundle "install" + + abi_dir = vendored_gems("specifications/#{Gem.ruby_abi}") + FileUtils.mkdir_p(abi_dir) + orphaned_gemspec = File.join(abi_dir, "orphaned-1.0-deadbeef.gemspec") + File.write(orphaned_gemspec, "orphaned") + + bundle :clean + + expect(File.exist?(orphaned_gemspec)).to be false + should_have_gems "foo-1.0" + end + + it "does not remove gemspecs for content-addressed gems in the bundle", :compact_index, rubygems: ">= 4.1.0.dev" do + skip "Gem::ContentAddress not available" if ruby_core? + + 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: Gem.ruby_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{Gem.ruby_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed'" + end + + bundle_config "path vendor/bundle" + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s } + source "https://gem.repo2" + + gem "mygem" + G + + bundle :clean + + abi_gemspecs = Dir.glob(vendored_gems("specifications/#{Gem.ruby_abi}/*.gemspec").to_s) + expect(abi_gemspecs.size).to eq(1), "expected content-addressed gemspec to be preserved, found: #{abi_gemspecs}" + end + end + it "does not call clean automatically when using system gems" do bundle_config "path.system true" diff --git a/spec/install/cooldown_spec.rb b/spec/install/cooldown_spec.rb index 2c0cb7f96ae4..8429f20a941b 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 @@ -217,7 +217,7 @@ def gemrc_cooldown(days) it "applies it when bundler configures none of its own" do gemrc_cooldown 7 - bundle "install", artifice: "compact_index_cooldown" + bundle "install", artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 1.0.0") end @@ -225,7 +225,7 @@ def gemrc_cooldown(days) it "takes the longer of the two settings" do gemrc_cooldown 7 - bundle "install", env: { "BUNDLE_COOLDOWN" => "1" }, artifice: "compact_index_cooldown" + bundle "install", env: { "BUNDLE_COOLDOWN" => "1" }, artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 1.0.0") end @@ -233,7 +233,7 @@ def gemrc_cooldown(days) it "counts a bundler-side 0 as a value rather than as unset" do gemrc_cooldown 7 - bundle "install", env: { "BUNDLE_COOLDOWN" => "0" }, artifice: "compact_index_cooldown" + bundle "install", env: { "BUNDLE_COOLDOWN" => "0" }, artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 1.0.0") end @@ -241,7 +241,7 @@ def gemrc_cooldown(days) it "counts a gemrc 0 as a value rather than as unset" do gemrc_cooldown 0 - bundle "install", env: { "BUNDLE_COOLDOWN" => "7" }, artifice: "compact_index_cooldown" + bundle "install", env: { "BUNDLE_COOLDOWN" => "7" }, artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 1.0.0") end @@ -254,7 +254,7 @@ def gemrc_cooldown(days) 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 @@ -262,7 +262,7 @@ def gemrc_cooldown(days) it "lets --cooldown 0 bypass it" do gemrc_cooldown 7 - 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 @@ -270,7 +270,7 @@ def gemrc_cooldown(days) it "warns and ignores it when it is not a number" do gemrc_cooldown "seven" - bundle "install", artifice: "compact_index_cooldown" + bundle "install", artifice: "compact_index_v2" expect(err).to include('Invalid cooldown value "seven" in the gemrc file, so it is ignored.') expect(the_bundle).to include_gems("ripe_gem 2.0.0") @@ -283,7 +283,7 @@ def gemrc_cooldown(days) 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") @@ -312,7 +312,7 @@ def gemrc_cooldown(days) #{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") @@ -325,7 +325,7 @@ def gemrc_cooldown(days) 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 @@ -336,7 +336,7 @@ def gemrc_cooldown(days) 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") @@ -348,10 +348,10 @@ def gemrc_cooldown(days) 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 @@ -361,7 +361,7 @@ def gemrc_cooldown(days) 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 @@ -391,7 +391,7 @@ def gemrc_cooldown(days) #{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 @@ -419,7 +419,7 @@ def gemrc_cooldown(days) #{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 @@ -428,7 +428,7 @@ def gemrc_cooldown(days) # 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" @@ -440,7 +440,7 @@ def gemrc_cooldown(days) 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" @@ -452,7 +452,7 @@ def gemrc_cooldown(days) 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" @@ -469,7 +469,7 @@ def gemrc_cooldown(days) 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 @@ -496,7 +496,7 @@ def gemrc_cooldown(days) #{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 @@ -539,7 +539,7 @@ def gemrc_cooldown(days) #{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 @@ -566,7 +566,7 @@ def gemrc_cooldown(days) #{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 @@ -593,11 +593,11 @@ def gemrc_cooldown(days) #{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 @@ -624,7 +624,7 @@ def gemrc_cooldown(days) #{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 @@ -654,7 +654,7 @@ def gemrc_cooldown(days) #{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") @@ -683,7 +683,7 @@ def gemrc_cooldown(days) 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 @@ -710,7 +710,7 @@ def gemrc_cooldown(days) #{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") @@ -724,7 +724,7 @@ def gemrc_cooldown(days) 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 @@ -737,7 +737,7 @@ def gemrc_cooldown(days) 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 @@ -764,7 +764,7 @@ def gemrc_cooldown(days) #{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/) @@ -795,7 +795,7 @@ def gemrc_cooldown(days) #{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 @@ -822,7 +822,7 @@ def gemrc_cooldown(days) #{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) @@ -850,7 +850,7 @@ def gemrc_cooldown(days) #{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 @@ -879,7 +879,7 @@ def gemrc_cooldown(days) #{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 @@ -906,7 +906,7 @@ def gemrc_cooldown(days) #{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 @@ -952,7 +952,7 @@ def gemrc_cooldown(days) #{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 @@ -1001,7 +1001,7 @@ def gemrc_cooldown(days) #{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 @@ -1031,7 +1031,7 @@ def gemrc_cooldown(days) #{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 @@ -1058,7 +1058,7 @@ def gemrc_cooldown(days) #{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)") @@ -1086,7 +1086,7 @@ def gemrc_cooldown(days) #{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)") @@ -1101,7 +1101,7 @@ def gemrc_cooldown(days) 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 @@ -1112,7 +1112,7 @@ def gemrc_cooldown(days) 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. @@ -1142,7 +1142,7 @@ def gemrc_cooldown(days) #{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)") @@ -1154,7 +1154,7 @@ def gemrc_cooldown(days) 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 @@ -1165,7 +1165,7 @@ def gemrc_cooldown(days) 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 @@ -1196,7 +1196,7 @@ def gemrc_cooldown(days) 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 @@ -1212,7 +1212,7 @@ def gemrc_cooldown(days) 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..8493c89cc203 --- /dev/null +++ b/spec/install/gemfile/content_addressable_spec.rb @@ -0,0 +1,550 @@ +# 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-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_enabled = lockfile.match?(/^CHECKSUMS$/) + + expected_content_address_line = +" mygem (1.0-x86_64-linux) #{content_address}" + expected_content_address_line << " sha256=#{digest}" if checksums_enabled + expect(lockfile).to include("CONTENT ADDRESSES\n#{expected_content_address_line}\n") + + checksums = checksums_section_when_enabled do |c| + c.checksum(gem_repo2, "mygem", "1.0", "x86_64-linux") + end + expect(lockfile).to include(checksums.to_s) + expect(lockfile).not_to include("(1.0-#{content_address})") + 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 "installs the content-addressed gem even when a more specific non-content-addressed platform gem exists" do + simulate_platform "arm64-darwin-23" do + build_repo2 do + build_gem "mygem", "1.0" do |s| + s.platform = Gem::Platform.new("arm64-darwin-23") + 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 content_addressed" + 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 "reports the Ruby version requirement when only incompatible content-addressed gems exist" do + simulate_platform "x86_64-linux" do + build_repo2 do + build_gem "other", "1.0" + 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 }, raise_on_error: false + source "https://gem.repo2" + + gem "mygem" + G + + expect(last_command).to be_failure + expect(err).to include("every version of mygem depends on Ruby ~> #{mismatched_abi}.0") + expect(err).not_to include("Could not find gem 'mygem'") + 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 + + fat_checksum = Digest::SHA256.file(gem_repo2("gems", "mygem-1.0-x86_64-linux.gem")).hexdigest + + lockfile <<~L + GEM + remote: https://gem.repo2/ + specs: + mygem (1.0-x86_64-linux) + + PLATFORMS + x86_64-linux + + DEPENDENCIES + mygem + + CONTENT ADDRESSES + mygem (1.0-x86_64-linux) #{mismatched_address} sha256=#{mismatched_checksum} + + CHECKSUMS + mygem (1.0-x86_64-linux) sha256=#{fat_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-x86_64-linux) #{mismatched_address}") + end + end + + it "omits the CHECKSUMS entry when only a content addressable build exists" do + simulate_platform "x86_64-linux" do + build_repo2 do + build_gem "othergem", "1.0" + 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_file = Dir[default_bundle_path("cache", "mygem-1.0-*.gem").to_s].first + content_address = File.basename(cached_file, ".gem").rpartition("-").last + digest = Digest::SHA256.file(cached_file).hexdigest + + expected_content_address_line = +" mygem (1.0-x86_64-linux) #{content_address}" + expected_content_address_line << " sha256=#{digest}" if lockfile.match?(/^CHECKSUMS$/) + expect(lockfile).to include("CONTENT ADDRESSES\n#{expected_content_address_line}\n") + + expect(lockfile).not_to match(/^ mygem \(1\.0-x86_64-linux\)$/) + + original_lockfile = lockfile + bundle_config "frozen true" + bundle "install", artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s } + expect(lockfile).to eq(original_lockfile) + 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.required_ruby_version = "~> #{current_abi}.0" + 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 + +RSpec.describe "bundle install with content-addressable gems invisible to pre-4.1 RubyGems clients", :compact_index, rubygems: ">= 4.1.0.a" 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]}" } + + it "installs content-addressed gems constrained so older clients refuse them" 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" + + expect(Dir[default_bundle_path("specifications", "mygem-1.0-*.gemspec").to_s]).to be_empty + + installed_gemspec = Dir[default_bundle_path("specifications", current_abi, "mygem-1.0-*.gemspec").to_s].first + expect(installed_gemspec).not_to be_nil + spec = Gem::Specification.load(installed_gemspec) + + expect(spec.required_rubygems_version).to eq(Gem::Requirement.new(">= 4.1.0.a")) + expect(spec.required_rubygems_version.satisfied_by?(Gem::Version.new("4.0.9"))).to be false + expect(spec.required_rubygems_version.satisfied_by?(Gem.rubygems_version)).to be true + end + end +end diff --git a/spec/other/ext_spec.rb b/spec/other/ext_spec.rb index a883eefe0667..ca6d8236132b 100644 --- a/spec/other/ext_spec.rb +++ b/spec/other/ext_spec.rb @@ -46,5 +46,21 @@ 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 "ignores content_address in the lock name so older Bundler versions read an ordinary platform pin" do + expect(Gem::NameTuple.new("a", v("1.0.0"), "x86_64-linux", content_address: "abcdef12").lock_name).to eq("a (1.0.0-x86_64-linux)") + expect(Gem::NameTuple.new("a", v("1.0.0"), "ruby", content_address: "abcdef12").lock_name).to eq("a (1.0.0)") + end + end +end + +RSpec.describe Bundler::LazySpecification do + describe "#to_lock" do + it "locks a content-addressable spec as an ordinary platform pin" 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-x86_64-linux)\n") + expect(spec.full_name).to eq("mygem-1.0-abcdef1234") + 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_cooldown_bad_created_at.rb b/spec/support/artifice/compact_index_cooldown_bad_created_at.rb index 3379a9683a65..e5f33a0663ab 100644 --- a/spec/support/artifice/compact_index_cooldown_bad_created_at.rb +++ b/spec/support/artifice/compact_index_cooldown_bad_created_at.rb @@ -1,14 +1,19 @@ # frozen_string_literal: true -require_relative "helpers/compact_index_cooldown" +require_relative "helpers/compact_index_v2" # Serves every version with a created_at year that Time.iso8601 accepts but # whose distance from now overflows Float. -class CompactIndexCooldownBadCreatedAt < CompactIndexCooldownAPI +class CompactIndexCooldownBadCreatedAt < CompactIndexV2API helpers do def build_gem_version(spec, deps, checksum) - 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, "#{"9" * 400}-01-01T00:00:00Z") + # 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, "#{"9" * 400}-01-01T00:00:00Z", + ruby_abi, content_address) end end end 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..9f8bb853f57d --- /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.eligible?(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/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..7c07e481e4a2 --- /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 72935437878a..4392fe583a72 100644 --- a/test/rubygems/helper.rb +++ b/test/rubygems/helper.rb @@ -873,7 +873,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 @@ -887,12 +887,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 @@ -1008,13 +1010,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| @@ -1025,11 +1046,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 @@ -1037,6 +1058,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: "~> #{Gem.ruby_abi}.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+. @@ -1226,7 +1272,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" @@ -1248,7 +1294,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)}" @@ -1261,6 +1311,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}" @@ -1286,7 +1339,11 @@ def write_marshalled_gemspecs(*all_specs) v = Gem.marshal_version all_specs.each do |spec| - path = "#{@gem_repo}quick/Marshal.#{v}/#{spec.original_name}.gemspec.rz" + # For content-addressed specs the gemspec is fetched by its + # content-addressed name, not its platform-suffixed name + name_tuple = Gem::NameTuple.new(spec.name, spec.version, spec.original_platform, + content_address: spec.content_address) + path = "#{@gem_repo}quick/Marshal.#{v}/#{name_tuple.spec_name}.rz" data = Marshal.dump spec data_deflate = Zlib::Deflate.deflate data @fetcher.data[path] = data_deflate diff --git a/test/rubygems/test_gem.rb b/test/rubygems/test_gem.rb index c81b0b0547ae..88b461a2a0a7 100644 --- a/test/rubygems/test_gem.rb +++ b/test/rubygems/test_gem.rb @@ -1053,6 +1053,12 @@ def test_self_env_requirement assert_equal Gem::Requirement.default, Gem.env_requirement("qux") end + def test_self_ruby_abi + Gem.stub(:ruby_version, Gem::Version.new("3.4.1")) do + assert_equal "3.4", Gem.ruby_abi + end + end + def test_self_ruby_version_with_non_mri_implementations util_set_RUBY_VERSION "2.5.0", 0, 60_928, "jruby 9.2.0.0 (2.5.0) 2018-05-24 81156a8 OpenJDK 64-Bit Server VM 25.171-b11 on 1.8.0_171-8u171-b11-0ubuntu0.16.04.1-b11 [linux-x86_64]" @@ -1499,6 +1505,79 @@ def test_load_plugins assert_equal %w[plugin], PLUGINS_LOADED end + def test_load_plugins_loads_latest_non_content_addressed_plugin_after_content_addressed_plugin + ruby_abi = Gem.ruby_abi + + _, ca_gem = util_gem "plugin_latest", "1.0", ruby_abi: ruby_abi do |s| + write_file File.join(@tempdir, "lib", "rubygems_plugin.rb") do |io| + io.write "class TestGem; PLUGINS_LOADED << 'ca-1.0'; end" + end + + s.files += %w[lib/rubygems_plugin.rb] + s.platform = "x86_64-linux" + end + + installed_ca_spec = Gem::Installer.at(ca_gem, force: true).install + + spec = quick_gem "plugin_latest", "2.0" do |s| + write_file File.join(@tempdir, "lib", "rubygems_plugin.rb") do |io| + io.write "class TestGem; PLUGINS_LOADED << 'fat-2.0'; end" + end + + s.files += %w[lib/rubygems_plugin.rb] + end + + installed_fat_spec = install_gem spec + + PLUGINS_LOADED.clear + $LOADED_FEATURES.delete File.join(installed_ca_spec.gem_dir, "lib", "rubygems_plugin.rb") + $LOADED_FEATURES.delete File.join(installed_fat_spec.gem_dir, "lib", "rubygems_plugin.rb") + Gem.load_plugins + + assert_equal %w[fat-2.0], PLUGINS_LOADED + end + + def test_load_plugins_loads_current_ruby_abi_plugins + Gem.stub(:ruby_version, Gem::Version.new("3.4.0")) do + plugins_dir = Gem.plugindir + current_abi_plugins_dir = File.join plugins_dir, Gem.ruby_abi + other_abi_plugins_dir = File.join plugins_dir, "3.3" + + write_file File.join(plugins_dir, "root_plugin.rb") do |fp| + fp.puts "class TestGem; PLUGINS_LOADED << 'root'; end" + end + + write_file File.join(current_abi_plugins_dir, "current_plugin.rb") do |fp| + fp.puts "class TestGem; PLUGINS_LOADED << 'current'; end" + end + + write_file File.join(other_abi_plugins_dir, "other_plugin.rb") do |fp| + fp.puts "class TestGem; PLUGINS_LOADED << 'other'; end" + end + + Gem.load_plugins + + assert_equal %w[root current], PLUGINS_LOADED + end + end + + def test_load_plugins_prefers_abi_scoped_stub_over_root_stub_for_the_same_gem + plugins_dir = Gem.plugindir + current_abi_plugins_dir = File.join plugins_dir, Gem.ruby_abi + + write_file File.join(plugins_dir, "mygem_plugin.rb") do |fp| + fp.puts "class TestGem; PLUGINS_LOADED << 'root'; end" + end + + write_file File.join(current_abi_plugins_dir, "mygem_plugin.rb") do |fp| + fp.puts "class TestGem; PLUGINS_LOADED << 'abi'; end" + end + + Gem.load_plugins + + assert_equal %w[abi], PLUGINS_LOADED + end + def test_load_user_installed_plugins @orig_gem_home = ENV["GEM_HOME"] ENV["GEM_HOME"] = @gemhome diff --git a/test/rubygems/test_gem_commands_build_command.rb b/test/rubygems/test_gem_commands_build_command.rb index 03af9dfff228..771eb07dbc9c 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,43 @@ 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 " Platform: arm64-darwin", output.shift + assert_equal " Ruby ABI: 3.4", output.shift + assert_equal [], output + end + def test_options_filename gemspec_file = File.join(@tempdir, @gem.spec_name) @@ -70,6 +107,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 +122,156 @@ 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_rejects_conflicting_required_rubygems_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.4.0" + s.required_rubygems_version = "< 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"] + 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_rubygems_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_contents_command.rb b/test/rubygems/test_gem_commands_contents_command.rb index a67ecfd63469..fa551be56f60 100644 --- a/test/rubygems/test_gem_commands_contents_command.rb +++ b/test/rubygems/test_gem_commands_contents_command.rb @@ -62,6 +62,22 @@ def test_execute_bad_gem assert_equal "", @ui.error end + def test_execute_bad_gem_with_spec_dir + @cmd.options[:args] = %w[foo] + @cmd.options[:specdirs] = [@gemhome] + + assert_raise Gem::MockGemUi::TermError do + use_ui @ui do + @cmd.execute + end + end + + assert_match(/Unable to find gem 'foo' in specified path/, @ui.output) + assert_match(/Directories searched:/, @ui.output) + assert_match(File.join(@gemhome, "specifications", Gem.ruby_abi), @ui.output) + assert_equal "", @ui.error + end + def test_execute_exact_match @cmd.options[:args] = %w[foo] gem "foo" 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..014edcf5f8be 100644 --- a/test/rubygems/test_gem_commands_info_command.rb +++ b/test/rubygems/test_gem_commands_info_command.rb @@ -42,6 +42,240 @@ 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) + + write_marshalled_gemspecs(spec_a, 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) + + write_marshalled_gemspecs(spec) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[a --remote] + + use_ui @ui do + @cmd.execute + end + + assert_includes @fetcher.paths, "#{@gem_repo}quick/Marshal.#{Gem.marshal_version}/a-1-abcdef12.gemspec.rz" + + 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) + + write_marshalled_gemspecs(spec, 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) + + write_marshalled_gemspecs(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) + + write_marshalled_gemspecs(spec, 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) + + write_marshalled_gemspecs(spec_v3, 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) + + write_marshalled_gemspecs(spec_e1, 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 505d42ad66fd..a22f88ea22d0 100644 --- a/test/rubygems/test_gem_commands_outdated_command.rb +++ b/test/rubygems/test_gem_commands_outdated_command.rb @@ -90,6 +90,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_pristine_command.rb b/test/rubygems/test_gem_commands_pristine_command.rb index 0ea140897ce1..e04fc5f5f924 100644 --- a/test/rubygems/test_gem_commands_pristine_command.rb +++ b/test/rubygems/test_gem_commands_pristine_command.rb @@ -11,6 +11,37 @@ def setup @cmd = Gem::Commands::PristineCommand.new end + def test_execute_content_addressed_gem + _, a_gem = util_gem("a", 2) do |spec| + spec.required_ruby_version = "~> #{Gem.ruby_abi}.0" + spec.platform = Gem::Platform.local.to_s + end + + address = Digest::SHA256.file(a_gem).hexdigest[0, 8] + filename = File.join(File.dirname(a_gem), "a-2-#{address}.gem") + FileUtils.cp a_gem, filename + + Gem::Installer.at(filename, force: true).install + Gem::Specification.reset + + gem_dir = File.join(@gemhome, "gems", "a-2-#{address}") + gemspec = File.join(@gemhome, "specifications", Gem.ruby_abi, "a-2-#{address}.gemspec") + assert_path_exist gem_dir + assert_path_exist gemspec + + FileUtils.rm_rf gem_dir + + @cmd.options[:args] = %w[a] + + use_ui @ui do + @cmd.execute + end + + assert_path_exist gem_dir + assert_path_exist gemspec + assert_path_not_exist File.join(@gemhome, "specifications", "a-2-#{address}.gemspec") + end + def test_execute a = util_spec "a" do |s| s.executables = %w[foo] diff --git a/test/rubygems/test_gem_commands_push_command.rb b/test/rubygems/test_gem_commands_push_command.rb index 904fdfcd8e87..054101fe4b6f 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..fc42baa4a7bf 100644 --- a/test/rubygems/test_gem_commands_update_command.rb +++ b/test/rubygems/test_gem_commands_update_command.rb @@ -42,6 +42,71 @@ def test_execute assert_empty out end + def test_execute_content_addressable_compact_index_gem + util_set_arch "x86_64-linux" + + release_ruby_version = Gem::Version.new("#{Gem.ruby_abi}.0") + + Gem.stub(:ruby_version, release_ruby_version) do + 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", Gem.ruby_abi, "ca_update-1.0.0-#{content_address}.gemspec") + end + 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 +181,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..868d4ad6866d --- /dev/null +++ b/test/rubygems/test_gem_content_address.rb @@ -0,0 +1,350 @@ +# 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_valid_ruby_abi + assert Gem::ContentAddress.valid_ruby_abi?("3.4") + assert Gem::ContentAddress.valid_ruby_abi?("10.0") + refute Gem::ContentAddress.valid_ruby_abi?("3") + refute Gem::ContentAddress.valid_ruby_abi?("3.4.0") + refute Gem::ContentAddress.valid_ruby_abi?("x.y") + refute Gem::ContentAddress.valid_ruby_abi?(nil) + refute Gem::ContentAddress.valid_ruby_abi?(3.4) + end + + def test_ruby_abi_for_with_abi_shaped_requirement + assert_equal "3.4", Gem::ContentAddress.ruby_abi_for(Gem::Requirement.new("~> 3.4.0")) + end + + def test_ruby_abi_for_with_nil_requirement + assert_nil Gem::ContentAddress.ruby_abi_for(nil) + end + + def test_ruby_abi_for_with_default_requirement + assert_nil Gem::ContentAddress.ruby_abi_for(Gem::Requirement.default) + end + + def test_ruby_abi_for_with_non_pessimistic_requirement + assert_nil Gem::ContentAddress.ruby_abi_for(Gem::Requirement.new(">= 3.0")) + end + + def test_ruby_abi_for_with_two_segment_pessimistic_requirement + assert_nil Gem::ContentAddress.ruby_abi_for(Gem::Requirement.new("~> 3.4")) + end + + def test_ruby_abi_for_with_nonzero_patch_level + assert_nil Gem::ContentAddress.ruby_abi_for(Gem::Requirement.new("~> 3.4.1")) + end + + def test_ruby_abi_for_with_compound_requirement + assert_nil Gem::ContentAddress.ruby_abi_for(Gem::Requirement.new(">= 3.2", "< 3.5")) + end + + def test_ruby_abi_requirement + assert_equal Gem::Requirement.new("~> 3.4.0"), Gem::ContentAddress.ruby_abi_requirement("3.4") + end + + def test_ruby_abi_requirement_round_trips_through_ruby_abi_for + assert_equal "3.4", Gem::ContentAddress.ruby_abi_for(Gem::ContentAddress.ruby_abi_requirement("3.4")) + end + + def test_platform_eligible + assert Gem::ContentAddress.platform_eligible?(Gem::Platform.new("x86_64-linux")) + refute Gem::ContentAddress.platform_eligible?(Gem::Platform::RUBY) + refute Gem::ContentAddress.platform_eligible?(nil) + end + + def test_content_addressed_row_with_ruby_platform + refute Gem::ContentAddress.content_addressed_row?("abcdef12", Gem::Platform::RUBY, Gem::Requirement.new("~> 3.4.0")) + end + + def test_eligible_with_abi_shaped_required_ruby_version_and_platform + spec = Gem::Specification.new "a", 1 + spec.required_ruby_version = "~> 3.4.0" + spec.platform = "x86_64-linux" + assert Gem::ContentAddress.eligible?(spec) + end + + def test_eligible_with_non_abi_shaped_required_ruby_version + spec = Gem::Specification.new "a", 1 + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + refute Gem::ContentAddress.eligible?(spec) + end + + def test_eligible_without_required_ruby_version + spec = Gem::Specification.new "a", 1 + spec.platform = "x86_64-linux" + refute Gem::ContentAddress.eligible?(spec) + end + + def test_eligible_with_ruby_platform + spec = Gem::Specification.new "a", 1 + spec.required_ruby_version = "~> 3.4.0" + refute Gem::ContentAddress.eligible?(spec) + end + + def test_eligible_with_nil_platform + spec = Gem::Specification.new "a", 1 + spec.required_ruby_version = "~> 3.4.0" + spec.platform = nil + refute Gem::ContentAddress.eligible?(spec) + end + + def test_ruby_abi_returns_nil_for_non_numeric_segments + assert_nil Gem::ContentAddress.ruby_abi_for(Gem::Requirement.new("~> 3.a.0")) + end + + def test_content_addressed_with_eligible_spec_and_valid_address + spec = Gem::Specification.new "a", 1 + spec.required_ruby_version = "~> 3.4.0" + spec.platform = "x86_64-linux" + spec.content_address = "abcdef12" + assert Gem::ContentAddress.content_addressed?(spec) + end + + def test_content_addressed_with_non_abi_shaped_required_ruby_version_and_valid_address + spec = Gem::Specification.new "a", 1 + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + spec.content_address = "abcdef12" + refute 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.4.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.4.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_eligible_without_ruby_abi_validation + spec = Gem::Specification.new "a", 1 + spec.platform = "x86_64-linux" + assert Gem::ContentAddress.eligible?(spec, validate_ruby_abi: false) + + spec.required_ruby_version = ">= 3.0" + assert Gem::ContentAddress.eligible?(spec, validate_ruby_abi: false) + end + + def test_eligible_without_ruby_abi_validation_still_requires_eligible_platform + spec = Gem::Specification.new "a", 1 + refute Gem::ContentAddress.eligible?(spec, validate_ruby_abi: false) + end + + def test_content_addressed_without_ruby_abi_validation + spec = Gem::Specification.new "a", 1 + spec.platform = "x86_64-linux" + spec.content_address = "abcdef12" + refute Gem::ContentAddress.content_addressed?(spec) + assert Gem::ContentAddress.content_addressed?(spec, validate_ruby_abi: false) + end + + def test_content_addressed_without_ruby_abi_validation_still_requires_address + spec = Gem::Specification.new "a", 1 + spec.platform = "x86_64-linux" + refute Gem::ContentAddress.content_addressed?(spec, validate_ruby_abi: false) + end + + def test_content_addressed_without_ruby_abi_validation_still_requires_eligible_platform + spec = Gem::Specification.new "a", 1 + spec.content_address = "abcdef12" + refute Gem::ContentAddress.content_addressed?(spec, validate_ruby_abi: false) + end + + def test_content_addressed_row + assert Gem::ContentAddress.content_addressed_row?("abcdef12", Gem::Platform.new("x86_64-linux"), Gem::Requirement.new("~> 3.4.0")) + end + + def test_content_addressed_row_without_address_shaped_suffix + refute Gem::ContentAddress.content_addressed_row?("x86_64-linux", Gem::Platform.new("x86_64-linux"), Gem::Requirement.new("~> 3.4.0")) + end + + def test_content_addressed_row_without_platform + refute Gem::ContentAddress.content_addressed_row?("abcdef12", nil, Gem::Requirement.new("~> 3.4.0")) + end + + def test_content_addressed_row_without_ruby_requirement + refute Gem::ContentAddress.content_addressed_row?("abcdef12", Gem::Platform.new("x86_64-linux"), nil) + end + + def test_content_addressed_row_with_non_abi_ruby_requirement + refute Gem::ContentAddress.content_addressed_row?("abcdef12", Gem::Platform.new("x86_64-linux"), Gem::Requirement.new(">= 3.0")) + end + + def test_content_addressed_row_without_ruby_abi_validation + assert Gem::ContentAddress.content_addressed_row?("abcdef12", Gem::Platform.new("x86_64-linux"), validate_ruby_abi: false) + refute Gem::ContentAddress.content_addressed_row?("abcdef12", nil, validate_ruby_abi: false) + refute Gem::ContentAddress.content_addressed_row?("x86_64-linux", Gem::Platform.new("x86_64-linux"), validate_ruby_abi: false) + end + + def test_ruby_abi_compatible_with_unset_requirement + spec = Gem::Specification.new "a", 1 + assert Gem::ContentAddress.ruby_abi_compatible?(spec, "3.4") + end + + def test_ruby_abi_compatible_with_matching_abi + spec = Gem::Specification.new "a", 1 + spec.required_ruby_version = "~> 3.4.0" + assert Gem::ContentAddress.ruby_abi_compatible?(spec, "3.4") + end + + def test_ruby_abi_compatible_with_different_abi + spec = Gem::Specification.new "a", 1 + spec.required_ruby_version = "~> 3.3.0" + refute Gem::ContentAddress.ruby_abi_compatible?(spec, "3.4") + end + + def test_ruby_abi_compatible_with_non_abi_shaped_requirement + spec = Gem::Specification.new "a", 1 + spec.required_ruby_version = ">= 3.0" + refute Gem::ContentAddress.ruby_abi_compatible?(spec, "3.4") + end + + def test_address_for + assert_equal Digest::SHA256.hexdigest("gem bytes")[0, 8], Gem::ContentAddress.address_for("gem bytes") + assert Gem::ContentAddress.match?(Gem::ContentAddress.address_for("gem bytes")) + end + + def test_address_for_with_length + address = Gem::ContentAddress.address_for("gem bytes", length: 64) + assert_equal Digest::SHA256.hexdigest("gem bytes"), address + assert Gem::ContentAddress.match?(address) + end + + def test_file_name_claim_with_address_suffix + spec = Gem::Specification.new "a", 1 + spec.platform = "x86_64-linux" + + assert_equal "abcdef12", Gem::ContentAddress.file_name_claim("a-1-abcdef12", spec) + end + + def test_file_name_claim_with_platform_suffix + spec = Gem::Specification.new "a", 1 + spec.platform = "x86_64-linux" + + assert_nil Gem::ContentAddress.file_name_claim("a-1-x86_64-linux", spec) + end + + def test_file_name_claim_without_suffix + spec = Gem::Specification.new "a", 1 + spec.platform = "x86_64-linux" + + assert_nil Gem::ContentAddress.file_name_claim("a-1", spec) + end + + def test_file_name_claim_with_hex_looking_original_platform + spec = Gem::Specification.new "a", 1 + spec.platform = "deadbeef" + + assert_nil Gem::ContentAddress.file_name_claim("a-1-deadbeef", spec) + end + + def test_file_name_claim_with_hex_looking_normalized_platform + duck_spec = Struct.new(:name, :version, :platform, :original_platform).new("a", "1", "deadbeef", "unknown") + assert_nil Gem::ContentAddress.file_name_claim("a-1-deadbeef", duck_spec) + end + + def test_verified_file_name_claim_returns_verified_address + spec, gem_path = util_gem("a", 2) + + address = Digest::SHA256.file(gem_path).hexdigest[0, 8] + ca_path = File.join(File.dirname(gem_path), "a-2-#{address}.gem") + FileUtils.cp gem_path, ca_path + + assert_equal address, Gem::ContentAddress.verified_file_name_claim(ca_path, spec) + end + + def test_verified_file_name_claim_returns_nil_without_claim + spec, gem_path = util_gem("a", 2) + + assert_nil Gem::ContentAddress.verified_file_name_claim(gem_path, spec) + end + + def test_verified_file_name_claim_raises_on_mismatch + spec, gem_path = util_gem("a", 2) + + ca_path = File.join(File.dirname(gem_path), "a-2-deadbeef.gem") + FileUtils.cp gem_path, ca_path + + e = assert_raise Gem::InstallError do + Gem::ContentAddress.verified_file_name_claim(ca_path, spec) + end + + assert_match(/content address mismatch/, e.message) + 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_doctor.rb b/test/rubygems/test_gem_doctor.rb index 1bcdc39022a8..9fd6f33641e2 100644 --- a/test/rubygems/test_gem_doctor.rb +++ b/test/rubygems/test_gem_doctor.rb @@ -192,4 +192,103 @@ def test_gem_repository_eh assert doctor.gem_repository?, "gems installed" end + + def test_doctor_preserves_valid_abi_scoped_gemspec + spec = util_ca_spec "ca_gem", "1", "aabbccdd", + required_ruby_version: "~> #{Gem.ruby_abi}.0", + platform: Gem::Platform.local.to_s + abi_dir = File.join @gemhome, "specifications", Gem.ruby_abi + FileUtils.mkdir_p abi_dir + gemspec_path = File.join(abi_dir, "#{spec.full_name}.gemspec") + File.write gemspec_path, spec.to_ruby_for_cache + + doctor = Gem::Doctor.new @gemhome + + use_ui @ui do + doctor.doctor + end + + assert_path_exist abi_dir + assert_path_exist gemspec_path + end + + def test_doctor_removes_corrupt_abi_scoped_gemspec + install_specs util_spec "regular_gem" + + spec = util_ca_spec "ca_gem", "1", "aabbccdd", + required_ruby_version: "~> #{Gem.ruby_abi}.0", + platform: Gem::Platform.local.to_s + abi_dir = File.join @gemhome, "specifications", Gem.ruby_abi + FileUtils.mkdir_p abi_dir + gemspec_path = File.join(abi_dir, "#{spec.full_name}.gemspec") + File.write gemspec_path, spec.to_ruby_for_cache + + corrupt_path = File.join(abi_dir, "corrupt_gem-1-deadbeef.gemspec") + File.write corrupt_path, "this will raise an exception when evaluated." + + doctor = Gem::Doctor.new @gemhome + + use_ui @ui do + doctor.doctor + end + + assert_path_exist abi_dir + assert_path_exist gemspec_path + assert_path_not_exist corrupt_path + end + + def test_doctor_preserves_other_abi_dir + install_specs util_spec "regular_gem" + + abi_dir = File.join @gemhome, "specifications", "9.9" + FileUtils.mkdir_p abi_dir + gemspec_path = File.join(abi_dir, "other_ruby_gem-1-deadbeef.gemspec") + File.write gemspec_path, "belongs to another Ruby installation" + + doctor = Gem::Doctor.new @gemhome + + use_ui @ui do + doctor.doctor + end + + assert_path_exist abi_dir + assert_path_exist gemspec_path + end + + def test_doctor_does_not_recurse_into_abi_symlink + pend "symlinks not supported" unless symlink_supported? + + install_specs util_spec "regular_gem" + + target_dir = File.join @tempdir, "outside_repository" + FileUtils.mkdir_p target_dir + outside_path = File.join(target_dir, "outside_gem-1-deadbeef.gemspec") + File.write outside_path, "outside the gem repository" + + link = File.join @gemhome, "specifications", Gem.ruby_abi + File.symlink target_dir, link + + doctor = Gem::Doctor.new @gemhome + + use_ui @ui do + doctor.doctor + end + + assert File.symlink?(link) + assert_path_exist outside_path + end + + def test_doctor_preserves_empty_abi_dir + install_specs util_spec "regular_gem" + abi_dir = File.join @gemhome, "specifications", Gem.ruby_abi + FileUtils.mkdir_p abi_dir + + doctor = Gem::Doctor.new @gemhome + + use_ui @ui do + doctor.doctor + end + + assert_path_exist abi_dir + end end diff --git a/test/rubygems/test_gem_installer.rb b/test/rubygems/test_gem_installer.rb index 5bd5bf89f05d..162f8dc347a9 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 @@ -810,6 +811,186 @@ def test_generate_plugins assert File.exist?(plugin_path), "plugin not written" end + def test_generate_plugins_for_content_addressed_gem_installs_stub_under_ruby_abi + ruby_abi = Gem.ruby_abi + + _, a_gem = util_gem "a", 2, ruby_abi: ruby_abi do |spec| + spec.platform = "x86_64-linux" + + write_file File.join(@tempdir, "lib", "rubygems_plugin.rb") do |io| + io.write "# do nothing" + end + + spec.files += %w[lib/rubygems_plugin.rb] + end + + root_plugin_path = File.join Gem.plugindir(@gemhome), "a_plugin.rb" + write_file root_plugin_path do |io| + io.write "# previous root plugin stub" + end + + installer = Gem::Installer.at a_gem, install_dir: @gemhome, force: true + spec = installer.install + plugin_path = File.join Gem.plugindir(@gemhome), ruby_abi, "a_plugin.rb" + + assert_equal ruby_abi, spec.ruby_abi + assert_path_not_exist root_plugin_path + assert_path_exist plugin_path + assert_match %r{\Arequire_relative '../../gems/a-2-[0-9a-f]{8}/lib/rubygems_plugin\.rb'}, + File.read(plugin_path) + end + + def test_generate_plugins_for_non_content_addressed_gem_removes_abi_scoped_stub + ruby_abi = Gem.ruby_abi + abi_plugin_path = File.join Gem.plugindir(@gemhome), ruby_abi, "a_plugin.rb" + write_file abi_plugin_path do |io| + io.write "# previous ABI plugin stub" + end + + spec = quick_gem "a", 2 do |s| + write_file File.join(@tempdir, "lib", "rubygems_plugin.rb") do |io| + io.write "# do nothing" + end + + s.files += %w[lib/rubygems_plugin.rb] + end + + util_build_gem spec + + installer = Gem::Installer.at spec.cache_file, install_dir: @gemhome, force: true + installer.install + root_plugin_path = File.join Gem.plugindir(@gemhome), "a_plugin.rb" + + assert_path_exist root_plugin_path + assert_path_not_exist abi_plugin_path + assert_match %r{\Arequire_relative '../gems/a-2/lib/rubygems_plugin\.rb'}, + File.read(root_plugin_path) + end + + def test_generate_plugins_for_non_content_addressed_gem_without_plugin_removes_abi_scoped_stub + ruby_abi = Gem.ruby_abi + + _, ca_gem = util_gem "a", 1, ruby_abi: ruby_abi do |spec| + spec.platform = "x86_64-linux" + + write_file File.join(@tempdir, "lib", "rubygems_plugin.rb") do |io| + io.write "# do nothing" + end + + spec.files += %w[lib/rubygems_plugin.rb] + end + + Gem::Installer.at(ca_gem, install_dir: @gemhome, force: true).install + abi_plugin_path = File.join Gem.plugindir(@gemhome), ruby_abi, "a_plugin.rb" + + assert_path_exist abi_plugin_path + + spec = quick_gem "a", 2 + util_build_gem spec + + Gem::Installer.at(spec.cache_file, install_dir: @gemhome, force: true).install + + assert_path_not_exist abi_plugin_path + end + + def test_install_with_matching_content_address + _, a_gem = util_gem("a", 2) do |spec| + spec.required_ruby_version = "~> 3.4.0" + spec.platform = "x86_64-linux" + end + + address = Digest::SHA256.file(a_gem).hexdigest[0, 8] + ca_gem = File.join(File.dirname(a_gem), "a-2-#{address}.gem") + FileUtils.cp a_gem, ca_gem + + installer = Gem::Installer.at ca_gem, install_dir: @gemhome, force: true, + content_address: address + spec = installer.install + + assert_equal address, spec.content_address + assert_path_exist File.join(@gemhome, "gems", "a-2-#{address}") + end + + def test_install_raises_when_content_address_is_not_carried_by_package + platform_spec, platform_gem = util_gem("a", 2) do |spec| + spec.required_ruby_version = "~> 3.4.0" + spec.platform = "x86_64-linux" + end + + Gem::Installer.at(platform_gem, install_dir: @gemhome, force: true).install + platform_gem_dir = File.join(@gemhome, "gems", platform_spec.full_name) + assert_path_exist platform_gem_dir + + installer = Gem::Installer.at platform_gem, install_dir: @gemhome, force: true, + content_address: "deadbeef" + + e = assert_raise Gem::InstallError do + installer.install + end + + assert_match(/content address mismatch/, e.message) + assert_match(/expected deadbeef, got no content address/, e.message) + assert_path_exist platform_gem_dir + end + + def test_plugin_stub_dir_for_content_addressed_gem_is_abi_scoped + spec = Gem::Specification.new "a", 2 + spec.platform = "x86_64-linux" + spec.required_ruby_version = "~> 3.4.0" + spec.content_address = "abcdef12" + + installer = util_installer spec, @gemhome + + assert_equal File.join(@gemhome, "plugins", "3.4"), + installer.send(:plugin_stub_dir_for, spec, File.join(@gemhome, "plugins")) + end + + def test_plugin_stub_dir_for_spec_with_address_but_non_abi_requirement_is_not_abi_scoped + spec = Gem::Specification.new "a", 2 + spec.platform = "x86_64-linux" + spec.required_ruby_version = ">= 3.0" + spec.content_address = "abcdef12" + + installer = util_installer spec, @gemhome + + assert_equal File.join(@gemhome, "plugins"), + installer.send(:plugin_stub_dir_for, spec, File.join(@gemhome, "plugins")) + end + + def test_ruby_abi_plugin_dir_for_non_content_addressed_gem_uses_running_abi + spec = Gem::Specification.new "a", 2 + + installer = util_installer spec, @gemhome + + assert_equal File.join(@gemhome, "plugins", Gem.ruby_abi), + installer.send(:ruby_abi_plugin_dir_for, spec, File.join(@gemhome, "plugins")) + end + + def test_remove_plugins_for_content_addressed_gem_removes_stub_from_ruby_abi_dir + ruby_abi = Gem.ruby_abi + + _, a_gem = util_gem "a", 2, ruby_abi: ruby_abi do |spec| + spec.platform = "x86_64-linux" + + write_file File.join(@tempdir, "lib", "rubygems_plugin.rb") do |io| + io.write "# do nothing" + end + + spec.files += %w[lib/rubygems_plugin.rb] + end + + installer = Gem::Installer.at a_gem, install_dir: @gemhome, force: true + spec = installer.install + plugin_path = File.join Gem.plugindir(@gemhome), ruby_abi, "a_plugin.rb" + + assert_path_exist plugin_path + + FileUtils.rm File.join(spec.gem_dir, "lib", "rubygems_plugin.rb") + installer.generate_plugins + + assert_path_not_exist plugin_path + end + def test_generate_plugins_with_install_dir spec = quick_gem "a" do |s| write_file File.join(@tempdir, "lib", "rubygems_plugin.rb") do |io| @@ -1030,6 +1211,315 @@ 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.4.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", "3.4", "a-2-#{address}.gemspec") + assert_path_not_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.4.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.4.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 = "~> #{Gem.ruby_abi}.0" + spec.platform = Gem::Platform.local.to_s + 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.4.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", "3.4", "a-2-#{address}.gemspec") + assert_equal 1, Dir[File.join(@gemhome, "gems", "a-2*")].size + assert_equal 1, Dir[File.join(@gemhome, "specifications", "3.4", "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.4.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", "3.4", "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.4.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.4.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", "3.4", "a-2-#{address1}.gemspec") + assert_path_exist File.join(@gemhome, "specifications", "3.4", "a-2-#{address2}.gemspec") + assert_equal 2, Dir[File.join(@gemhome, "gems", "a-2-*")].size + assert_equal 2, Dir[File.join(@gemhome, "specifications", "3.4", "a-2-*.gemspec")].size + end + + def test_sequential_content_addressed_installs_with_restrictive_dir_mode + pend "chmod not supported" if Gem.win_platform? + + abi_spec_dir = File.join(@gemhome, "specifications", Gem.ruby_abi) + addresses = %w[a b].map do |name| + _, gem = util_gem(name, 2) do |spec| + spec.required_ruby_version = "~> #{Gem.ruby_abi}.0" + spec.platform = Gem::Platform.local.to_s + end + + address = Digest::SHA256.file(gem).hexdigest[0, 8] + filename = File.join(File.dirname(gem), "#{name}-2-#{address}.gem") + FileUtils.cp gem, filename + + Gem::Installer.at(filename, install_dir: @gemhome, force: true, dir_mode: 0o555).install + + [name, address] + end + + addresses.each do |name, address| + assert_path_exist File.join(abi_spec_dir, "#{name}-2-#{address}.gemspec") + end + + _, a_address = addresses.first + assert_equal 0o555, File.stat(File.join(@gemhome, "gems", "a-2-#{a_address}")).mode & 0o777 + assert_equal 0o555, File.stat(abi_spec_dir).mode & 0o777 + ensure + FileUtils.chmod(0o755, abi_spec_dir) if abi_spec_dir && File.directory?(abi_spec_dir) + end + + def test_incompatible_abi_content_addressed_install_is_not_registered_in_memory + incompatible_abi = "1.0" + refute_equal Gem.ruby_abi, incompatible_abi + + _, a_gem = util_gem("a", 2) do |spec| + spec.required_ruby_version = "~> #{incompatible_abi}.0" + spec.platform = Gem::Platform.local.to_s + end + + address = Digest::SHA256.file(a_gem).hexdigest[0, 8] + filename = File.join(File.dirname(a_gem), "a-2-#{address}.gem") + FileUtils.cp a_gem, filename + + use_ui @ui do + Gem::Installer.at(filename, force: true).install + end + + assert_path_exist File.join(@gemhome, "specifications", incompatible_abi, "a-2-#{address}.gemspec") + assert_empty Gem::Specification.find_all_by_name("a").map(&:full_name).grep(/#{address}/) + assert_match(/scoped to Ruby ABI #{incompatible_abi}/, @ui.output) + end + + def test_current_abi_content_addressed_install_is_registered_in_memory + _, a_gem = util_gem("a", 2) do |spec| + spec.required_ruby_version = "~> #{Gem.ruby_abi}.0" + spec.platform = Gem::Platform.local.to_s + end + + address = Digest::SHA256.file(a_gem).hexdigest[0, 8] + filename = File.join(File.dirname(a_gem), "a-2-#{address}.gem") + FileUtils.cp a_gem, filename + + Gem::Installer.at(filename, force: true).install + + assert_includes Gem::Specification.find_all_by_name("a").map(&:full_name), "a-2-#{address}" + 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 fdc0c22f45ab..f509fdfbc4d0 100644 --- a/test/rubygems/test_gem_package.rb +++ b/test/rubygems/test_gem_package.rb @@ -236,6 +236,417 @@ 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 Gem::Requirement.new(Gem::Package::MINIMUM_RUBYGEMS_VERSION), loaded_spec.required_rubygems_version + assert_equal "3.4", loaded_spec.ruby_abi + end + + def test_required_rubygems_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.new("~> 3.4.0") + + FileUtils.mkdir "lib" + + File.open "lib/code.rb", "w" do |io| + io.write "# lib/code.rb" + end + + assert_equal Gem::Requirement.default, spec.required_rubygems_version + + Gem::Package.build(spec, false, false, nil, "3.4") + + assert_equal Gem::Requirement.new(">= 4.1.0.a"), spec.required_rubygems_version + end + + def test_required_rubygems_version_untouched_when_floor_already_satisfied + 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") + spec.required_rubygems_version = Gem::Requirement.new(">= 4.2") + + FileUtils.mkdir "lib" + + File.open "lib/code.rb", "w" do |io| + io.write "# lib/code.rb" + end + + ui = Gem::MockGemUi.new + built_file = use_ui ui do + Gem::Package.build(spec, false, false, nil, "3.4") + end + + assert_equal Gem::Requirement.new(">= 4.2"), spec.required_rubygems_version + assert_equal Gem::Requirement.new(">= 4.2"), Gem::Package.new(built_file).spec.required_rubygems_version + refute_match "required_rubygems_version was changed", ui.error + end + + def test_required_rubygems_version_weaker_lower_bound_is_raised_to_floor_with_warning + 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") + spec.required_rubygems_version = Gem::Requirement.new(">= 3.0") + + FileUtils.mkdir "lib" + + File.open "lib/code.rb", "w" do |io| + io.write "# lib/code.rb" + end + + ui = Gem::MockGemUi.new + built_file = use_ui ui do + Gem::Package.build(spec, false, false, nil, "3.4") + end + + assert_equal Gem::Requirement.new(">= 4.1.0.a"), spec.required_rubygems_version + assert_equal Gem::Requirement.new(">= 4.1.0.a"), Gem::Package.new(built_file).spec.required_rubygems_version + + assert_match "required_rubygems_version was changed from \">= 3.0\" to \">= 4.1.0.a\"", ui.error + end + + def test_required_rubygems_version_upper_bound_above_minimum_is_preserved + 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") + spec.required_rubygems_version = Gem::Requirement.new("< 5.0") + + FileUtils.mkdir "lib" + + File.open "lib/code.rb", "w" do |io| + io.write "# lib/code.rb" + end + + ui = Gem::MockGemUi.new + use_ui ui do + Gem::Package.build(spec, false, false, nil, "3.4") + end + + assert_equal Gem::Requirement.new(["< 5.0", ">= 4.1.0.a"]), spec.required_rubygems_version + assert_match "required_rubygems_version was changed from \"< 5.0\" to \">= 4.1.0.a, < 5.0\"", ui.error + end + + def test_raise_if_required_rubygems_version_conflicts_with_content_addressing + FileUtils.mkdir "lib" + + File.open "lib/code.rb", "w" do |io| + io.write "# lib/code.rb" + end + + conflicting_requirements = [ + "~> 3.5", + "< 4.0", + "<= 4.0.9", + "= 3.5.9", + "~> 4.0.0", + "< 4.1.0.a", + ] + + conflicting_requirements.each do |conflicting| + 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") + spec.required_rubygems_version = Gem::Requirement.new(conflicting) + + 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_rubygems_version is set to #{Gem::Requirement.new(conflicting)}", e.message + assert_match "excludes RubyGems >= 4.1.0.a", e.message + assert_equal Gem::Requirement.new(conflicting), spec.required_rubygems_version + assert_empty Dir["platformed-1-*.gem"] + end + end + + def test_required_rubygems_version_is_not_duplicated_if_already_present + 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") + spec.required_rubygems_version = Gem::Requirement.new(Gem::Package::MINIMUM_RUBYGEMS_VERSION) + + FileUtils.mkdir "lib" + + File.open "lib/code.rb", "w" do |io| + io.write "# lib/code.rb" + end + + Gem::Package.build(spec, false, false, nil, "3.4") + + assert_equal [">= 4.1.0.a"], spec.required_rubygems_version.as_list + end + + def test_required_rubygems_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.new("~> 3.4.0") + + FileUtils.mkdir "lib" + + File.open "lib/code.rb", "w" do |io| + io.write "# lib/code.rb" + end + + assert_raise Gem::InvalidSpecificationException do + Gem::Package.build(spec, false, false, nil, "3.4") + end + + assert_equal Gem::Requirement.default, spec.required_rubygems_version + 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) + assert_equal Gem::Requirement.default, spec.required_rubygems_version + assert_equal Gem::Requirement.default, Gem::Package.new(built_file).spec.required_rubygems_version + 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 2c599e00986b..9171c56ee97d 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.4.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" @@ -273,6 +296,49 @@ def fetcher.fetch_path(uri, *rest) end end + def test_download_content_addressed_gem_does_not_fall_back_to_platform_name + ca_spec, = util_gem "a", "1" do |s| + s.required_ruby_version = "~> 3.4.0" + s.platform = "x86_64-linux" + end + ca_spec.content_address = "abcdef12" + + fetcher = Gem::RemoteFetcher.fetcher + def fetcher.fetch_path(uri, *rest) + @tried_uris ||= [] + @tried_uris << uri.to_s + raise Gem::RemoteFetcher::FetchError.new("not found", uri) + end + + assert_raise Gem::RemoteFetcher::FetchError do + fetcher.download(ca_spec, "http://gems.example.com") + end + + tried_uris = fetcher.instance_variable_get(:@tried_uris) + assert_equal ["http://gems.example.com/gems/a-1-abcdef12.gem"], tried_uris + assert_path_not_exist ca_spec.cache_file + end + + def test_download_does_not_retry_identical_alternate_name + a1_spec, = util_gem "a", "1" do |s| + s.platform = "x86_64-linux" + end + + fetcher = Gem::RemoteFetcher.fetcher + def fetcher.fetch_path(uri, *rest) + @tried_uris ||= [] + @tried_uris << uri.to_s + raise Gem::RemoteFetcher::FetchError.new("not found", uri) + end + + assert_raise Gem::RemoteFetcher::FetchError do + fetcher.download(a1_spec, "http://gems.example.com") + end + + tried_uris = fetcher.instance_variable_get(:@tried_uris) + assert_equal ["http://gems.example.com/gems/a-1-x86_64-linux.gem"], tried_uris + end + def test_download_platform_legacy original_platform = "old-platform" diff --git a/test/rubygems/test_gem_resolver.rb b/test/rubygems/test_gem_resolver.rb index 84ede36b6c85..8e17d5c11079 100644 --- a/test/rubygems/test_gem_resolver.rb +++ b/test/rubygems/test_gem_resolver.rb @@ -322,6 +322,137 @@ 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_prefers_compatible_content_addressed_gem_over_more_specific_platform + util_set_arch "arm64-darwin-27" + current_abi = "#{Gem.ruby_version.segments[0]}.#{Gem.ruby_version.segments[1]}" + + ca_spec = util_spec "a", "1" + fat_spec = util_spec "a", "1" + + ca_spec.platform = "arm64-darwin" + ca_spec.required_ruby_version = "~> #{current_abi}.0" + ca_spec.content_address = "abc1234567" + fat_spec.platform = "arm64-darwin-27" + + s = set(fat_spec, ca_spec) + dependency = make_dep "a" + resolver = Gem::Resolver.new([dependency], s) + assert_resolves_to [ca_spec], resolver + end + + def test_prefers_more_specific_platform_over_content_addressed_gem_for_another_ruby + util_set_arch "arm64-darwin-27" + + ca_spec = util_spec "a", "1" + fat_spec = util_spec "a", "1" + + ca_spec.platform = "arm64-darwin" + ca_spec.required_ruby_version = ">= 999" + ca_spec.content_address = "abc1234567" + fat_spec.platform = "arm64-darwin-27" + + s = set(fat_spec, ca_spec) + dependency = make_dep "a" + resolver = Gem::Resolver.new([dependency], s) + assert_resolves_to [fat_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..5a563f4161eb 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,95 @@ 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.to_s], ruby: ["~> 3.4.0"] }, + } + + 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_assign_content_address_without_ruby_requirement + set = Gem::Resolver::APISet.new + data = { + name: "rails", + number: "3.0.3", + suffix: "abc1234567", + dependencies: [], + requirements: { platform: [Gem::Platform.local.to_s] }, + } + + spec = Gem::Resolver::APISpecification.new set, data + + assert_nil spec.content_address + end + + def test_initialize_does_not_assign_content_address_with_non_abi_ruby_requirement + set = Gem::Resolver::APISet.new + data = { + name: "rails", + number: "3.0.3", + suffix: "abc1234567", + dependencies: [], + requirements: { platform: [Gem::Platform.local.to_s], ruby: [">= 3.0"] }, + } + + spec = Gem::Resolver::APISpecification.new set, data + + assert_nil 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.to_s] }, + } + + 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.to_s] }, + } + + data[:requirements][:ruby] = ["~> 3.4.0"] + 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 +133,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 +148,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 +216,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 +238,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.to_s], ruby: ["~> 3.4.0"] }, + } + + 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 +284,7 @@ def test_installable_platform_eh data = { name: "b", number: "1", - platform: "cpu-other_platform-1", + suffix: "cpu-other_platform-1", dependencies: [], } @@ -182,7 +295,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 +309,7 @@ def test_source data = { name: "a", number: "1", - platform: "ruby", + suffix: "ruby", dependencies: [], } @@ -215,7 +328,7 @@ def test_spec data = { name: "a", number: "1", - platform: "ruby", + suffix: "ruby", dependencies: [], } @@ -239,7 +352,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_resolver_specification.rb b/test/rubygems/test_gem_resolver_specification.rb index e2bbce0c0c02..a741a063ba0e 100644 --- a/test/rubygems/test_gem_resolver_specification.rb +++ b/test/rubygems/test_gem_resolver_specification.rb @@ -34,6 +34,53 @@ def test_install assert_equal expected, a_spec.spec.loaded_from end + def test_install_passes_content_address_for_content_addressed_spec + gemhome = "#{@gemhome}2" + spec_fetcher do |fetcher| + fetcher.gem "a", 1 + end + + a = util_spec "a", 1 do |s| + s.platform = "x86_64-linux" + s.required_ruby_version = "~> 3.4.0" + end + a.content_address = "abcdef12" + @fetcher.data["#{@gem_repo}gems/a-1-abcdef12.gem"] = @fetcher.data["#{@gem_repo}gems/a-1.gem"] + + a_spec = TestSpec.new a + a_spec.source = Gem::Source.new @gem_repo + + expected_option = nil + begin + a_spec.install install_dir: gemhome do |installer| + expected_option = installer.options[:content_address] + raise Gem::InstallError, "stop before extracting" + end + rescue Gem::InstallError + end + + assert_equal "abcdef12", expected_option + end + + def test_install_does_not_pass_content_address_for_plain_spec + gemhome = "#{@gemhome}2" + spec_fetcher do |fetcher| + fetcher.gem "a", 1 + end + + a = util_spec "a", 1 + + a_spec = TestSpec.new a + a_spec.source = Gem::Source.new @gem_repo + + expected_option = :unset + a_spec.install install_dir: gemhome do |installer| + expected_option = installer.options[:content_address] + end + + assert_nil expected_option + end + def test_installable_platform_eh a = util_spec "a", 1 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..929d42a6c1cf 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..e97171965260 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 = "~> #{ruby_abi}.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..025fec7e66eb 100644 --- a/test/rubygems/test_gem_specification.rb +++ b/test/rubygems/test_gem_specification.rb @@ -1907,6 +1907,162 @@ 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_spec_paths_for_content_addressed_spec_use_abi_scoped_dir + spec = util_ca_spec "a", "2", "78be552b", ruby_abi: "3.4" + spec.loaded_from = File.join @gemhome, "specifications", "3.4", "a-2-78be552b.gemspec" + + assert_equal @gemhome, spec.base_dir + assert_equal File.join(@gemhome, "specifications", "3.4"), spec.spec_dir + assert_equal File.join(@gemhome, "specifications", "3.4", "a-2-78be552b.gemspec"), + spec.spec_file + assert_equal File.join(@gemhome, "gems", "a-2-78be552b"), spec.gem_dir + end + + def test_spec_file_for_content_addressed_spec_loaded_from_flat_dir + spec = util_ca_spec "a", "2", "78be552b", ruby_abi: "3.4" + spec.loaded_from = File.join @gemhome, "specifications", "a-2-78be552b.gemspec" + + assert_equal File.join(@gemhome, "specifications"), spec.spec_dir + assert_equal File.join(@gemhome, "specifications", "a-2-78be552b.gemspec"), + spec.spec_file + end + + def test_base_dir_unchanged_for_abi_shaped_dir_outside_specifications + spec = util_spec "a", 2 + spec.loaded_from = File.join @tempdir, "3.4", "a-2.gemspec" + + assert_equal @tempdir, spec.base_dir + end + + def test_specification_record_dirs_from_includes_current_abi_dir + assert_equal [File.join(@gemhome, "specifications"), + File.join(@gemhome, "specifications", Gem.ruby_abi)], + Gem::SpecificationRecord.dirs_from([@gemhome]) + end + + def test_specification_record_abi_scoped_spec_dir_eh + assert Gem::SpecificationRecord.abi_scoped_spec_dir?(File.join(@gemhome, "specifications", "3.4")) + + refute Gem::SpecificationRecord.abi_scoped_spec_dir?(File.join(@gemhome, "specifications")) + refute Gem::SpecificationRecord.abi_scoped_spec_dir?(File.join(@gemhome, "specifications", "default")) + refute Gem::SpecificationRecord.abi_scoped_spec_dir?(File.join(@tempdir, "3.4")) + end + + def test_specification_record_specification_dir_for_content_addressed_spec + spec = util_ca_spec "a", "2", "78be552b", ruby_abi: "3.4" + + assert_equal File.join(@gemhome, "specifications", "3.4"), + Gem::SpecificationRecord.specification_dir_for(spec, @gemhome) + end + + def test_specification_record_specification_dir_for_non_content_addressed_spec + spec = util_spec "a", 2 do |s| + s.required_ruby_version = "~> 3.4.0" + s.platform = "x86_64-linux" + end + + assert_equal File.join(@gemhome, "specifications"), + Gem::SpecificationRecord.specification_dir_for(spec, @gemhome) + end + + def test_self_stubs_finds_content_addressed_gemspec_in_current_abi_dir + write_ca_gemspec_in "ca_gem", Gem.ruby_abi + + Gem::Specification.reset + + abi_dir = File.join(@gemhome, "specifications", Gem.ruby_abi) + assert_path_exist File.join(abi_dir, "ca_gem-1-aabbccdd.gemspec"), + "content-addressed gemspec file should exist in the ABI dir" + assert_includes Gem::SpecificationRecord.dirs_from([@gemhome]), abi_dir, + "dirs_from should include the ABI dir" + glob = Gem::Util.glob_files_in_dir("*.gemspec", abi_dir) + assert_includes glob.map {|f| File.basename(f) }, "ca_gem-1-aabbccdd.gemspec", + "Dir.glob should find the content-addressed gemspec in the ABI dir" + stub_path = File.join(abi_dir, "ca_gem-1-aabbccdd.gemspec") + gemspec_stub = Gem::StubSpecification.gemspec_stub(stub_path, @gemhome, File.join(@gemhome, "gems")) + assert gemspec_stub.valid?, "StubSpecification should be valid (data parses correctly)" + + all_stubs = Gem::Specification.stubs + stub_names = all_stubs.map(&:full_name) + assert_includes stub_names, "ca_gem-1-aabbccdd", + "stubs should include ca_gem (got: #{stub_names.inspect})" + + stub = Gem::Specification.stubs.find {|s| s.name == "ca_gem" } + + refute_nil stub + assert_equal "ca_gem-1-aabbccdd", stub.full_name + assert_equal @gemhome, stub.base_dir + assert_equal File.join(@gemhome, "gems", "ca_gem-1-aabbccdd"), stub.full_gem_path + end + + def test_content_addressed_gemspec_for_other_abi_is_not_an_activation_candidate + other_abi = "1.0" + refute_equal Gem.ruby_abi, other_abi + + write_ca_gemspec_in "ca_gem", other_abi + + Gem::Specification.reset + + assert_nil Gem::Specification.stubs.find {|s| s.name == "ca_gem" } + end + + def test_flat_record_does_not_see_abi_scoped_gemspecs + write_ca_gemspec_in "ca_gem", Gem.ruby_abi + + record = Gem::SpecificationRecord.new([File.join(@gemhome, "specifications")]) + + assert_nil record.stubs.find {|s| s.name == "ca_gem" } + end + + def write_ca_gemspec_in(name, abi) + spec = util_ca_spec name, "1", "aabbccdd", required_ruby_version: "~> #{abi}.0" + abi_dir = File.join @gemhome, "specifications", abi + FileUtils.mkdir_p abi_dir + File.write File.join(abi_dir, "#{spec.full_name}.gemspec"), spec.to_ruby_for_cache + spec + end + def test_full_name assert_equal "a-1", @a1.full_name @@ -1925,6 +2081,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.4.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 +2116,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 +2524,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.4.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_uninstaller.rb b/test/rubygems/test_gem_uninstaller.rb index d1aacb40536b..137bb1de97a9 100644 --- a/test/rubygems/test_gem_uninstaller.rb +++ b/test/rubygems/test_gem_uninstaller.rb @@ -326,6 +326,34 @@ def test_uninstall assert_same uninstaller, @post_uninstall_hook_arg end + def test_uninstall_content_addressed_gem_removes_abi_scoped_gemspec + _, a_gem = util_gem("ca_gone", 2) do |spec| + spec.required_ruby_version = "~> #{Gem.ruby_abi}.0" + spec.platform = Gem::Platform.local.to_s + end + + digest = Digest::SHA256.file(a_gem).hexdigest + address = digest[0, 8] + filename = File.join(File.dirname(a_gem), "ca_gone-2-#{address}.gem") + FileUtils.cp a_gem, filename + + Gem::Installer.at(filename, force: true).install + Gem::Specification.reset + + gemspec = File.join(@gemhome, "specifications", Gem.ruby_abi, "ca_gone-2-#{address}.gemspec") + gem_dir = File.join(@gemhome, "gems", "ca_gone-2-#{address}") + cache_file = File.join(@gemhome, "cache", "ca_gone-2-#{address}.gem") + assert_path_exist gemspec + assert_path_exist gem_dir + assert_path_exist cache_file + + Gem::Uninstaller.new("ca_gone", executables: true, force: true).uninstall + + assert_path_not_exist gemspec + assert_path_not_exist gem_dir + assert_path_not_exist cache_file + end + def test_uninstall_default_gem spec = new_default_spec "default", "2" 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