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