From 3ed0b618e2334dd91f467f8c2d4e68673b02974c Mon Sep 17 00:00:00 2001 From: Harriet Oughton Date: Fri, 28 Aug 2026 00:27:39 -0400 Subject: [PATCH 01/17] Support content addressable gems in gem build Co-authored-by: Jenny Shen --- lib/rubygems/commands/build_command.rb | 9 +- lib/rubygems/package.rb | 169 ++++++- lib/rubygems/package_task.rb | 4 +- lib/rubygems/specification.rb | 17 + lib/rubygems/version_option.rb | 14 + .../test_gem_commands_build_command.rb | 190 +++++++- test/rubygems/test_gem_package.rb | 411 ++++++++++++++++++ test/rubygems/test_gem_package_task.rb | 41 ++ test/rubygems/test_gem_specification.rb | 36 ++ test/rubygems/test_gem_version_option.rb | 20 + 10 files changed, 900 insertions(+), 11 deletions(-) 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/package.rb b/lib/rubygems/package.rb index e1a80717dec6..cf61ca7a4637 100644 --- a/lib/rubygems/package.rb +++ b/lib/rubygems/package.rb @@ -129,13 +129,51 @@ class TarInvalidError < Error; end # Permission for other files attr_accessor :data_mode - def self.build(spec, skip_validation = false, strict_validation = false, file_name = nil) - gem_file = file_name || spec.file_name + ## + # The number of characters of the SHA-256 digest of the gem contents used + # in a content-addressable gem file name. + + DEFAULT_CONTENT_ADDRESS_LENGTH = 8 + + ## + # 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. - package = new gem_file - package.spec = spec - package.build skip_validation, strict_validation + 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 + + 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 @@ -315,16 +353,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::Requirement.new("~> #{ruby_abi}.0") + + build skip_validation, strict_validation + + bytes = @gem.with_read_io(&:read) + gem_file = "#{@spec.name}-#{@spec.version}-#{Digest::SHA256.hexdigest(bytes)[0, DEFAULT_CONTENT_ADDRESS_LENGTH]}.gem" + File.binwrite(gem_file, bytes) + + say " File: #{gem_file}" + say " Platform: #{@spec.platform}" + say " Ruby ABI: #{ruby_abi}" + + gem_file + end + ## # A list of file names contained in this gem @@ -641,6 +710,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 !/\A\d+\.\d+\z/.match?(ruby_abi) + raise ArgumentError, "Ruby ABI must be in X.Y format" + elsif @spec.platform.nil? || @spec.platform == Gem::Platform::RUBY + raise ArgumentError, "Cannot build a gem scoped to a single Ruby ABI as no platform or a Ruby platform has been set" + elsif @spec.required_ruby_version && @spec.required_ruby_version != Gem::Requirement.default && @spec.ruby_abi != ruby_abi + raise ArgumentError, "Cannot build gem for Ruby ABI #{ruby_abi} because required_ruby_version is set to #{@spec.required_ruby_version}. Please set required_ruby_version to \"~> #{ruby_abi}.0\"." + end + end + ## # 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/specification.rb b/lib/rubygems/specification.rb index adee800051fc..48fc50c8e40d 100644 --- a/lib/rubygems/specification.rb +++ b/lib/rubygems/specification.rb @@ -568,6 +568,23 @@ def add_dependency(gem, *requirements) add_dependency_with_type(gem, :runtime, requirements) end + ## + # Ruby ABI of the gem derived from required_ruby_version + # Only supports required_ruby_version in "~> X.Y.0" format (single pessimistic requirement with 3 segments) + # Returns nil if the required_ruby_version does not specify a single Ruby ABI + + def ruby_abi + return nil if required_ruby_version.nil? || required_ruby_version == Gem::Requirement.default + + requirements = required_ruby_version.requirements + return nil if requirements.size != 1 + + op, version = requirements.first + return nil if op != "~>" || version.segments.size != 3 || version.segments[2] != 0 + + version.segments[0..1].join(".") + end + ## # Executables included in the gem. # diff --git a/lib/rubygems/version_option.rb b/lib/rubygems/version_option.rb index 7910fd3d1b50..3da18dd98253 100644 --- a/lib/rubygems/version_option.rb +++ b/lib/rubygems/version_option.rb @@ -46,6 +46,20 @@ def add_prerelease_option(*wrap) end end + ## + # Add the --ruby-abi option to the option parser. + + def add_ruby_abi_option(task = command, *wrap) + add_option("--ruby-abi RUBY_ABI", + "Specify the Ruby ABI of gem to #{task}", *wrap) do |value, options| + unless /\A\d+\.\d+\z/.match?(value) + raise Gem::OptionParser::InvalidArgument, "#{value}: Ruby ABI must be in X.Y format" + end + + options[:ruby_abi] = value + end + end + ## # Add the --version option to the option parser. diff --git a/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_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_specification.rb b/test/rubygems/test_gem_specification.rb index c63e68be47dd..ab37ada378fa 100644 --- a/test/rubygems/test_gem_specification.rb +++ b/test/rubygems/test_gem_specification.rb @@ -1907,6 +1907,42 @@ 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_full_name assert_equal "a-1", @a1.full_name 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 From e25a58ade8138b7cc5a20d6c1a44c89b2c5e832c Mon Sep 17 00:00:00 2001 From: Gira Chawda Date: Sun, 30 Aug 2026 13:11:37 -0400 Subject: [PATCH 02/17] Support content addressable gems in gem list, search, and info Co-authored-by: Harriet Oughton Co-authored-by: Jenny Shen --- Manifest.txt | 1 + lib/rubygems.rb | 1 + lib/rubygems/content_address.rb | 37 +++ lib/rubygems/name_tuple.rb | 73 ++++-- lib/rubygems/query_utils.rb | 117 +++++++-- lib/rubygems/safe_marshal.rb | 2 +- lib/rubygems/source.rb | 145 ++++++++++- lib/rubygems/specification.rb | 16 +- test/rubygems/helper.rb | 35 ++- .../test_gem_commands_info_command.rb | 234 ++++++++++++++++++ .../test_gem_commands_list_command.rb | 173 +++++++++++++ .../test_gem_commands_search_command.rb | 197 +++++++++++++++ test/rubygems/test_gem_content_address.rb | 83 +++++++ test/rubygems/test_gem_name_tuple.rb | 131 ++++++++++ test/rubygems/test_gem_safe_marshal.rb | 35 +++ test/rubygems/test_gem_source.rb | 103 ++++++++ 16 files changed, 1331 insertions(+), 52 deletions(-) create mode 100644 lib/rubygems/content_address.rb create mode 100644 test/rubygems/test_gem_content_address.rb 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/lib/rubygems.rb b/lib/rubygems.rb index 1cac0433cd81..2835862e2c1a 100644 --- a/lib/rubygems.rb +++ b/lib/rubygems.rb @@ -1411,6 +1411,7 @@ def default_gem_load_paths MARSHAL_SPEC_DIR = "quick/Marshal.#{Gem.marshal_version}/".freeze autoload :ConfigFile, File.expand_path("rubygems/config_file", __dir__) + autoload :ContentAddress, File.expand_path("rubygems/content_address", __dir__) autoload :CIDetector, File.expand_path("rubygems/ci_detector", __dir__) autoload :Dependency, File.expand_path("rubygems/dependency", __dir__) autoload :DependencyList, File.expand_path("rubygems/dependency_list", __dir__) diff --git a/lib/rubygems/content_address.rb b/lib/rubygems/content_address.rb new file mode 100644 index 000000000000..bb48d10a5339 --- /dev/null +++ b/lib/rubygems/content_address.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +## +# Gem::ContentAddress encapsulates the pattern for recognizing +# content-addressable gem file names. + +module Gem::ContentAddress + # :nodoc: + PATTERN = /\A[0-9a-f]{8,64}\z/ + + ## + # Whether +spec+ is eligible for content addressing. A gem must + # pin a required_ruby_version and declare a non-RUBY platform to be + # content addressed. + + def self.applicable?(spec) + required_ruby_version = spec.required_ruby_version + !required_ruby_version.nil? && !required_ruby_version.none? && + !spec.platform.nil? && spec.platform != Gem::Platform::RUBY + end + + ## + # Whether +spec+ is content-addressed: it is eligible for content + # addressing and has a valid content address set. + + def self.content_addressed?(spec) + applicable?(spec) && match?(spec.content_address) + end + + ## + # Whether +value+ is a valid content address (a string of 8-64 + # lowercase hexadecimal characters). + + def self.match?(value) + value.is_a?(String) && PATTERN.match?(value) + end +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/query_utils.rb b/lib/rubygems/query_utils.rb index 9849370b1a62..91fe2535101b 100644 --- a/lib/rubygems/query_utils.rb +++ b/lib/rubygems/query_utils.rb @@ -149,14 +149,29 @@ 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 + decode_content_addressable_tuples(matching_tuples, latest: specs_type == :latest) + end end output_query_results(spec_tuples) end + def decode_content_addressable_tuples(spec_tuples, latest: false) + spec_tuples.group_by {|_, source| source }.flat_map do |source, source_tuples| + next source_tuples unless source.respond_to?(:decode_content_addressable_tuples) + + tuples = source_tuples.map(&:first) + source.decode_content_addressable_tuples(tuples, latest: latest).map {|tuple| [tuple, source] } + end + end + def specs_type if options[:all] || options[:version].specific? if options[:prerelease] @@ -200,9 +215,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 +233,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 +246,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 +258,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 - entry << " (#{list.join ", "})" + 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 make_entry(entry_tuples, platforms) + def version_platform_labels(version, platforms, platform_ruby_abis, label_platform: false) + platforms = platforms.uniq + return [] if platforms == [Gem::Platform::RUBY] + + 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, platform_ruby_abis) detail_tuple = entry_tuples.first name_tuples, specs = entry_tuples.flatten.partition do |item| @@ -275,8 +323,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 +367,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 +375,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 +391,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/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..7548a33ce46b 100644 --- a/lib/rubygems/source.rb +++ b/lib/rubygems/source.rb @@ -185,6 +185,42 @@ def compact_index_client # :nodoc: end end + ## + # 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 +platform+, when this # source provides it through the compact index created_at metadata. @@ -290,13 +326,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 +353,108 @@ 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 + + class ContentAddressableInfo + attr_reader :version, :suffix, :ruby_abi, :platform + + def initialize(version, suffix, ruby_abi, platform = nil) + @version = version + @suffix = suffix + @ruby_abi = ruby_abi + @platform = platform + end + + def hash + [@version, @suffix].hash + end + + def eql?(other) + other.is_a?(ContentAddressableInfo) && + version == other.version && + suffix == other.suffix + end + 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_PLATFORM] + + requirements = compact_index_requirements(info_row) + platform = required_platform_from(requirements[:platform]) + next unless platform + next unless requirements[:ruby] + + ContentAddressableInfo.new(version, suffix, ruby_abi_from(requirements[:ruby]), platform) + end + + available_rows & wanted_rows + end + + def compact_index_requirements(info_row) + info_row[Gem::CompactIndexClient::INFO_REQS].to_h do |key, requirements| + [key.to_sym, requirements] + end + end + + def required_platform_from(requirement) + platform_requirement = Array(requirement).last.to_s + operator, platform = platform_requirement.split(" ", 2) + return unless operator == "=" && platform + + platform + end + + def ruby_abi_from(requirement) + Array(requirement).each do |ruby_requirement| + match = ruby_requirement.to_s.match(/\A~>\s*(\d+)\.(\d+)\.0\z/) + return "#{match[1]}.#{match[2]}" if match + end + + nil + end + def max_versions_by_platform(tuples) - tuples.group_by(&:platform).map {|_, platform_tuples| platform_tuples.max_by(&:version) } + grouped_tuples = tuples.group_by {|tuple| latest_platform_key(tuple) } + grouped_tuples.map do |_, platform_tuples| + platform_tuples.max_by(&:version) + end + end + + def latest_platform_key(tuple) + if tuple.content_address + [tuple.platform, tuple.ruby_abi || tuple.content_address] + else + tuple.platform + end end def compact_index_uri diff --git a/lib/rubygems/specification.rb b/lib/rubygems/specification.rb index 48fc50c8e40d..f2c3d7c27f96 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 @@ -1371,7 +1378,8 @@ def ==(other) # :nodoc: self.class === other && name == other.name && version == other.version && - platform == other.platform + platform == other.platform && + content_address == other.content_address end ## @@ -1942,7 +1950,7 @@ def has_unit_tests? # :nodoc: # :startdoc: def hash # :nodoc: - name.hash ^ version.hash + [name, version, platform, content_address].hash end def init_with(coder) # :nodoc: @@ -1978,6 +1986,7 @@ def initialize(name = nil, version = nil) @loaded_from = nil @original_platform = nil @installed_by_version = nil + @content_address = nil set_nil_attributes_to_nil set_not_nil_attributes_to_default_values @@ -2298,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? diff --git a/test/rubygems/helper.rb b/test/rubygems/helper.rb index 72935437878a..1d7fddb4230c 100644 --- a/test/rubygems/helper.rb +++ b/test/rubygems/helper.rb @@ -1008,6 +1008,24 @@ 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 @@ -1226,7 +1244,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 +1266,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 +1283,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 +1311,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_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_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_content_address.rb b/test/rubygems/test_gem_content_address.rb new file mode 100644 index 000000000000..d89355049ce4 --- /dev/null +++ b/test/rubygems/test_gem_content_address.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require_relative "helper" +require "rubygems/content_address" + +class TestGemContentAddress < Gem::TestCase + def test_match + assert Gem::ContentAddress.match?("78be552b") + refute Gem::ContentAddress.match?("x86_64-linux") + refute Gem::ContentAddress.match?(nil) + refute Gem::ContentAddress.match?("") + refute Gem::ContentAddress.match?(0xabcdef12) + refute Gem::ContentAddress.match?(:abcdef12) + refute Gem::ContentAddress.match?("abcdef12 ") + refute Gem::ContentAddress.match?(" abcdef12") + end + + def test_match_boundary_lengths + assert Gem::ContentAddress.match?("a" * 8) + assert Gem::ContentAddress.match?("a" * 64) + refute Gem::ContentAddress.match?("a" * 7) + refute Gem::ContentAddress.match?("a" * 65) + end + + def test_match_rejects_uppercase + refute Gem::ContentAddress.match?("ABCDEF12") + end + + def test_applicable_with_required_ruby_version_and_platform + spec = Gem::Specification.new "a", 1 + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + assert Gem::ContentAddress.applicable?(spec) + end + + def test_applicable_without_required_ruby_version + spec = Gem::Specification.new "a", 1 + spec.platform = "x86_64-linux" + refute Gem::ContentAddress.applicable?(spec) + end + + def test_applicable_with_ruby_platform + spec = Gem::Specification.new "a", 1 + spec.required_ruby_version = ">= 3.0" + refute Gem::ContentAddress.applicable?(spec) + end + + def test_applicable_with_nil_platform + spec = Gem::Specification.new "a", 1 + spec.required_ruby_version = ">= 3.0" + spec.platform = nil + refute Gem::ContentAddress.applicable?(spec) + end + + def test_content_addressed_with_eligible_spec_and_valid_address + spec = Gem::Specification.new "a", 1 + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + spec.content_address = "abcdef12" + assert Gem::ContentAddress.content_addressed?(spec) + end + + def test_content_addressed_with_eligible_spec_and_no_address + spec = Gem::Specification.new "a", 1 + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + refute Gem::ContentAddress.content_addressed?(spec) + end + + def test_content_addressed_with_eligible_spec_and_invalid_address + spec = Gem::Specification.new "a", 1 + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + spec.content_address = "x86_64-linux" + refute Gem::ContentAddress.content_addressed?(spec) + end + + def test_content_addressed_with_ineligible_spec_and_valid_address + spec = Gem::Specification.new "a", 1 + spec.content_address = "abcdef12" + refute Gem::ContentAddress.content_addressed?(spec) + end +end 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_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..97731be4e9bd 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| From 57fc2d41b94c1249a61aa9bf4bbde3bf80d862f0 Mon Sep 17 00:00:00 2001 From: Harriet Oughton Date: Sun, 30 Aug 2026 13:13:48 -0400 Subject: [PATCH 03/17] Support content addressable gems in gem install Co-authored-by: Gira Chawda Co-authored-by: Jenny Shen --- lib/bundler/source/rubygems.rb | 1 + lib/rubygems/basic_specification.rb | 18 +- lib/rubygems/content_address.rb | 16 + lib/rubygems/installer.rb | 20 ++ lib/rubygems/package.rb | 22 ++ lib/rubygems/remote_fetcher.rb | 3 +- lib/rubygems/resolver.rb | 11 +- lib/rubygems/resolver/api_set.rb | 6 +- lib/rubygems/resolver/api_specification.rb | 36 ++- lib/rubygems/resolver/index_specification.rb | 10 +- lib/rubygems/resolver/installer_set.rb | 2 +- lib/rubygems/resolver/spec_specification.rb | 4 + lib/rubygems/resolver/specification.rb | 10 +- lib/rubygems/source/local.rb | 3 +- lib/rubygems/specification.rb | 6 +- lib/rubygems/stub_specification.rb | 55 +++- test/rubygems/test_gem_content_address.rb | 36 +++ .../rubygems/test_gem_dependency_installer.rb | 26 +- test/rubygems/test_gem_installer.rb | 282 ++++++++++++++++++ test/rubygems/test_gem_remote_fetcher.rb | 66 ++++ test/rubygems/test_gem_resolver.rb | 131 ++++++++ test/rubygems/test_gem_resolver_api_set.rb | 23 +- .../test_gem_resolver_api_specification.rb | 104 ++++++- .../test_gem_resolver_index_specification.rb | 15 + .../test_gem_resolver_specification.rb | 47 +++ test/rubygems/test_gem_source_local.rb | 13 + test/rubygems/test_gem_specification.rb | 54 ++++ test/rubygems/test_gem_stub_specification.rb | 76 +++++ 28 files changed, 1039 insertions(+), 57 deletions(-) diff --git a/lib/bundler/source/rubygems.rb b/lib/bundler/source/rubygems.rb index 26e7b90426c4..b072f51b177d 100644 --- a/lib/bundler/source/rubygems.rb +++ b/lib/bundler/source/rubygems.rb @@ -617,6 +617,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/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/content_address.rb b/lib/rubygems/content_address.rb index bb48d10a5339..5ed8a86d313f 100644 --- a/lib/rubygems/content_address.rb +++ b/lib/rubygems/content_address.rb @@ -34,4 +34,20 @@ def self.content_addressed?(spec) def self.match?(value) value.is_a?(String) && PATTERN.match?(value) end + + ## + # Ranks +spec+ for candidate selection against +ruby_version+: a + # content-addressed spec built for that Ruby ranks first (0), any + # non-content-addressed spec next (1), and a content-addressed spec built + # for another Ruby last (2), since its binary cannot load there. + + def self.ruby_abi_specificity_match(spec, ruby_version = Gem.ruby_version) + return 1 unless match?(spec.content_address) + + if spec.required_ruby_version.satisfied_by?(ruby_version) + 0 + else + 2 + end + end end diff --git a/lib/rubygems/installer.rb b/lib/rubygems/installer.rb index 2599c0ff6ec8..7d41be94d95d 100644 --- a/lib/rubygems/installer.rb +++ b/lib/rubygems/installer.rb @@ -114,6 +114,12 @@ def extract_files(destination_dir, pattern = "*") def copy_to(path) end + + def gem + end + + def content_address + end end ## @@ -266,6 +272,7 @@ def spec # specifications/.gemspec #=> the Gem::Specification def install + assign_content_address pre_install_checks run_pre_install_hooks @@ -966,6 +973,19 @@ def ensure_writable_dir(dir) # :nodoc: private + 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/package.rb b/lib/rubygems/package.rb index cf61ca7a4637..ce4703023f46 100644 --- a/lib/rubygems/package.rb +++ b/lib/rubygems/package.rb @@ -254,6 +254,28 @@ def copy_to(path) FileUtils.cp @gem.path, path unless File.exist? path end + ## + # Derives the content address from the gem's file name and verifies it + # against the SHA256 digest of the file contents. + + def content_address + path = @gem&.path + return unless path + + return nil unless Gem::ContentAddress.applicable?(spec) + + filename = File.basename(path, ".gem") + base = "#{spec.name}-#{spec.version}" + suffix = filename.delete_prefix("#{base}-") + return nil if suffix == filename + return nil unless Gem::ContentAddress.match?(suffix) + + require "digest" + digest = Digest::SHA256.file(path).hexdigest + raise Gem::InstallError, "content address mismatch for #{File.basename(path)}" unless digest.start_with?(suffix) + suffix + end + ## # Adds a checksum for each entry in the gem to checksums.yaml.gz. 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/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..a37b7ef417d5 100644 --- a/lib/rubygems/resolver/api_specification.rb +++ b/lib/rubygems/resolver/api_specification.rb @@ -33,8 +33,7 @@ def initialize(set, api_data) @set = set @name = api_data[:name] @version = Gem::Version.new(api_data[:number]).freeze - @platform = Gem::Platform.new(api_data[:platform]).freeze - @original_platform = api_data[:platform].freeze + assign_platform(api_data) @dependencies = api_data[:dependencies].map do |name, ver| Gem::Dependency.new(name, ver.split(/\s*,\s*/)).freeze end.freeze @@ -48,15 +47,17 @@ def ==(other) # :nodoc: @set == other.set && @name == other.name && @version == other.version && - @platform == other.platform + @platform == other.platform && + @content_address == other.content_address end def hash - @set.hash ^ @name.hash ^ @version.hash ^ @platform.hash + @set.hash ^ @name.hash ^ @version.hash ^ @platform.hash ^ @content_address.hash end def fetch_development_dependencies # :nodoc: - spec = source.fetch_spec Gem::NameTuple.new @name, @version, @platform + suffix = @content_address || @platform + spec = source.fetch_spec Gem::NameTuple.new @name, @version, suffix @dependencies = spec.dependencies end @@ -101,6 +102,7 @@ def spec # :nodoc: s.original_platform = @original_platform s.required_ruby_version = @required_ruby_version s.required_rubygems_version = @required_rubygems_version + s.content_address = @content_address @dependencies.each do |dependency| s.add_runtime_dependency dependency.name, *dependency.requirement.as_list @@ -114,6 +116,30 @@ def source # :nodoc: private + def assign_platform(api_data) + suffix = api_data[:suffix] + required_platform = required_platform_from(api_data.dig(:requirements, :platform)) + + if Gem::ContentAddress.match?(suffix) && required_platform + @content_address = suffix.freeze + @platform = required_platform.freeze + @original_platform = required_platform.to_s.freeze + else + @content_address = nil + @platform = Gem::Platform.new(suffix).freeze + @original_platform = suffix.freeze + end + end + + def required_platform_from(requirement) + return unless requirement + + op, platform = requirement.last&.split(" ", 2) + return unless op == "=" && platform + + Gem::Platform.new(platform) + end + def parse_created_at(value) value = value.first if value.is_a?(Array) diff --git a/lib/rubygems/resolver/index_specification.rb b/lib/rubygems/resolver/index_specification.rb index 7b9560807148..20ef14e68291 100644 --- a/lib/rubygems/resolver/index_specification.rb +++ b/lib/rubygems/resolver/index_specification.rb @@ -15,7 +15,7 @@ class Gem::Resolver::IndexSpecification < Gem::Resolver::Specification # The +name+, +version+ and +platform+ are the name, version and platform of # the gem. - def initialize(set, name, version, source, platform) + def initialize(set, name, version, source, platform, content_address: nil) super() @set = set @@ -24,6 +24,7 @@ def initialize(set, name, version, source, platform) @source = source @platform = Gem::Platform.new(platform.to_s) @original_platform = platform.to_s + @content_address = content_address @spec = nil end @@ -60,11 +61,12 @@ def ==(other) self.class === other && @name == other.name && @version == other.version && - @platform == other.platform + @platform == other.platform && + @content_address == other.content_address end def hash - @name.hash ^ @version.hash ^ @platform.hash + [@name, @version, @platform, @content_address].hash end def inspect # :nodoc: @@ -93,7 +95,7 @@ def pretty_print(q) # :nodoc: def spec # :nodoc: @spec ||= begin - tuple = Gem::NameTuple.new @name, @version, @original_platform + tuple = Gem::NameTuple.new @name, @version, @original_platform, content_address: @content_address @source.fetch_spec tuple end diff --git a/lib/rubygems/resolver/installer_set.rb b/lib/rubygems/resolver/installer_set.rb index 42ce0890e2b6..e1113e595560 100644 --- a/lib/rubygems/resolver/installer_set.rb +++ b/lib/rubygems/resolver/installer_set.rb @@ -163,7 +163,7 @@ def find_all(req) @local_source.find_all_gems(name, dep.requirement).each do |local_spec| res << Gem::Resolver::IndexSpecification.new( self, local_spec.name, local_spec.version, - @local_source, local_spec.platform + @local_source, local_spec.platform, content_address: local_spec.content_address ) end rescue Gem::Package::FormatError diff --git a/lib/rubygems/resolver/spec_specification.rb b/lib/rubygems/resolver/spec_specification.rb index 00ef9fdba05b..f08d9773f8ea 100644 --- a/lib/rubygems/resolver/spec_specification.rb +++ b/lib/rubygems/resolver/spec_specification.rb @@ -23,6 +23,10 @@ def dependencies spec.dependencies end + def content_address # :nodoc: + spec.content_address + end + ## # The required_ruby_version constraint for this specification diff --git a/lib/rubygems/resolver/specification.rb b/lib/rubygems/resolver/specification.rb index 986fa7c9ae82..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/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/specification.rb b/lib/rubygems/specification.rb index f2c3d7c27f96..b92393928a83 100644 --- a/lib/rubygems/specification.rb +++ b/lib/rubygems/specification.rb @@ -2407,11 +2407,14 @@ def test_files # :nodoc: # still have their default values are omitted. def to_ruby + content_addressed = Gem::ContentAddress.content_addressed?(self) + gem_suffix = content_addressed ? content_address : platform result = [] result << "# -*- encoding: utf-8 -*-" - result << "#{Gem::StubSpecification::PREFIX}#{name} #{version} #{platform} #{raw_require_paths.join("\0")}" + result << "#{Gem::StubSpecification::PREFIX}#{name} #{version} #{gem_suffix} #{raw_require_paths.join("\0")}" result << "#{Gem::StubSpecification::PREFIX}#{extensions.join "\0"}" unless extensions.empty? + result << "#{Gem::StubSpecification::TARGET_PREFIX}platform=#{platform}" if content_addressed result << nil result << "Gem::Specification.new do |s|" @@ -2420,6 +2423,7 @@ def to_ruby unless platform.nil? || platform == Gem::Platform::RUBY result << " s.platform = #{ruby_code original_platform}" end + result << " s.content_address = #{ruby_code content_address} if s.respond_to? :content_address=" if content_addressed result << "" result << " s.required_rubygems_version = #{ruby_code required_rubygems_version} if s.respond_to? :required_rubygems_version=" diff --git a/lib/rubygems/stub_specification.rb b/lib/rubygems/stub_specification.rb index 53b337ed8554..825f1ec11d5e 100644 --- a/lib/rubygems/stub_specification.rb +++ b/lib/rubygems/stub_specification.rb @@ -9,14 +9,18 @@ class Gem::StubSpecification < Gem::BasicSpecification # :nodoc: PREFIX = "# stub: " + # :nodoc: + TARGET_PREFIX = "# stub-target: " + # :nodoc: OPEN_MODE = "r:UTF-8:-" class StubLine # :nodoc: all attr_reader :name, :version, :platform, :require_paths, :extensions, - :full_name + :full_name, :content_address NO_EXTENSIONS = [].freeze + NO_TARGET = {}.freeze # These are common require paths. REQUIRE_PATHS = { # :nodoc: @@ -33,7 +37,7 @@ class StubLine # :nodoc: all "lib" => ["lib"].freeze, }.freeze - def initialize(data, extensions) + def initialize(data, extensions, target = NO_TARGET) parts = data[PREFIX.length..-1].split(" ", 4) @name = -parts[0] @version = if Gem::Version.correct?(parts[1]) @@ -42,12 +46,17 @@ def initialize(data, extensions) Gem::Version.new(0) end - @platform = Gem::Platform.new parts[2] + suffix = parts[2] + target_platform = target["platform"] + @platform = Gem::Platform.new(target_platform || suffix) + @content_address = suffix if target_platform && Gem::ContentAddress.match?(suffix) @extensions = extensions - @full_name = if platform == Gem::Platform::RUBY + @full_name = if @content_address + "#{name}-#{version}-#{content_address}" + elsif platform == Gem::Platform::RUBY "#{name}-#{version}" else - "#{name}-#{version}-#{platform}" + "#{name}-#{version}-#{suffix}" end path_list = parts.last @@ -110,18 +119,27 @@ def data file.readline # discard encoding line stubline = file.readline if stubline.start_with?(PREFIX) - extline = file.readline - - extensions = - if extline.delete_prefix!(PREFIX) - extline.chomp! - extline.split "\0" - else - StubLine::NO_EXTENSIONS + line = file.readline + + if line.delete_prefix!(PREFIX) + line.chomp! + extensions = line.split "\0" + line = file.readline + else + extensions = StubLine::NO_EXTENSIONS + end + + target = StubLine::NO_TARGET + if line.delete_prefix!(TARGET_PREFIX) + line.chomp! + target = line.split(",").to_h do |pair| + key, value = pair.split("=", 2) + [key, value] end + end stubline.chomp! # readline(chomp: true) allocates 3x as much as .readline.chomp! - @data = StubLine.new stubline, extensions + @data = StubLine.new stubline, extensions, target end rescue EOFError end @@ -162,6 +180,10 @@ def platform data.platform end + def content_address # :nodoc: + data.content_address + end + ## # Extensions for this gem @@ -208,13 +230,14 @@ def ==(other) # :nodoc: self.class === other && name == other.name && version == other.version && - platform == other.platform + platform == other.platform && + content_address == other.content_address end alias_method :eql?, :== # :nodoc: def hash # :nodoc: - name.hash ^ version.hash ^ platform.hash + [name, version, platform, content_address].hash end def <=>(other) # :nodoc: diff --git a/test/rubygems/test_gem_content_address.rb b/test/rubygems/test_gem_content_address.rb index d89355049ce4..a935946304b8 100644 --- a/test/rubygems/test_gem_content_address.rb +++ b/test/rubygems/test_gem_content_address.rb @@ -80,4 +80,40 @@ def test_content_addressed_with_ineligible_spec_and_valid_address spec.content_address = "abcdef12" refute Gem::ContentAddress.content_addressed?(spec) end + + def test_ruby_abi_specificity_match_ranks_content_addressed_spec_for_that_ruby_first + spec = Gem::Specification.new "a", 1 + spec.platform = "x86_64-linux" + spec.required_ruby_version = "~> 3.4.0" + spec.content_address = "abcdef12" + + assert_equal 0, Gem::ContentAddress.ruby_abi_specificity_match(spec, Gem::Version.new("3.4.1")) + end + + def test_ruby_abi_specificity_match_ranks_non_content_addressed_spec_second + spec = Gem::Specification.new "a", 1 + spec.platform = "x86_64-linux" + spec.required_ruby_version = "~> 3.3.0" + + assert_equal 1, Gem::ContentAddress.ruby_abi_specificity_match(spec, Gem::Version.new("3.4.1")) + end + + def test_ruby_abi_specificity_match_ranks_content_addressed_spec_for_another_ruby_last + spec = Gem::Specification.new "a", 1 + spec.platform = "x86_64-linux" + spec.required_ruby_version = "~> 3.3.0" + spec.content_address = "abcdef12" + + assert_equal 2, Gem::ContentAddress.ruby_abi_specificity_match(spec, Gem::Version.new("3.4.1")) + end + + def test_ruby_abi_specificity_match_defaults_to_the_running_ruby + spec = Gem::Specification.new "a", 1 + spec.platform = "x86_64-linux" + spec.required_ruby_version = "~> 3.4.0" + spec.content_address = "abcdef12" + + assert_equal Gem::ContentAddress.ruby_abi_specificity_match(spec, Gem.ruby_version), + Gem::ContentAddress.ruby_abi_specificity_match(spec) + end end diff --git a/test/rubygems/test_gem_dependency_installer.rb b/test/rubygems/test_gem_dependency_installer.rb index 27fad9513536..a4b183fa075f 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,26 @@ 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(".") + _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) + end + def test_install_local_prerelease util_setup_gems @@ -507,7 +527,7 @@ def test_install_local_dependency_no_network_for_target_gem end def test_install_compact_index_api - a1, a1_gem = util_gem "a", 1, "b" => ">= 1" + a1, a1_gem = util_gem "a", 1, { "b" => ">= 1" } b1, b1_gem = util_gem "b", 1 util_setup_compact_index a1, b1 diff --git a/test/rubygems/test_gem_installer.rb b/test/rubygems/test_gem_installer.rb index 5bd5bf89f05d..3fa8bafcbce7 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,46 @@ def test_generate_plugins assert File.exist?(plugin_path), "plugin not written" 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_generate_plugins_with_install_dir spec = quick_gem "a" do |s| write_file File.join(@tempdir, "lib", "rubygems_plugin.rb") do |io| @@ -1030,6 +1071,247 @@ def test_install_dir_takes_precedence_to_user_install assert_path_not_exist File.join(Gem.user_dir, "gems", @spec.full_name) end + def test_install_assigns_content_address_from_filename + _, a_gem = util_gem("a", 2) do |spec| + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + end + + digest = Digest::SHA256.file(a_gem).hexdigest + address = digest[0, 8] + dir = File.dirname(a_gem) + filename = File.join(dir, "a-2-#{address}.gem") + FileUtils.cp a_gem, filename + installer = Gem::Installer.at filename, install_dir: @gemhome, force: true + # deliberately memoize the directory before installation, to prove the installation recalculates + installer.gem_dir + spec = installer.install + + assert_equal address, spec.content_address + assert_equal "a-2-#{address}", spec.full_name + assert_path_exist File.join(@gemhome, "gems", "a-2-#{address}") + assert_path_exist File.join(@gemhome, "cache", "a-2-#{address}.gem") + assert_path_exist File.join(@gemhome, "specifications", "a-2-#{address}.gemspec") + end + + def test_install_raises_for_mismatched_content_address + _, a_gem = util_gem("a", 2) do |spec| + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + end + dir = File.dirname(a_gem) + filename = File.join(dir, "a-2-deadbeef.gem") + FileUtils.cp a_gem, filename + installer = Gem::Installer.at filename, install_dir: @gemhome, force: true + + e = assert_raise Gem::InstallError do + installer.install + end + + assert_match(/content address mismatch/, e.message) + end + + def test_non_content_addressed_gems_install_as_expected + _, a_gem = util_gem "a", 2 + installer = Gem::Installer.at a_gem, install_dir: @gemhome, force: true + spec = installer.install + + assert_nil spec.content_address + assert_equal "a-2", spec.full_name + assert_path_exist File.join(@gemhome, "gems", "a-2") + assert_path_exist File.join(@gemhome, "specifications", "a-2.gemspec") + end + + def test_numeric_version_not_treated_as_content_address + _, a_gem = util_gem "a", "20240101" + installer = Gem::Installer.at a_gem, install_dir: @gemhome, force: true + spec = installer.install + + assert_nil spec.content_address + assert_equal "a-20240101", spec.full_name + assert_path_exist File.join(@gemhome, "gems", "a-20240101") + assert_path_exist File.join(@gemhome, "specifications", "a-20240101.gemspec") + end + + def test_normal_gem_with_hex_suffix_is_not_content_addressed + _, a_gem = util_gem "a", 2 do |spec| + spec.platform = Gem::Platform.new("x86-linux-deadbeef") + end + installer = Gem::Installer.at a_gem, install_dir: @gemhome, force: true + spec = installer.install + + assert_nil spec.content_address + assert_equal "a-2-x86-linux-deadbeef", spec.full_name + end + + def test_content_address_not_set_without_required_ruby_version_and_platform + _, a_gem = util_gem "a", 2 + digest = Digest::SHA256.file(a_gem).hexdigest + address = digest[0, 8] + dir = File.dirname(a_gem) + filename = File.join(dir, "a-2-#{address}.gem") + FileUtils.cp a_gem, filename + installer = Gem::Installer.at filename, install_dir: @gemhome, force: true + spec = installer.install + + assert_nil spec.content_address + assert_equal "a-2", spec.full_name + end + + def test_hex_suffix_without_matching_spec_prefix_is_not_content_addressed + _, a_gem = util_gem "a", 2 + + digest = Digest::SHA256.file(a_gem).hexdigest + address = digest[0, 8] + dir = File.dirname(a_gem) + filename = File.join(dir, "wrong_name-2-#{address}.gem") + FileUtils.cp a_gem, filename + installer = Gem::Installer.at filename, install_dir: @gemhome, force: true + spec = installer.install + + assert_nil spec.content_address + assert_equal "a-2", spec.full_name + end + + def test_content_address_not_set_with_only_required_ruby_version + _, a_gem = util_gem("a", 2) do |spec| + spec.required_ruby_version = ">= 3.0" + end + digest = Digest::SHA256.file(a_gem).hexdigest + address = digest[0, 8] + dir = File.dirname(a_gem) + filename = File.join(dir, "a-2-#{address}.gem") + FileUtils.cp a_gem, filename + installer = Gem::Installer.at filename, install_dir: @gemhome, force: true + spec = installer.install + + assert_nil spec.content_address + end + + def test_content_address_not_set_with_only_platform + _, a_gem = util_gem("a", 2) do |spec| + spec.platform = "x86_64-linux" + end + digest = Digest::SHA256.file(a_gem).hexdigest + address = digest[0, 8] + dir = File.dirname(a_gem) + filename = File.join(dir, "a-2-#{address}.gem") + FileUtils.cp a_gem, filename + installer = Gem::Installer.at filename, install_dir: @gemhome, force: true + spec = installer.install + + assert_nil spec.content_address + end + + def test_require_works_after_content_addressed_install + source_spec, a_gem = util_gem("a", 2) do |spec| + spec.files = ["lib/ca_activation_test.rb"] + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + end + FileUtils.rm_rf source_spec.gem_dir + + digest = Digest::SHA256.file(a_gem).hexdigest + address = digest[0, 8] + dir = File.dirname(a_gem) + filename = File.join(dir, "a-2-#{address}.gem") + FileUtils.cp a_gem, filename + + installer = Gem::Installer.at filename, install_dir: @gemhome, force: true + installer.install + + Gem::Specification.reset + + assert require "ca_activation_test" + end + + def test_reinstalling_content_addressed_gem_is_idempotent + source_spec, a_gem = util_gem("a", 2) do |spec| + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + end + FileUtils.rm_rf source_spec.gem_dir + + digest = Digest::SHA256.file(a_gem).hexdigest + address = digest[0, 8] + dir = File.dirname(a_gem) + filename = File.join(dir, "a-2-#{address}.gem") + FileUtils.cp a_gem, filename + installer = Gem::Installer.at filename, install_dir: @gemhome, force: true + installer.install + + installer2 = Gem::Installer.at filename, install_dir: @gemhome, force: true + spec2 = installer2.install + + assert_equal "a-2-#{address}", spec2.full_name + assert_path_exist File.join(@gemhome, "gems", "a-2-#{address}") + assert_path_exist File.join(@gemhome, "specifications", "a-2-#{address}.gemspec") + assert_equal 1, Dir[File.join(@gemhome, "gems", "a-2*")].size + assert_equal 1, Dir[File.join(@gemhome, "specifications", "a-2*.gemspec")].size + end + + def test_install_assigns_content_address_from_filename_with_full_sha + _, a_gem = util_gem("a", 2) do |spec| + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + end + + digest = Digest::SHA256.file(a_gem).hexdigest + dir = File.dirname(a_gem) + filename = File.join(dir, "a-2-#{digest}.gem") + FileUtils.cp a_gem, filename + installer = Gem::Installer.at filename, install_dir: @gemhome, force: true + spec = installer.install + + assert_equal digest, spec.content_address + assert_equal "a-2-#{digest}", spec.full_name + assert_path_exist File.join(@gemhome, "gems", "a-2-#{digest}") + assert_path_exist File.join(@gemhome, "cache", "a-2-#{digest}.gem") + assert_path_exist File.join(@gemhome, "specifications", "a-2-#{digest}.gemspec") + end + + def test_two_content_addressed_gems_with_same_name_version_coexist + _, gem1 = util_gem("a", 2) do |spec| + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + spec.summary = "variant 1" + end + gem1_backup = File.join(@tempdir, "gem1_backup.gem") + FileUtils.cp gem1, gem1_backup + + _, gem2 = util_gem("a", 2) do |spec| + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + spec.summary = "variant 2" + end + + FileUtils.rm_rf File.join(@gemhome, "gems", "a-2-x86_64-linux") + FileUtils.rm_rf File.join(@gemhome, "specifications", "a-2-x86_64-linux.gemspec") + Gem::Specification.reset + + dir = File.dirname(gem1) + digest1 = Digest::SHA256.file(gem1_backup).hexdigest + digest2 = Digest::SHA256.file(gem2).hexdigest + refute_equal digest1, digest2 + + address1 = digest1[0, 8] + address2 = digest2[0, 8] + file1 = File.join(dir, "a-2-#{address1}.gem") + file2 = File.join(dir, "a-2-#{address2}.gem") + FileUtils.cp gem1_backup, file1 + FileUtils.cp gem2, file2 + + Gem::Installer.at(file1, install_dir: @gemhome, force: true).install + Gem::Installer.at(file2, install_dir: @gemhome, force: true).install + + assert_path_exist File.join(@gemhome, "gems", "a-2-#{address1}") + assert_path_exist File.join(@gemhome, "gems", "a-2-#{address2}") + assert_path_exist File.join(@gemhome, "specifications", "a-2-#{address1}.gemspec") + assert_path_exist File.join(@gemhome, "specifications", "a-2-#{address2}.gemspec") + assert_equal 2, Dir[File.join(@gemhome, "gems", "a-2-*")].size + assert_equal 2, Dir[File.join(@gemhome, "specifications", "a-2-*.gemspec")].size + end + def test_install installer = util_setup_installer diff --git a/test/rubygems/test_gem_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..d3fe3917ed0e 100644 --- a/test/rubygems/test_gem_resolver_api_specification.rb +++ b/test/rubygems/test_gem_resolver_api_specification.rb @@ -8,7 +8,7 @@ def test_initialize data = { name: "rails", number: "3.0.3", - platform: Gem::Platform.local.to_s, + suffix: Gem::Platform.local.to_s, dependencies: [ ["bundler", "~> 1.0"], ["railties", "= 3.0.3"], @@ -30,12 +30,64 @@ def test_initialize assert_nil spec.created_at end + def test_initialize_content_address + set = Gem::Resolver::APISet.new + data = { + name: "rails", + number: "3.0.3", + suffix: "abc1234567", + dependencies: [], + requirements: { platform: ["= #{Gem::Platform.local}"] }, + } + + spec = Gem::Resolver::APISpecification.new set, data + + assert_equal "abc1234567", spec.content_address + assert_equal Gem::Platform.local, spec.platform + assert Gem::ContentAddress.match?(spec.content_address) + assert_equal "abc1234567", spec.spec.content_address + end + + def test_initialize_does_not_treat_non_content_address_suffix_as_content_addressed + set = Gem::Resolver::APISet.new + data = { + name: "rails", + number: "3.0.3", + suffix: Gem::Platform.local.to_s, + dependencies: [], + requirements: { platform: ["= #{Gem::Platform.local}"] }, + } + + spec = Gem::Resolver::APISpecification.new set, data + + assert_nil spec.content_address + refute Gem::ContentAddress.match?(spec.content_address) + assert_equal Gem::Platform.local, spec.platform + end + + def test_content_addressed_specs_with_different_addresses_are_distinct + set = Gem::Resolver::APISet.new + data = { + name: "rails", + number: "3.0.3", + suffix: "abc1234567", + dependencies: [], + requirements: { platform: ["= #{Gem::Platform.local}"] }, + } + + first = Gem::Resolver::APISpecification.new set, data + second = Gem::Resolver::APISpecification.new set, data.merge(suffix: "def1234567") + + refute_equal first, second + refute_equal first.hash, second.hash + end + def test_initialize_created_at set = Gem::Resolver::APISet.new data = { name: "rails", number: "3.0.3", - platform: "ruby", + suffix: "ruby", dependencies: [], requirements: { created_at: ["2026-06-05T10:30:45Z"] }, } @@ -50,7 +102,7 @@ def test_initialize_created_at_invalid data = { name: "rails", number: "3.0.3", - platform: "ruby", + suffix: "ruby", dependencies: [], requirements: { created_at: ["not a timestamp"] }, } @@ -65,7 +117,7 @@ def test_initialize_created_at_non_iso8601 data = { name: "rails", number: "3.0.3", - platform: "ruby", + suffix: "ruby", dependencies: [], requirements: { created_at: ["2026"] }, } @@ -133,7 +185,7 @@ def test_fetch_development_dependencies data = { name: "rails", number: "3.0.3", - platform: "ruby", + suffix: "ruby", dependencies: [ ["bundler", "~> 1.0"], ["railties", "= 3.0.3"], @@ -155,12 +207,42 @@ def test_fetch_development_dependencies assert_equal expected, spec.dependencies end + def test_fetch_development_dependencies_for_content_addressed_spec + fetched_tuple = nil + fetched_spec = util_spec "rails", "3.0.3" do |s| + s.add_development_dependency "a", "= 1" + end + + source = Object.new + source.define_singleton_method(:fetch_spec) do |tuple| + fetched_tuple = tuple + fetched_spec + end + + set = Gem::Resolver::APISet.new + set.instance_variable_set :@source, source + data = { + name: "rails", + number: "3.0.3", + suffix: "abc1234567", + dependencies: [], + requirements: { platform: ["= #{Gem::Platform.local}"] }, + } + + spec = Gem::Resolver::APISpecification.new set, data + + spec.fetch_development_dependencies + + assert_equal "rails-3.0.3-abc1234567.gemspec", fetched_tuple.spec_name + assert_equal [Gem::Dependency.new("a", "= 1", :development)], spec.dependencies + end + def test_installable_platform_eh set = Gem::Resolver::APISet.new data = { name: "a", number: "1", - platform: "ruby", + suffix: "ruby", dependencies: [], } @@ -171,7 +253,7 @@ def test_installable_platform_eh data = { name: "b", number: "1", - platform: "cpu-other_platform-1", + suffix: "cpu-other_platform-1", dependencies: [], } @@ -182,7 +264,7 @@ def test_installable_platform_eh data = { name: "c", number: "1", - platform: Gem::Platform.local.to_s, + suffix: Gem::Platform.local.to_s, dependencies: [], } @@ -196,7 +278,7 @@ def test_source data = { name: "a", number: "1", - platform: "ruby", + suffix: "ruby", dependencies: [], } @@ -215,7 +297,7 @@ def test_spec data = { name: "a", number: "1", - platform: "ruby", + suffix: "ruby", dependencies: [], } @@ -239,7 +321,7 @@ def test_spec_jruby_platform data = { name: "j", number: "1", - platform: "jruby", + suffix: "jruby", dependencies: [], } diff --git a/test/rubygems/test_gem_resolver_index_specification.rb b/test/rubygems/test_gem_resolver_index_specification.rb index ed9475f0cf81..3f09844f8f92 100644 --- a/test/rubygems/test_gem_resolver_index_specification.rb +++ b/test/rubygems/test_gem_resolver_index_specification.rb @@ -32,6 +32,21 @@ def test_initialize_platform assert_equal Gem::Platform.local, spec.platform end + def test_content_addressed_specs_with_different_addresses_are_distinct + set = Gem::Resolver::IndexSet.new + source = Gem::Source::Local.new + version = Gem::Version.new "3.0.3" + first = Gem::Resolver::IndexSpecification.new( + set, "rails", version, source, Gem::Platform.local, content_address: "abc1234567" + ) + second = Gem::Resolver::IndexSpecification.new( + set, "rails", version, source, Gem::Platform.local, content_address: "def1234567" + ) + + refute_equal first, second + refute_equal first.hash, second.hash + end + def test_install spec_fetcher do |fetcher| fetcher.gem "a", 2 diff --git a/test/rubygems/test_gem_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_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_specification.rb b/test/rubygems/test_gem_specification.rb index ab37ada378fa..9757885f251e 100644 --- a/test/rubygems/test_gem_specification.rb +++ b/test/rubygems/test_gem_specification.rb @@ -1943,6 +1943,11 @@ def test_ruby_abi_returns_nil_for_non_pessimistic_operator assert_nil spec.ruby_abi end + def test_ruby_abi_returns_nil_for_default_required_ruby_version + spec = Gem::Specification.new + assert_nil spec.ruby_abi + end + def test_full_name assert_equal "a-1", @a1.full_name @@ -1961,6 +1966,15 @@ def test_full_name assert_equal "a-1-x86-darwin-8", @a1.full_name end + def test_content_addressable_full_name + @a1 = Gem::Specification.new "a", 1 + @a1.required_ruby_version = ">= 3.0" + @a1.platform = "x86_64-linux" + @a1.content_address = "abcdef12" + assert_equal "a-1-abcdef12", @a1.full_name + assert_equal "x86_64-linux", @a1.platform.to_s + end + def test_full_name_windows test_cases = { "i386-mswin32" => "a-1-x86-mswin32-60", @@ -1987,6 +2001,21 @@ def test_hash refute_equal @a1.hash, @a2.hash end + def test_content_addressable_specs_are_distinct + first = Gem::Specification.new "a", 1 + first.required_ruby_version = ">= 3.0" + first.platform = "arm64-darwin" + first.content_address = "abcdef12" + + second = Gem::Specification.new "a", 1 + second.required_ruby_version = ">= 3.0" + second.platform = "arm64-darwin" + second.content_address = "12345678" + + refute_equal first, second + assert_equal 2, [first, second].uniq.size + end + def test_installed_by_version assert_equal v(0), @a1.installed_by_version @@ -2380,6 +2409,31 @@ def test_to_ruby assert_equal @a2, same_spec end + def test_to_ruby_content_addressable + spec = Gem::Specification.new "a", 1 + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + spec.content_address = "abcdef12" + spec.extensions = ["ext/a/extconf.rb"] + + ruby_code = spec.to_ruby + + expected_stub = <<~STUB.chomp + # stub: a 1 abcdef12 lib + # stub: ext/a/extconf.rb + # stub-target: platform=x86_64-linux + STUB + + assert_includes ruby_code, expected_stub + assert_includes ruby_code, "if s.respond_to? :content_address=" + + same_spec = eval ruby_code + + assert_equal "abcdef12", same_spec.content_address + assert_equal "x86_64-linux", same_spec.platform.to_s + assert_equal "a-1-abcdef12", same_spec.full_name + end + def test_to_ruby_with_rsa_key require "rubygems/openssl" pend "openssl is missing" unless defined?(OpenSSL::PKey::RSA) diff --git a/test/rubygems/test_gem_stub_specification.rb b/test/rubygems/test_gem_stub_specification.rb index 744ffa7d059e..1aa3b6532436 100644 --- a/test/rubygems/test_gem_stub_specification.rb +++ b/test/rubygems/test_gem_stub_specification.rb @@ -23,6 +23,34 @@ def test_initialize assert @foo.stubbed? end + def test_initialize_with_target + stub = stub_with_target + + assert_equal "stub_with_target", stub.name + assert_equal v(2), stub.version + assert_equal Gem::Platform.new("x86_64-linux"), stub.platform + assert_equal [stub.extension_dir, "lib"], stub.require_paths + assert_equal %w[ext/stub_with_target/extconf.rb], stub.extensions + assert_equal "ab12345678", stub.content_address + assert_equal "stub_with_target-2-ab12345678", stub.full_name + end + + def test_initialize_hex_suffix_without_target_is_not_content_addressable + stub = stub_without_target + + assert_nil stub.content_address + assert_equal Gem::Platform.new("ab12345678"), stub.platform + assert_equal "stub_without_target-2-ab12345678", stub.full_name + end + + def test_content_addressable_stubs_are_distinct + first = stub_with_target "ab12345678" + second = stub_with_target "cd12345678" + + refute_equal first, second + assert_equal 2, [first, second].uniq.size + end + def test_initialize_extension stub = stub_with_extension @@ -291,6 +319,54 @@ def stub_without_version end end + def stub_with_target(content_address = "ab12345678") + spec = File.join @gemhome, "specifications", "stub_with_target-#{content_address}.gemspec" + File.open spec, "w" do |io| + io.write <<~STUB + # -*- encoding: utf-8 -*- + # stub: stub_with_target 2 #{content_address} lib + # stub: ext/stub_with_target/extconf.rb + # stub-target: platform=x86_64-linux + + Gem::Specification.new do |s| + s.name = 'stub_with_target' + s.version = Gem::Version.new '2' + end + STUB + + io.flush + + stub = Gem::StubSpecification.gemspec_stub io.path, @gemhome, File.join(@gemhome, "gems") + + yield stub if block_given? + + return stub + end + end + + def stub_without_target(suffix = "ab12345678") + spec = File.join @gemhome, "specifications", "stub_without_target-#{suffix}.gemspec" + File.open spec, "w" do |io| + io.write <<~STUB + # -*- encoding: utf-8 -*- + # stub: stub_without_target 2 #{suffix} lib + + Gem::Specification.new do |s| + s.name = 'stub_without_target' + s.version = Gem::Version.new '2' + end + STUB + + io.flush + + stub = Gem::StubSpecification.gemspec_stub io.path, @gemhome, File.join(@gemhome, "gems") + + yield stub if block_given? + + return stub + end + end + def stub_with_extension spec = File.join @gemhome, "specifications", "stub_e-2.gemspec" File.open spec, "w" do |io| From 4bd4695e9e7977c1a46ce31cdba921357f60fff3 Mon Sep 17 00:00:00 2001 From: Gira Chawda Date: Fri, 28 Aug 2026 00:27:43 -0400 Subject: [PATCH 04/17] Support content addressable gems in gem push Co-authored-by: Jenny Shen --- lib/rubygems/commands/push_command.rb | 61 ++- test/rubygems/helper.rb | 23 +- .../test_gem_commands_push_command.rb | 370 ++++++++++++++++++ 3 files changed, 443 insertions(+), 11 deletions(-) diff --git a/lib/rubygems/commands/push_command.rb b/lib/rubygems/commands/push_command.rb index ba2cc420c858..b4dbf5881ee8 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.applicable?(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/test/rubygems/helper.rb b/test/rubygems/helper.rb index 1d7fddb4230c..8389dbc70f62 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 @@ -1028,11 +1030,12 @@ def util_ca_spec(name, version, content_address, ruby_abi: nil, platform: "x86_6 ## # 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| @@ -1043,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 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") From 38291d08bad9cebd5fef367185c388e70e324594 Mon Sep 17 00:00:00 2001 From: Gira Chawda Date: Fri, 28 Aug 2026 00:27:43 -0400 Subject: [PATCH 05/17] Support content addressable gems in gem yank Co-authored-by: Jenny Shen --- lib/rubygems/commands/yank_command.rb | 17 ++-- .../test_gem_commands_yank_command.rb | 77 ++++++++++++++++++- 2 files changed, 85 insertions(+), 9 deletions(-) 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/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" From 9c4afde89d0813c9c510e1842f7129122b5346d1 Mon Sep 17 00:00:00 2001 From: Harriet Oughton Date: Fri, 28 Aug 2026 00:27:47 -0400 Subject: [PATCH 06/17] Support content addressable gems in bundle install, lockfile, and local cache Co-authored-by: Jenny Shen --- lib/bundler/endpoint_specification.rb | 27 +- lib/bundler/fetcher.rb | 6 +- lib/bundler/lazy_specification.rb | 21 +- lib/bundler/lockfile_parser.rb | 16 +- lib/bundler/match_platform.rb | 25 +- lib/bundler/remote_specification.rb | 9 +- lib/bundler/resolver.rb | 2 +- lib/bundler/rubygems_ext.rb | 41 +- lib/bundler/rubygems_gem_installer.rb | 7 + lib/bundler/rubygems_integration.rb | 7 +- lib/bundler/source/rubygems.rb | 2 + lib/bundler/stub_specification.rb | 3 +- lib/rubygems/source.rb | 39 +- lib/rubygems/specification.rb | 2 +- spec/bundler/endpoint_specification_spec.rb | 62 ++- spec/bundler/lockfile_parser_spec.rb | 59 +++ spec/bundler/override_spec.rb | 1 + spec/bundler/remote_specification_spec.rb | 29 +- spec/install/cooldown_spec.rb | 90 ++-- .../gemfile/content_addressable_spec.rb | 494 ++++++++++++++++++ spec/other/ext_spec.rb | 15 + .../artifice/compact_index_cooldown.rb | 6 - spec/support/artifice/compact_index_v2.rb | 6 + .../support/artifice/helpers/compact_index.rb | 14 +- .../helpers/compact_index_cooldown.rb | 13 - .../artifice/helpers/compact_index_v2.rb | 52 ++ spec/support/builders.rb | 21 +- spec/support/checksums.rb | 8 +- spec/support/rubygems_ext.rb | 2 +- .../rubygems/test_gem_dependency_installer.rb | 3 + 30 files changed, 933 insertions(+), 149 deletions(-) create mode 100644 spec/install/gemfile/content_addressable_spec.rb delete mode 100644 spec/support/artifice/compact_index_cooldown.rb create mode 100644 spec/support/artifice/compact_index_v2.rb delete mode 100644 spec/support/artifice/helpers/compact_index_cooldown.rb create mode 100644 spec/support/artifice/helpers/compact_index_v2.rb diff --git a/lib/bundler/endpoint_specification.rb b/lib/bundler/endpoint_specification.rb index 9be06aac47c0..f18b63220f02 100644 --- a/lib/bundler/endpoint_specification.rb +++ b/lib/bundler/endpoint_specification.rb @@ -5,24 +5,33 @@ module Bundler class EndpointSpecification < Gem::Specification include MatchRemoteMetadata - attr_reader :name, :version, :platform, :checksum, :created_at + attr_reader :name, :version, :platform, :checksum, :created_at, :content_address attr_writer :dependencies attr_accessor :remote, :locked_platform - def initialize(name, version, platform, spec_fetcher, dependencies, metadata = nil) + def initialize(name, version, suffix, spec_fetcher, dependencies, metadata = nil) super() @name = name @version = Gem::Version.create version - @platform = Gem::Platform.new(platform) @spec_fetcher = spec_fetcher @dependencies = nil @unbuilt_dependencies = dependencies + @content_address = nil + @required_platform = nil @loaded_from = nil @remote_specification = nil @locked_platform = nil parse_metadata(metadata) + + if Gem::ContentAddress.match?(suffix) && @required_platform + @content_address = suffix + @platform = @required_platform + @required_rubygems_version ||= Gem::Requirement.default + else + @platform = Gem::Platform.new(suffix) + end end def insecurely_materialized? @@ -147,7 +156,8 @@ def inspect private def _remote_specification - @_remote_specification ||= @spec_fetcher.fetch_spec([@name, @version, @platform]) + suffix = @content_address || @platform + @_remote_specification ||= @spec_fetcher.fetch_spec([@name, @version, suffix]) end def local_specification_path @@ -183,6 +193,8 @@ def parse_metadata(data) @required_ruby_version = Gem::Requirement.new(v) when "created_at" @created_at = parse_created_at(v.is_a?(Array) ? v.last : v)&.freeze + when "platform" + @required_platform = required_platform_from(Array(v).last) end end rescue StandardError => e @@ -215,5 +227,12 @@ def parse_created_at(value) def build_dependency(name, requirements) Dependency.new(name, requirements) end + + def required_platform_from(value) + op, platform = value.to_s.split(" ", 2) + return unless op == "=" && platform + + Gem::Platform.new(platform) + end end end diff --git a/lib/bundler/fetcher.rb b/lib/bundler/fetcher.rb index ecaca9242056..3d77f8750b8d 100644 --- a/lib/bundler/fetcher.rb +++ b/lib/bundler/fetcher.rb @@ -177,13 +177,13 @@ def specs_with_retry(gem_names, source) def specs(gem_names, source) index = Bundler::Index.new - fetch_specs(gem_names).each do |name, version, platform, dependencies, metadata| + fetch_specs(gem_names).each do |name, version, suffix, dependencies, metadata| spec = if dependencies - EndpointSpecification.new(name, version, platform, self, dependencies, metadata).tap do |es| + EndpointSpecification.new(name, version, suffix, self, dependencies, metadata).tap do |es| source.checksum_store.replace(es, es.checksum) end else - RemoteSpecification.new(name, version, platform, self) + RemoteSpecification.new(name, version, suffix, self) end spec.source = source spec.remote = @remote diff --git a/lib/bundler/lazy_specification.rb b/lib/bundler/lazy_specification.rb index 0a6c46299752..cc62608252ea 100644 --- a/lib/bundler/lazy_specification.rb +++ b/lib/bundler/lazy_specification.rb @@ -8,7 +8,7 @@ class LazySpecification include MatchPlatform include ForcePlatform - attr_reader :name, :version, :platform, :materialization + attr_reader :name, :version, :platform, :materialization, :content_address attr_accessor :source, :remote, :force_ruby_platform, :dependencies, :required_ruby_version, :required_rubygems_version attr_accessor :overrides @@ -27,7 +27,7 @@ class LazySpecification alias_method :runtime_dependencies, :dependencies def self.from_spec(s) - lazy_spec = new(s.name, s.version, s.platform, s.source) + lazy_spec = new(s.name, s.version, s.platform, s.source, content_address: s.content_address) lazy_spec.dependencies = s.runtime_dependencies lazy_spec.required_ruby_version = s.required_ruby_version lazy_spec.required_rubygems_version = s.required_rubygems_version @@ -35,13 +35,14 @@ def self.from_spec(s) lazy_spec end - def initialize(name, version, platform, source = nil, **materialization_options) + def initialize(name, version, platform, source = nil, content_address: nil, **materialization_options) @name = name @version = version @dependencies = [] @required_ruby_version = Gem::Requirement.default @required_rubygems_version = Gem::Requirement.default @platform = platform || Gem::Platform::RUBY + @content_address = content_address @original_source = source @source = source @@ -65,7 +66,9 @@ def source_changed? end def full_name - @full_name ||= if platform == Gem::Platform::RUBY + @full_name ||= if Gem::ContentAddress.match?(@content_address) && platform != Gem::Platform::RUBY + "#{@name}-#{@version}-#{@content_address}" + elsif platform == Gem::Platform::RUBY "#{@name}-#{@version}" else "#{@name}-#{@version}-#{platform}" @@ -81,7 +84,7 @@ def lock_name end def name_tuple - Gem::NameTuple.new(@name, @version, @platform) + Gem::NameTuple.new(@name, @version, @platform, content_address: @content_address) end def ==(other) @@ -118,7 +121,11 @@ def satisfies?(dependency) def to_lock out = String.new - out << " #{lock_name}\n" + out << " #{lock_name}" + # Append the platform additionally for content-addressable gems that contain a SHA + # where the platform would otherwise be + out << " #{platform}" if Gem::ContentAddress.match?(content_address) && platform != Gem::Platform::RUBY + out << "\n" dependencies.sort_by(&:to_s).uniq.each do |dep| next if dep.type == :development @@ -192,6 +199,8 @@ def use_exact_resolved_specifications? # Used for legacy lockfiles and as a fallback when the exact locked spec # is incompatible. Falls back to frozen bundle behavior if none match. def resolve_best_platform(specs, locked_platforms: nil) + specs = MatchPlatform.select_all_content_address_match(specs, content_address) + find_compatible_platform_spec(specs, locked_platforms: locked_platforms) || frozen_bundle_fallback(specs) end diff --git a/lib/bundler/lockfile_parser.rb b/lib/bundler/lockfile_parser.rb index 852fc631f3b1..160d5583d67f 100644 --- a/lib/bundler/lockfile_parser.rb +++ b/lib/bundler/lockfile_parser.rb @@ -264,11 +264,13 @@ def parse_checksum(line) checksums = $6 name = $2 version = $3 - platform = $4 + content_address = $4 if Gem::ContentAddress.match?($4) + platform = $4 unless content_address version = Gem::Version.new(version) platform = platform ? Gem::Platform.new(platform) : Gem::Platform::RUBY - full_name = Gem::NameTuple.new(name, version, platform).full_name + name_tuple = Gem::NameTuple.new(name, version, platform, content_address: content_address) + full_name = name_tuple.full_name spec = @specs[full_name] if name == "bundler" @@ -295,11 +297,17 @@ def parse_spec(line) if spaces.size == 4 # only load platform for non-dependency (spec) line - platform = $4 + if Gem::ContentAddress.match?($4) && $6 && $6 != Gem::Platform::RUBY.to_s + content_address = $4 + platform = $6 + else + platform = $4 + content_address = $6 if Gem::ContentAddress.match?($6) + end version = Gem::Version.new(version) platform = platform ? Gem::Platform.new(platform) : Gem::Platform::RUBY - @current_spec = LazySpecification.new(name, version, platform, @current_source, strict: @strict) + @current_spec = LazySpecification.new(name, version, platform, @current_source, content_address: content_address, strict: @strict) @current_source.add_dependency_names(name) @specs[@current_spec.full_name] = @current_spec diff --git a/lib/bundler/match_platform.rb b/lib/bundler/match_platform.rb index 11d510ba6c47..c07253bfafd0 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.match?(s.content_address) } + 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..bf899693f536 100644 --- a/lib/bundler/remote_specification.rb +++ b/lib/bundler/remote_specification.rb @@ -10,11 +10,11 @@ class RemoteSpecification include MatchPlatform include Comparable - attr_reader :name, :version, :platform + attr_reader :name, :version, :platform, :content_address attr_writer :dependencies attr_accessor :source, :remote, :locked_platform, :created_at - def initialize(name, version, platform, spec_fetcher) + def initialize(name, version, platform, spec_fetcher, content_address: nil) @name = name @version = Gem::Version.create version @original_platform = platform || Gem::Platform::RUBY @@ -22,6 +22,7 @@ def initialize(name, version, platform, spec_fetcher) @spec_fetcher = spec_fetcher @dependencies = nil @locked_platform = nil + @content_address = content_address end def insecurely_materialized? @@ -35,7 +36,9 @@ def fetch_platform end def full_name - @full_name ||= if @platform == Gem::Platform::RUBY + @full_name ||= if Gem::ContentAddress.match?(@content_address) && @platform != Gem::Platform::RUBY + "#{@name}-#{@version}-#{@content_address}" + elsif @platform == Gem::Platform::RUBY "#{@name}-#{@version}" else "#{@name}-#{@version}-#{@platform}" diff --git a/lib/bundler/resolver.rb b/lib/bundler/resolver.rb index a164a4193541..72c6a32afb34 100644 --- a/lib/bundler/resolver.rb +++ b/lib/bundler/resolver.rb @@ -275,7 +275,7 @@ def incompatibilities_for(package, version) def all_versions_for(package) name = package.name - results = (@base[name] + filter_specs(@all_specs[name], package)).uniq {|spec| [spec.version.hash, spec.platform] } + results = (@base[name] + filter_specs(@all_specs[name], package)).uniq {|spec| [spec.version.hash, spec.platform, spec.content_address] } if name == "bundler" && !bundler_pinned_to_current_version? bundler_spec = Gem.loaded_specs["bundler"] diff --git a/lib/bundler/rubygems_ext.rb b/lib/bundler/rubygems_ext.rb index 4ad2bdf46f04..623de1836e77 100644 --- a/lib/bundler/rubygems_ext.rb +++ b/lib/bundler/rubygems_ext.rb @@ -13,7 +13,34 @@ # `Gem::Source` from the redefined `Gem::Specification#source`. require "rubygems/source" +# Can be removed once RubyGems 4.0.0 support is dropped +unless Gem::BasicSpecification.method_defined?(:content_address) + Gem::BasicSpecification.attr_accessor :content_address +end + +# Can be removed once RubyGems 4.0.0 support is dropped +unless Gem::NameTuple.method_defined?(:content_address) + Gem::NameTuple.attr_reader :content_address +end + module Gem + # Can be removed once RubyGems 4.0.0 support is dropped + unless defined?(Gem::ContentAddress) + module ContentAddress + def self.match?(token) + false + end + + def self.applicable?(spec) + false + end + + def self.content_addressed?(spec) + false + end + end + end + # Can be removed once RubyGems 3.5.11 support is dropped unless Gem.respond_to?(:freebsd_platform?) def self.freebsd_platform? @@ -417,7 +444,8 @@ class NameTuple unless Gem::NameTuple.new("a", Gem::Version.new("1"), Gem::Platform.new("x86_64-linux")).platform.is_a?(String) alias_method :initialize_with_platform, :initialize - def initialize(name, version, platform = Gem::Platform::RUBY) + def initialize(name, version, platform = Gem::Platform::RUBY, content_address = nil) + @content_address = content_address if Gem::Platform === platform initialize_with_platform(name, version, platform.to_s) else @@ -426,7 +454,18 @@ def initialize(name, version, platform = Gem::Platform::RUBY) end end + unless instance_method(:initialize).parameters.any? {|kind, name| kind == :key && name == :content_address } + alias_method :initialize_without_content_address, :initialize + + def initialize(name, version, platform = Gem::Platform::RUBY, content_address: nil) + initialize_without_content_address(name, version, platform) + @content_address = content_address + end + end + def lock_name + return "#{name} (#{version}-#{content_address})" if Gem::ContentAddress.match?(content_address) + if platform == Gem::Platform::RUBY "#{name} (#{version})" else diff --git a/lib/bundler/rubygems_gem_installer.rb b/lib/bundler/rubygems_gem_installer.rb index d8c50556c531..e33f135c59e4 100644 --- a/lib/bundler/rubygems_gem_installer.rb +++ b/lib/bundler/rubygems_gem_installer.rb @@ -4,6 +4,11 @@ module Bundler class RubyGemsGemInstaller < Gem::Installer + # Can be removed once RubyGems 4.0.0 support is dropped + unless private_method_defined?(:assign_content_address) + private def assign_content_address; end + end + # Cap how many jobserver slots a single gem's `make` may grab so that one # gem with many recipes doesn't starve the others sharing the pool. Beyond # a handful of jobs the extra parallelism rarely pays off in practice. @@ -14,6 +19,8 @@ def check_executable_overwrite(filename) end def install + assign_content_address + pre_install_checks run_pre_install_hooks diff --git a/lib/bundler/rubygems_integration.rb b/lib/bundler/rubygems_integration.rb index e04ef232592a..06d17d0cacdc 100644 --- a/lib/bundler/rubygems_integration.rb +++ b/lib/bundler/rubygems_integration.rb @@ -144,7 +144,12 @@ def ext_lock def spec_from_gem(path) require "rubygems/package" - Gem::Package.new(path).spec + package = Gem::Package.new(path) + spec = package.spec + if package.respond_to?(:content_address) + spec.content_address = package.content_address + end + spec end def build_gem(gem_dir, spec) diff --git a/lib/bundler/source/rubygems.rb b/lib/bundler/source/rubygems.rb index b072f51b177d..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 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/source.rb b/lib/rubygems/source.rb index 7548a33ce46b..7b59230d46ef 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. @@ -359,27 +377,6 @@ def compact_index_info_rows(name) [] end - class ContentAddressableInfo - attr_reader :version, :suffix, :ruby_abi, :platform - - def initialize(version, suffix, ruby_abi, platform = nil) - @version = version - @suffix = suffix - @ruby_abi = ruby_abi - @platform = platform - end - - def hash - [@version, @suffix].hash - end - - def eql?(other) - other.is_a?(ContentAddressableInfo) && - version == other.version && - suffix == other.suffix - end - end - def content_addressable_tuples(name, rows) metadata = content_addressable_metadata(name, rows) diff --git a/lib/rubygems/specification.rb b/lib/rubygems/specification.rb index b92393928a83..a3018912a041 100644 --- a/lib/rubygems/specification.rb +++ b/lib/rubygems/specification.rb @@ -2140,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 ## diff --git a/spec/bundler/endpoint_specification_spec.rb b/spec/bundler/endpoint_specification_spec.rb index 207db2fcbf38..26e049899087 100644 --- a/spec/bundler/endpoint_specification_spec.rb +++ b/spec/bundler/endpoint_specification_spec.rb @@ -3,12 +3,12 @@ RSpec.describe Bundler::EndpointSpecification do let(:name) { "foo" } let(:version) { "1.0.0" } - let(:platform) { Gem::Platform::RUBY } + let(:suffix) { Gem::Platform::RUBY } let(:dependencies) { [] } let(:spec_fetcher) { double(:spec_fetcher) } let(:metadata) { nil } - subject(:spec) { described_class.new(name, version, platform, spec_fetcher, dependencies, metadata) } + subject(:spec) { described_class.new(name, version, suffix, spec_fetcher, dependencies, metadata) } def with_tz(tz) orig_tz = ENV["TZ"] @@ -44,6 +44,37 @@ def with_tz(tz) end describe "#parse_metadata" do + context "when a content-addressed suffix has platform metadata" do + let(:suffix) { "abc1234567" } + let(:metadata) { { "platform" => ["= arm64-darwin"], "ruby" => [">= 3.0.0"] } } + + it "uses the platform from the metadata" do + expect(spec.platform).to eq(Gem::Platform.new("arm64-darwin")) + expect(spec.content_address).to eq("abc1234567") + end + + it "includes the content address in full_name" do + expect(spec.full_name).to eq("foo-1.0.0-abc1234567") + end + end + + context "when the suffix is an ordinary platform" do + let(:suffix) { "x86_64-linux" } + + it "uses the suffix as the platform without a content address" do + expect(spec.platform).to eq(Gem::Platform.new("x86_64-linux")) + expect(spec.content_address).to be_nil + end + end + + context "when a content-addressed suffix has no platform metadata" do + let(:suffix) { "abc1234567" } + + it "treats the suffix as a platform without a content address" do + expect(spec.content_address).to be_nil + end + end + context "when the metadata has malformed requirements" do let(:metadata) { { "rubygems" => ">\n" } } it "raises a helpful error message" do @@ -152,33 +183,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_parser_spec.rb b/spec/bundler/lockfile_parser_spec.rb index c92d8909d29e..c54ae5d1fa92 100644 --- a/spec/bundler/lockfile_parser_spec.rb +++ b/spec/bundler/lockfile_parser_spec.rb @@ -145,6 +145,65 @@ include_examples "parsing" + context "when a spec has a content address" do + let(:lockfile_contents) do + <<~L + GEM + remote: https://rubygems.org/ + specs: + mygem (1.0-abcdef1234) x86_64-linux + + PLATFORMS + x86_64-linux + + DEPENDENCIES + mygem + + CHECKSUMS + mygem (1.0-abcdef1234) sha256=814828c34f1315d7e7b7e8295184577cc4e969bad6156ac069d02d63f58d82e8 + + BUNDLED WITH + 1.12.0.rc.2 + L + end + + it "parses the platform and content address" do + spec = subject.specs.find {|s| s.name == "mygem" } + + expect(spec.platform).to eq(Gem::Platform.new("x86_64-linux")) + expect(spec.content_address).to eq("abcdef1234") + + checksums = subject.sources.first.checksum_store.to_lock(spec) + expect(checksums).to eq("#{spec.lock_name} sha256=814828c34f1315d7e7b7e8295184577cc4e969bad6156ac069d02d63f58d82e8") + end + end + + context "when a Ruby-platform suffix resembles a content address but no platform is present" do + let(:lockfile_contents) do + <<~L + GEM + remote: https://rubygems.org/ + specs: + mygem (1.0-abcdef1234) + + PLATFORMS + ruby + + DEPENDENCIES + mygem + + BUNDLED WITH + 1.12.0.rc.2 + L + end + + it "does not parse the suffix as a content address" do + spec = subject.specs.find {|s| s.name == "mygem" } + + expect(spec.content_address).to be_nil + end + end + context "when an extra section is at the end" do let(:lockfile_contents) { super() + "\n\nFOO BAR\n baz\n baa\n qux\n" } include_examples "parsing" diff --git a/spec/bundler/override_spec.rb b/spec/bundler/override_spec.rb index ad8be75520b5..347509d27d3d 100644 --- a/spec/bundler/override_spec.rb +++ b/spec/bundler/override_spec.rb @@ -56,6 +56,7 @@ def initialize(name, ruby_req, rubygems_req) src.define_singleton_method(:runtime_dependencies) { [] } src.define_singleton_method(:required_ruby_version) { Gem::Requirement.default } src.define_singleton_method(:required_rubygems_version) { Gem::Requirement.default } + src.define_singleton_method(:content_address) { nil } src.define_singleton_method(:respond_to?) {|*| raise "from_spec must not call respond_to?" } expect { Bundler::LazySpecification.from_spec(src) }.not_to raise_error end diff --git a/spec/bundler/remote_specification_spec.rb b/spec/bundler/remote_specification_spec.rb index f35b231d5869..fa794b7d3d80 100644 --- a/spec/bundler/remote_specification_spec.rb +++ b/spec/bundler/remote_specification_spec.rb @@ -3,10 +3,10 @@ RSpec.describe Bundler::RemoteSpecification do let(:name) { "foo" } let(:version) { Gem::Version.new("1.0.0") } - let(:platform) { Gem::Platform::RUBY } + let(:suffix) { Gem::Platform::RUBY } let(:spec_fetcher) { double(:spec_fetcher) } - subject { described_class.new(name, version, platform, spec_fetcher) } + subject { described_class.new(name, version, suffix, spec_fetcher) } it "is Comparable" do expect(described_class.ancestors).to include(Comparable) @@ -34,7 +34,7 @@ end context "when platform is nil" do - let(:platform) { nil } + let(:suffix) { nil } it "should return the spec name and version" do expect(subject.full_name).to eq("foo-1.0.0") @@ -42,7 +42,7 @@ end context "when platform is a non-ruby platform" do - let(:platform) { "jruby" } + let(:suffix) { "jruby" } it "should return the spec name, version, and platform" do expect(subject.full_name).to eq("foo-1.0.0-java") @@ -53,7 +53,7 @@ describe "#<=>" do let(:other_name) { name } let(:other_version) { version } - let(:other_platform) { platform } + let(:other_platform) { suffix } let(:other_spec_fetcher) { spec_fetcher } shared_examples_for "a comparison" do @@ -147,7 +147,7 @@ end context "when platform is not ruby" do - let(:platform) { "jruby" } + let(:suffix) { "jruby" } it "should return a sorting delegate array with name, version, and 1" do expect(subject.sort_obj).to match_array(["foo", version, 1]) @@ -184,4 +184,21 @@ end end end + + describe "#content_address" do + it "is nil for a hex suffix (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/install/cooldown_spec.rb b/spec/install/cooldown_spec.rb index 2c0cb7f96ae4..b1f3bbcc7d25 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 @@ -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..9184079f4d19 --- /dev/null +++ b/spec/install/gemfile/content_addressable_spec.rb @@ -0,0 +1,494 @@ +# frozen_string_literal: true + +RSpec.describe "bundle install with content-addressable gems", :compact_index, rubygems: ">= 4.1.0.dev" do + before do + skip "Gem::ContentAddress not available" if ruby_core? + end + + let(:current_abi) { "#{Gem.ruby_version.segments[0]}.#{Gem.ruby_version.segments[1]}" } + let(:mismatched_abi) { "#{Gem.ruby_version.segments[0] + 1}.0" } + + it "installs the content-addressed gem when the Ruby ABI matches" do + simulate_platform "x86_64-linux" do + build_repo2 do + build_gem "mygem", "1.0" do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.write "lib/mygem.rb", "MYGEM = '1.0 not_content_addressed'" + end + end + + build_gem "mygem", "1.0", ruby_abi: current_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{current_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed'" + end + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s } + source "https://gem.repo2" + + gem "mygem" + G + + expect(the_bundle).to include_gems "mygem 1.0 content_addressed" + + cached_files = Dir.glob(default_bundle_path("cache", "mygem-1.0-*.gem").to_s) + expect(cached_files.size).to eq(1), "expected exactly one cached gem file, found: #{cached_files}" + expect(cached_files.first).to match(/mygem-1\.0-[0-9a-f]{8,64}\.gem$/) + expect(default_bundle_path("cache", "mygem-1.0-x86_64-linux.gem")).not_to exist + expect(lockfile).to match(/^ mygem \(1\.0-[0-9a-f]{8,64}\) x86_64-linux$/) + + content_address = File.basename(cached_files.first, ".gem").rpartition("-").last + digest = Digest::SHA256.file(cached_files.first).hexdigest + expect(content_address).to eq(digest[0, content_address.length]) + + checksums = checksums_section_when_enabled do |c| + c.checksum(gem_repo2, "mygem", "1.0", "x86_64-linux", content_address: content_address) + end + expect(lockfile).to include(checksums.to_s) + expect(lockfile).to include("mygem (1.0-#{content_address}) sha256=#{digest}") + expect(lockfile).not_to include("mygem (1.0-x86_64-linux)") + end + end + + it "resolves a content-addressed binary from the local cache after a lockfile round-trip" do + simulate_platform "x86_64-linux" do + build_repo2 do + build_gem "mygem", "1.0" do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.write "lib/mygem.rb", "MYGEM = '1.0 not_content_addressed'" + end + end + + build_gem "mygem", "1.0", ruby_abi: current_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{current_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed'" + end + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s } + source "https://gem.repo2" + + gem "mygem" + G + + cached_file = Dir[default_bundle_path("cache", "mygem-1.0-*.gem").to_s].first + FileUtils.mkdir_p(bundled_app("vendor/cache")) + FileUtils.cp(cached_file, bundled_app("vendor/cache")) + + gem_dir = Dir[default_bundle_path("gems", "mygem-1.0-*").to_s].first + pristine_system_gems + bundle "install --local" + + expect(the_bundle).to include_gems "mygem 1.0 content_addressed" + expect(Dir[default_bundle_path("gems", "mygem-1.0-*").to_s].first).to eq(gem_dir) + end + end + + it "falls back to the non-content-addressed gem when the content-addressed gem requires a different Ruby ABI" do + simulate_platform "x86_64-linux" do + build_repo2 do + build_gem "mygem", "1.0" do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.write "lib/mygem.rb", "MYGEM = '1.0 not_content_addressed'" + end + end + + build_gem "mygem", "1.0", ruby_abi: mismatched_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{mismatched_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed'" + end + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s } + source "https://gem.repo2" + + gem "mygem" + G + + expect(the_bundle).to include_gems "mygem 1.0 not_content_addressed" + end + end + + it "does not treat a content-addressed suffix as content-addressable when platform metadata is missing" do + simulate_platform "x86_64-linux" do + build_repo2 do + build_gem "mygem", "1.0" do |s| + s.platform = "abcdef12" + s.write "lib/mygem.rb", "MYGEM = '1.0 hex_platform'" + end + end + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s }, raise_on_error: false + source "https://gem.repo2" + + gem "mygem" + G + + expect(err).to include("Could not find gem 'mygem'") + end + end + + it "falls back to the non-content-addressed gem when the content-addressed gem is for a different platform" do + simulate_platform "x86_64-linux" do + build_repo2 do + build_gem "mygem", "1.0" do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.write "lib/mygem.rb", "MYGEM = '1.0 not_content_addressed'" + end + end + + build_gem "mygem", "1.0", ruby_abi: current_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("arm64-darwin") + s.required_ruby_version = "~> #{current_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed'" + end + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s } + source "https://gem.repo2" + + gem "mygem" + G + + expect(the_bundle).to include_gems "mygem 1.0 not_content_addressed" + end + end + + it "installs the content-addressed gem matching the current platform when multiple platforms are available" do + simulate_platform "x86_64-linux" do + build_repo2 + + build_gem "mygem", "1.0", ruby_abi: current_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{current_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed_linux'" + end + + build_gem "mygem", "1.0", ruby_abi: current_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("arm64-darwin") + s.required_ruby_version = "~> #{current_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed_darwin'" + end + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s } + source "https://gem.repo2" + + gem "mygem" + G + + expect(the_bundle).to include_gems "mygem 1.0 content_addressed_linux" + end + end + + it "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 + + lockfile <<~L + GEM + remote: https://gem.repo2/ + specs: + mygem (1.0-#{mismatched_address}) x86_64-linux + + PLATFORMS + x86_64-linux + + DEPENDENCIES + mygem + + CHECKSUMS + mygem (1.0-#{mismatched_address}) sha256=#{mismatched_checksum} + + BUNDLED WITH + #{Bundler::VERSION} + L + + bundle_config "frozen true" + bundle "install", artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s }, raise_on_error: false + + expect(last_command).to be_failure + expect(the_bundle).not_to include_gems "mygem 1.0 content_addressed" + expect(lockfile).to include("mygem (1.0-#{mismatched_address}) x86_64-linux") + end + end + + it "fails when the downloaded content-addressed gem hash does not match the filename" do + simulate_platform "x86_64-linux" do + build_repo2 do + build_gem "mygem", "1.0" do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.write "lib/mygem.rb", "MYGEM = '1.0 not_content_addressed'" + end + end + + build_gem "mygem", "1.0", ruby_abi: current_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{current_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed'" + end + + ca_gem = Dir[gem_repo2("gems", "mygem-1.0-[0-9a-f]*.gem")].first + non_ca_gem = gem_repo2("gems", "mygem-1.0-x86_64-linux.gem") + FileUtils.cp non_ca_gem, ca_gem + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s }, raise_on_error: false + source "https://gem.repo2" + + gem "mygem" + G + + expect(err).to include("content address mismatch") + end + end +end + +RSpec.describe "bundle install with content-addressable gems invisible to pre-4.1 RubyGems clients", :compact_index, rubygems: ">= 4.1.0.a" do + 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" + + installed_gemspec = Dir[default_bundle_path("specifications", "mygem-1.0-*.gemspec").to_s].first + 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..056371f5eb80 100644 --- a/spec/other/ext_spec.rb +++ b/spec/other/ext_spec.rb @@ -46,5 +46,20 @@ expect(Gem::NameTuple.new("a", v("1.0.0"), "ruby").lock_name).to eq("a (1.0.0)") expect(Gem::NameTuple.new("a", v("1.0.0")).lock_name).to eq("a (1.0.0)") end + + it "uses content_address in the lock name when set" do + expect(Gem::NameTuple.new("a", v("1.0.0"), "x86_64-linux", content_address: "abcdef12").lock_name).to eq("a (1.0.0-abcdef12)") + expect(Gem::NameTuple.new("a", v("1.0.0"), "ruby", content_address: "abcdef12").lock_name).to eq("a (1.0.0-abcdef12)") + end + end +end + +RSpec.describe Bundler::LazySpecification do + describe "#to_lock" do + it "appends the content address after the platform lock name when set" do + spec = Bundler::LazySpecification.new("mygem", v("1.0"), "x86_64-linux", nil, content_address: "abcdef1234") + + expect(spec.to_lock).to eq(" mygem (1.0-abcdef1234) x86_64-linux\n") + end end end diff --git a/spec/support/artifice/compact_index_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_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/helpers/compact_index.rb b/spec/support/artifice/helpers/compact_index.rb index e684aa862879..b2c61d2b8317 100644 --- a/spec/support/artifice/helpers/compact_index.rb +++ b/spec/support/artifice/helpers/compact_index.rb @@ -6,6 +6,16 @@ require "compact_index" require "digest" +# The vendored compact_index code targets rubygems.org's Rails environment; +# provide the ActiveSupport predicate it relies on when running without Rails. +unless Object.method_defined?(:present?) + class Object + def present? + respond_to?(:empty?) ? !empty? : !!self + end + end +end + class CompactIndexAPI < Endpoint helpers do include Spec::Path @@ -85,7 +95,7 @@ def gems(gem_repo = default_gem_repo) 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 @@ -98,7 +108,7 @@ def gems(gem_repo = default_gem_repo) end def build_gem_version(spec, deps, checksum) - CompactIndex::GemVersion.new(spec.version.version, spec.platform.to_s, checksum, nil, + 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) 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_v2.rb b/spec/support/artifice/helpers/compact_index_v2.rb new file mode 100644 index 000000000000..48c4b6a36a91 --- /dev/null +++ b/spec/support/artifice/helpers/compact_index_v2.rb @@ -0,0 +1,52 @@ +# 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 + 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, + spec.ruby_abi, spec.content_address) + end + + def content_addressable_specs(gem_repo) + Dir.glob(File.join(gem_repo, "gems", "*.gem")).filter_map do |file| + token = File.basename(file, ".gem").rpartition("-").last + next unless Gem::ContentAddress.match?(token) + + spec = Gem::Package.new(file).spec + next unless Gem::ContentAddress.applicable?(spec) + spec.content_address = token + spec + end + end + end + + def gems(gem_repo = default_gem_repo) + all_gems = super + ca_specs = content_addressable_specs(gem_repo) + ca_specs.group_by(&:name).each do |name, versions| + gem = all_gems.find {|g| g.name == name } + new_versions = versions.map do |spec| + deps = spec.runtime_dependencies.map do |d| + reqs = d.requirement.requirements.map {|r| r.join(" ") }.join(", ") + CompactIndex::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 << CompactIndex::Gem.new(name, new_versions) + end + end + all_gems + end +end diff --git a/spec/support/builders.rb b/spec/support/builders.rb index 43ab7e053dfb..4204b93d3eca 100644 --- a/spec/support/builders.rb +++ b/spec/support/builders.rb @@ -659,17 +659,20 @@ def _build(opts) destination = opts[:path] || _default_path FileUtils.mkdir_p(lib_path.join(destination)) - if [:yaml, false].include?(opts[:gemspec]) - Dir.chdir(lib_path) do - Bundler.rubygems.build(@spec, opts[:skip_validation]) + built_gem = + if opts[:ruby_abi] + Dir.chdir(lib_path) { Gem::Package.build(@spec, false, false, nil, opts[:ruby_abi]) } + elsif [:yaml, false].include?(opts[:gemspec]) + Dir.chdir(lib_path) do + Bundler.rubygems.build(@spec, opts[:skip_validation]) + end + elsif opts[:skip_validation] + Dir.chdir(lib_path) { Gem::Package.build(@spec, true) } + else + Dir.chdir(lib_path) { Gem::Package.build(@spec) } end - elsif opts[:skip_validation] - Dir.chdir(lib_path) { Gem::Package.build(@spec, true) } - else - Dir.chdir(lib_path) { Gem::Package.build(@spec) } - end - gem_path = File.expand_path("#{@spec.full_name}.gem", lib_path) + gem_path = File.expand_path(built_gem || "#{@spec.full_name}.gem", lib_path) if opts[:to_system] @context.system_gems gem_path, default: opts[:default] elsif opts[:to_bundle] diff --git a/spec/support/checksums.rb b/spec/support/checksums.rb index 7b69bba6680b..638c9cf421f3 100644 --- a/spec/support/checksums.rb +++ b/spec/support/checksums.rb @@ -16,18 +16,18 @@ def initialize_copy(original) @checksums = @checksums.dup end - def checksum(repo, name, version, platform = Gem::Platform::RUBY, folder = "gems") + def checksum(repo, name, version, platform = Gem::Platform::RUBY, folder = "gems", content_address: nil) @bundler_registered = true if name == "bundler" - name_tuple = Gem::NameTuple.new(name, version, platform) + name_tuple = Gem::NameTuple.new(name, version, platform, content_address: content_address) gem_file = File.join(repo, folder, "#{name_tuple.full_name}.gem") File.open(gem_file, "rb") do |f| register(name_tuple, Bundler::Checksum.from_gem(f, "#{gem_file} (via ChecksumsBuilder#checksum)")) end end - def no_checksum(name, version, platform = Gem::Platform::RUBY) - name_tuple = Gem::NameTuple.new(name, version, platform) + def no_checksum(name, version, platform = Gem::Platform::RUBY, content_address: nil) + name_tuple = Gem::NameTuple.new(name, version, platform, content_address: content_address) register(name_tuple, nil) end diff --git a/spec/support/rubygems_ext.rb b/spec/support/rubygems_ext.rb index 812dc4deaa9c..5d60bce7d019 100644 --- a/spec/support/rubygems_ext.rb +++ b/spec/support/rubygems_ext.rb @@ -105,7 +105,7 @@ def install_vendored_compact_index next if files.all? {|path| File.exist?(target_root.join(path)) } require "open-uri" - ref = ENV["COMPACT_INDEX_REF"] || "7c68a7b39761c61a66f9299f85b889ec39afc02c" + ref = ENV["COMPACT_INDEX_REF"] || "bdf05e24cd381402822387240f1697c0193ad171" files.each do |path| url = "https://raw.githubusercontent.com/rubygems/rubygems.org/#{ref}/#{path}" target = target_root.join(path) diff --git a/test/rubygems/test_gem_dependency_installer.rb b/test/rubygems/test_gem_dependency_installer.rb index a4b183fa075f..685455068f64 100644 --- a/test/rubygems/test_gem_dependency_installer.rb +++ b/test/rubygems/test_gem_dependency_installer.rb @@ -426,6 +426,7 @@ def test_install_local 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 @@ -442,6 +443,8 @@ def test_install_local_by_name_preserves_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 From 2ae5f05f52eb6e1b4fff05056bb884d96f6b989e Mon Sep 17 00:00:00 2001 From: Jenny Shen Date: Sun, 30 Aug 2026 14:44:37 -0400 Subject: [PATCH 07/17] Vendor compact_index under a dedicated namespace Gem::Indexer (rubygems-generate_index) loads the V1-only CompactIndex constants, either from the compact_index gem or from its own embedded copy, so the artifice's copy of rubygems.org's V2-only implementation can never share that name safely. Check in a copy of rubygems.org's lib/compact_index, renamed to the VendoredCompactIndex namespace, under spec/support/vendor/compact_index, and load it from the artifice with a plain require. Checking the copy in (rather than downloading it during the test run) keeps the suite hermetic and offline, makes the namespace rewrite visible in review, and lets parallel workers and CI runners that skip the test-deps setup load it without falling back to the incompatible gem. Refresh the copy with `rake vendor:compact_index`; `rake vendor:compact_index_check` fails if the checked-in copy drifts from the pinned upstream ref. --- .rubocop.yml | 1 + Rakefile | 40 ++++++++++ .../compact_index_concurrent_download.rb | 2 +- ...compact_index_partial_update_bad_digest.rb | 4 +- ...artial_update_no_digest_not_incremental.rb | 4 +- .../compact_index_precompiled_before.rb | 2 +- .../artifice/compact_index_range_ignored.rb | 2 +- .../artifice/compact_index_rate_limited.rb | 2 +- .../compact_index_wrong_dependencies.rb | 2 +- .../compact_index_wrong_gem_checksum.rb | 2 +- .../support/artifice/helpers/compact_index.rb | 25 ++---- .../helpers/compact_index_extra_api.rb | 6 +- .../artifice/helpers/compact_index_v2.rb | 12 ++- spec/support/rubygems_ext.rb | 42 ---------- .../vendor/compact_index/lib/compact_index.rb | 25 ++++++ .../lib/compact_index/dependency.rb | 13 ++++ .../compact_index/lib/compact_index/gem.rb | 9 +++ .../lib/compact_index/gem_version.rb | 74 ++++++++++++++++++ .../lib/compact_index/versions_file.rb | 77 +++++++++++++++++++ spec/support/vendored_compact_index.rb | 14 ++++ 20 files changed, 281 insertions(+), 77 deletions(-) create mode 100644 spec/support/vendor/compact_index/lib/compact_index.rb create mode 100644 spec/support/vendor/compact_index/lib/compact_index/dependency.rb create mode 100644 spec/support/vendor/compact_index/lib/compact_index/gem.rb create mode 100644 spec/support/vendor/compact_index/lib/compact_index/gem_version.rb create mode 100644 spec/support/vendor/compact_index/lib/compact_index/versions_file.rb create mode 100644 spec/support/vendored_compact_index.rb 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/Rakefile b/Rakefile index ae9874da96d2..120a1a8a2c65 100644 --- a/Rakefile +++ b/Rakefile @@ -164,6 +164,46 @@ namespace :vendor do error_message: "Vendored gems are out of sync. Please update the vendored lib patches." ) end + + # Pinned upstream revision of rubygems/rubygems.org that the vendored + # compact_index copy is generated from. Bump this (or pass COMPACT_INDEX_REF) + # and re-run the task to refresh. + COMPACT_INDEX_REF = "572cf8948f53520668c7a07808760566f07129f8" + COMPACT_INDEX_FILES = %w[ + lib/compact_index.rb + lib/compact_index/dependency.rb + lib/compact_index/gem.rb + lib/compact_index/gem_version.rb + lib/compact_index/versions_file.rb + ].freeze + + desc "Vendor spec-suite compact_index from rubygems.org (COMPACT_INDEX_REF to override ref)" + task :compact_index do + require "open-uri" + require "fileutils" + + ref = ENV["COMPACT_INDEX_REF"] || COMPACT_INDEX_REF + dest_root = File.expand_path("spec/support/vendor/compact_index", __dir__) + + COMPACT_INDEX_FILES.each do |path| + url = "https://raw.githubusercontent.com/rubygems/rubygems.org/#{ref}/#{path}" + contents = URI.parse(url).open(&:read).gsub("CompactIndex", "VendoredCompactIndex") + + target = File.join(dest_root, path) + FileUtils.mkdir_p(File.dirname(target)) + File.write(target, contents) + end + + puts "Vendored compact_index from rubygems.org@#{ref} into #{dest_root}" + end + + desc "Check vendored compact_index is up to date" + task compact_index_check: :compact_index do + Spec::Rubygems.check_source_control_changes( + success_message: "Vendored compact_index is in sync", + error_message: "Vendored compact_index is out of sync. Run `rake vendor:compact_index`." + ) + end end namespace :rubocop do diff --git a/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_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_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 b2c61d2b8317..de3d57f5fac7 100644 --- a/spec/support/artifice/helpers/compact_index.rb +++ b/spec/support/artifice/helpers/compact_index.rb @@ -1,21 +1,10 @@ # 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" -# The vendored compact_index code targets rubygems.org's Rails environment; -# provide the ActiveSupport predicate it relies on when running without Rails. -unless Object.method_defined?(:present?) - class Object - def present? - respond_to?(:empty?) ? !empty? : !!self - end - end -end - class CompactIndexAPI < Endpoint helpers do include Spec::Path @@ -91,7 +80,7 @@ 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 @@ -102,20 +91,20 @@ def gems(gem_repo = default_gem_repo) 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::GemVersionV2.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 @@ -123,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 @@ -132,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_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 index 48c4b6a36a91..116a1e39e1d5 100644 --- a/spec/support/artifice/helpers/compact_index_v2.rb +++ b/spec/support/artifice/helpers/compact_index_v2.rb @@ -6,9 +6,13 @@ class CompactIndexV2API < 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, + # 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, - spec.ruby_abi, spec.content_address) + ruby_abi, content_address) end def content_addressable_specs(gem_repo) @@ -32,7 +36,7 @@ def gems(gem_repo = default_gem_repo) new_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 = Digest(:SHA256).file("#{gem_repo}/gems/#{spec.full_name}.gem").hexdigest @@ -44,7 +48,7 @@ def gems(gem_repo = default_gem_repo) if gem gem.versions.concat(new_versions) else - all_gems << CompactIndex::Gem.new(name, new_versions) + all_gems << VendoredCompactIndex::Gem.new(name, new_versions) end end all_gems diff --git a/spec/support/rubygems_ext.rb b/spec/support/rubygems_ext.rb index 5d60bce7d019..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"] || "bdf05e24cd381402822387240f1697c0193ad171" - files.each do |path| - url = "https://raw.githubusercontent.com/rubygems/rubygems.org/#{ref}/#{path}" - target = target_root.join(path) - FileUtils.mkdir_p(File.dirname(target)) - tmp = "#{target}.tmp" - File.write(tmp, URI.parse(url).open(&:read)) - File.rename(tmp, target) - end - end end def check_source_control_changes(success_message:, error_message:) diff --git a/spec/support/vendor/compact_index/lib/compact_index.rb b/spec/support/vendor/compact_index/lib/compact_index.rb new file mode 100644 index 000000000000..e132143aec39 --- /dev/null +++ b/spec/support/vendor/compact_index/lib/compact_index.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# Vendored from https://github.com/rubygems/compact_index v0.15.0 + +require_relative "compact_index/gem" +require_relative "compact_index/gem_version" +require_relative "compact_index/dependency" + +require_relative "compact_index/versions_file" + +module VendoredCompactIndex + def self.names(gem_names) + gem_names.join("\n").prepend("---\n") << "\n" + end + + def self.versions(versions_file, gems = nil, args = {}) + versions_file.contents(gems, args) + end + + def self.info(versions) + versions.inject(+"---\n") do |output, version| + output << version.to_line << "\n" + end + end +end diff --git a/spec/support/vendor/compact_index/lib/compact_index/dependency.rb b/spec/support/vendor/compact_index/lib/compact_index/dependency.rb new file mode 100644 index 000000000000..3c67e4f9b838 --- /dev/null +++ b/spec/support/vendor/compact_index/lib/compact_index/dependency.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module VendoredCompactIndex + Dependency = Struct.new(:gem, :version, :platform, :checksum) do + def version_and_platform + if platform.nil? || platform == "ruby" + version + else + "#{version}-#{platform}" + end + end + end +end diff --git a/spec/support/vendor/compact_index/lib/compact_index/gem.rb b/spec/support/vendor/compact_index/lib/compact_index/gem.rb new file mode 100644 index 000000000000..bfe6150ec59b --- /dev/null +++ b/spec/support/vendor/compact_index/lib/compact_index/gem.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module VendoredCompactIndex + Gem = Struct.new(:name, :versions) do + def <=>(other) + name <=> other.name + end + end +end diff --git a/spec/support/vendor/compact_index/lib/compact_index/gem_version.rb b/spec/support/vendor/compact_index/lib/compact_index/gem_version.rb new file mode 100644 index 000000000000..192eb4d7a828 --- /dev/null +++ b/spec/support/vendor/compact_index/lib/compact_index/gem_version.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module VendoredCompactIndex + module GemVersionMethods + def version_token + return "#{number}-#{content_address}" if content_address? + + if platform.nil? || platform == "ruby" + number + else + "#{number}-#{platform}" + end + end + + def <=>(other) + number_comp = number <=> other.number + + if number_comp.zero? + [platform, ruby_abi, content_address].compact <=> + [other.platform, other.ruby_abi, other.content_address].compact + else + number_comp + end + end + + def to_line + line = "#{version_token} #{deps_line}|checksum:#{checksum}" + line << ",ruby:#{ruby_version_line}" if ruby_version && ruby_version != ">= 0" + line << ",rubygems:#{rubygems_version_line}" if rubygems_version && rubygems_version != ">= 0" + line << ",platform:= #{platform}" if content_address? + line + end + + private + + def content_address? + !content_address.nil? && !content_address.empty? + end + + def ruby_version_line + join_multiple(ruby_version) + end + + def rubygems_version_line + join_multiple(rubygems_version) + end + + def deps_line + return "" if dependencies.nil? + + dependencies.map do |d| + [d[:gem], join_multiple(d.version_and_platform)].join(":") + end.join(",") + end + + def join_multiple(requirements) + requirements = requirements.split(", ") + requirements.sort! + requirements.join("&") + end + end + + GemVersionV2 = Struct.new(:number, :platform, :checksum, :info_checksum, + :dependencies, :ruby_version, :rubygems_version, + :created_at, :ruby_abi, :content_address) do + include GemVersionMethods + + def to_line + line = super + line << ",created_at:#{created_at}" if created_at + line + end + end +end diff --git a/spec/support/vendor/compact_index/lib/compact_index/versions_file.rb b/spec/support/vendor/compact_index/lib/compact_index/versions_file.rb new file mode 100644 index 000000000000..3e1c65f4215a --- /dev/null +++ b/spec/support/vendor/compact_index/lib/compact_index/versions_file.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "time" +require "date" +require "digest" + +module VendoredCompactIndex + class VersionsFile + def initialize(file = nil) + @path = file || "/versions.list" + end + + def contents(gems = nil, args = {}) + gems = calculate_info_checksums(gems) if args.delete(:calculate_info_checksums) { false } + + raise ArgumentError, "Unknown options: #{args.keys.join(', ')}" unless args.empty? + + File.read(@path).tap do |out| + out << gem_lines(gems) if gems + end + end + + def updated_at + created_at_header(@path) || Time.at(0).utc.to_datetime + end + + def create(gems, timestamp = Time.now.iso8601) + gems.sort! + create_from_sorted(gems, timestamp) + end + + def create_from_sorted(gems, timestamp = Time.now.iso8601) + File.open(@path, "w") do |io| + io.write "created_at: #{timestamp}\n---\n" + write_gem_lines(io, gems) + end + end + + private + + def gem_lines(gems) + lines = +"" + write_gem_lines(lines, gems) + lines + end + + def write_gem_lines(io, gems) + gems.each do |gem| + version_numbers = gem.versions.map(&:version_token).join(",") + io << gem.name << + " " << version_numbers << + " #{gem.versions.last.info_checksum}\n" + end + end + + def calculate_info_checksums(gems) + gems.each do |gem| + info_checksum = Digest::MD5.hexdigest(VendoredCompactIndex.info(gem[:versions])) + gem[:versions].last[:info_checksum] = info_checksum + end + end + + def created_at_header(path) + return unless File.exist? path + + File.open(path) do |file| + file.each_line do |line| + line.match(/created_at: (.*)\n|---\n/) do |match| + return match[1] && DateTime.parse(match[1]) + end + end + end + + nil + end + end +end diff --git a/spec/support/vendored_compact_index.rb b/spec/support/vendored_compact_index.rb new file mode 100644 index 000000000000..dcf0e5cd3e44 --- /dev/null +++ b/spec/support/vendored_compact_index.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# Bundler specs load this code from the compact-index artifice, including +# inside spawned Bundler processes, so it must have no side effects beyond +# defining the VendoredCompactIndex constants; in particular it must not +# touch $LOAD_PATH or load any other spec setup code. +# +# The vendored copy under spec/support/vendor/compact_index/ is rubygems.org's +# `lib/compact_index/`, renamed to the VendoredCompactIndex namespace so it can +# never collide with the V1-only CompactIndex constants that +# rubygems-generate_index loads for Gem::Indexer. Refresh it with +# `rake vendor:compact_index`. +# +require_relative "vendor/compact_index/lib/compact_index" unless defined?(VendoredCompactIndex) From c375a585b64f0a534c6a34cb3cd9fb76ce74726c Mon Sep 17 00:00:00 2001 From: Gira Chawda Date: Mon, 31 Aug 2026 19:12:02 -0400 Subject: [PATCH 08/17] Support content addressable gems in gem update, outdated, dependency, and fetch --- lib/rubygems/commands/dependency_command.rb | 18 +++- lib/rubygems/commands/outdated_command.rb | 2 +- lib/rubygems/commands/update_command.rb | 2 +- lib/rubygems/compact_index_client.rb | 5 +- lib/rubygems/query_utils.rb | 11 +-- lib/rubygems/source.rb | 18 +++- lib/rubygems/spec_fetcher.rb | 54 +++++++--- .../gemfile/content_addressable_spec.rb | 4 + test/rubygems/helper.rb | 25 +++++ .../test_gem_commands_dependency_command.rb | 47 +++++++++ .../test_gem_commands_fetch_command.rb | 38 +++++++ .../test_gem_commands_outdated_command.rb | 27 +++++ .../test_gem_commands_update_command.rb | 98 +++++++++++++++++++ .../rubygems/test_gem_compact_index_client.rb | 13 ++- test/rubygems/test_gem_source.rb | 10 ++ test/rubygems/test_gem_spec_fetcher.rb | 81 +++++++++++++++ 16 files changed, 419 insertions(+), 34 deletions(-) diff --git a/lib/rubygems/commands/dependency_command.rb b/lib/rubygems/commands/dependency_command.rb index 9aaefae999d4..3caf5c367270 100644 --- a/lib/rubygems/commands/dependency_command.rb +++ b/lib/rubygems/commands/dependency_command.rb @@ -66,7 +66,11 @@ def fetch_remote_specs(name, requirement, prerelease) # :nodoc: end end - ss.map {|tuple, source| source.fetch_spec(tuple) } + fetcher.decode_content_addressable_tuples(ss).map do |tuple, source| + spec = source.fetch_spec(tuple) + spec.content_address = tuple.content_address if tuple.content_address + spec + end end def fetch_specs(name_pattern, requirement, prerelease) # :nodoc: @@ -85,7 +89,7 @@ def fetch_specs(name_pattern, requirement, prerelease) # :nodoc: ensure_specs specs - specs.uniq.sort + specs.uniq(&:full_name).sort end def display_pipe(specs) # :nodoc: @@ -148,9 +152,17 @@ def ensure_specs(specs) # :nodoc: terminate_interaction 1 end + def content_address_annotation(spec) # :nodoc: + return "" unless Gem::ContentAddress.content_addressed?(spec) + + parts = ["Platform: #{spec.platform}"] + parts << "Ruby ABI: #{spec.ruby_abi}" if spec.ruby_abi + " (#{parts.join(" ")})" + end + def print_dependencies(spec, level = 0) # :nodoc: response = String.new - response << " " * level + "Gem #{spec.full_name}\n" + response << " " * level + "Gem #{spec.full_name}#{content_address_annotation(spec)}\n" unless spec.dependencies.empty? spec.dependencies.sort_by(&:name).each do |dep| response << " " * level + " #{dep}\n" diff --git a/lib/rubygems/commands/outdated_command.rb b/lib/rubygems/commands/outdated_command.rb index 7721be88e71f..f0bd03a6e672 100644 --- a/lib/rubygems/commands/outdated_command.rb +++ b/lib/rubygems/commands/outdated_command.rb @@ -88,7 +88,7 @@ def partition_by_cooldown(spec_tuples) embargoed = [] with_times = spec_tuples.map do |tup, source| - [tup, source, source.created_at(tup.name, tup.version, tup.platform)] + [tup, source, source.created_at_for_tuple(tup)] end if !with_times.empty? && with_times.none? {|_, _, created_at| created_at } diff --git a/lib/rubygems/commands/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/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/query_utils.rb b/lib/rubygems/query_utils.rb index 91fe2535101b..1f1e708052fe 100644 --- a/lib/rubygems/query_utils.rb +++ b/lib/rubygems/query_utils.rb @@ -156,22 +156,13 @@ def show_remote_gems(name) if args.empty? matching_tuples else - decode_content_addressable_tuples(matching_tuples, latest: specs_type == :latest) + fetcher.decode_content_addressable_tuples(matching_tuples, latest: specs_type == :latest) end end output_query_results(spec_tuples) end - def decode_content_addressable_tuples(spec_tuples, latest: false) - spec_tuples.group_by {|_, source| source }.flat_map do |source, source_tuples| - next source_tuples unless source.respond_to?(:decode_content_addressable_tuples) - - tuples = source_tuples.map(&:first) - source.decode_content_addressable_tuples(tuples, latest: latest).map {|tuple| [tuple, source] } - end - end - def specs_type if options[:all] || options[:version].specific? if options[:prerelease] diff --git a/lib/rubygems/source.rb b/lib/rubygems/source.rb index 7b59230d46ef..5ce5d19e926d 100644 --- a/lib/rubygems/source.rb +++ b/lib/rubygems/source.rb @@ -240,12 +240,12 @@ def decode_content_addressable_tuples(tuples, latest: false) end ## - # The publish time of gem +name+ at +version+ for +platform+, when this + # 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 ||= {} @@ -255,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 @@ -269,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. @@ -403,7 +411,7 @@ def content_addressable_metadata(name, rows) 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_PLATFORM] + suffix = info_row[Gem::CompactIndexClient::INFO_SUFFIX] requirements = compact_index_requirements(info_row) platform = required_platform_from(requirements[:platform]) diff --git a/lib/rubygems/spec_fetcher.rb b/lib/rubygems/spec_fetcher.rb index 6f06b554d2c1..200e08f0e352 100644 --- a/lib/rubygems/spec_fetcher.rb +++ b/lib/rubygems/spec_fetcher.rb @@ -91,7 +91,8 @@ def search_for_dependency(dependency, matching_platform = true, type: nil) rejected_specs = {} - list, errors = available_specs(type || dependency.identity) + specs_type = type || dependency.identity + list, errors = available_specs(specs_type) list.each do |source, specs| if dependency.name.is_a?(String) && specs.respond_to?(:bsearch) @@ -100,17 +101,20 @@ def search_for_dependency(dependency, matching_platform = true, type: nil) specs = specs[start_index...end_index] if start_index && end_index end + specs = specs.select {|tup| dependency.match?(tup) } + specs = decode_source_content_addressable_tuples(source, specs, latest: specs_type == :latest) + found[source] = specs.select do |tup| - if dependency.match?(tup) - if matching_platform && !Gem::Platform.match_gem?(tup.platform, tup.name) - pm = ( - rejected_specs[dependency] ||= \ - Gem::PlatformMismatch.new(tup.name, tup.version)) - pm.add_platform tup.platform - false - else - true - end + if matching_platform && !Gem::Platform.match_gem?(tup.platform, tup.name) + pm = ( + rejected_specs[dependency] ||= \ + Gem::PlatformMismatch.new(tup.name, tup.version)) + pm.add_platform tup.platform + false + elsif matching_platform && !ruby_abi_match?(tup) + false + else + true end end end @@ -159,6 +163,7 @@ def spec_for_dependency(dependency, matching_platform = true) specs = [] tuples.each do |tup, source| spec = source.fetch_spec(tup) + spec.content_address = tup.content_address if tup.content_address rescue Gem::RemoteFetcher::FetchError => e errors << Gem::SourceFetchProblem.new(source, e) else @@ -168,6 +173,17 @@ def spec_for_dependency(dependency, matching_platform = true) [specs, errors] end + ## + # Decodes the content-addressable tuples in +spec_tuples+ ([tuple, source] + # pairs) to carry their real platform and Ruby ABI via each source. + + def decode_content_addressable_tuples(spec_tuples, latest: false) + spec_tuples.group_by {|_, source| source }.flat_map do |source, source_tuples| + tuples = source_tuples.map(&:first) + decode_source_content_addressable_tuples(source, tuples, latest: latest).map {|tuple| [tuple, source] } + end + end + ## # Suggests gems based on the supplied +gem_name+. Returns an array of # alternative gem names. @@ -290,4 +306,20 @@ def tuples_for(source, type, gracefully_ignore = false) # :nodoc: raise unless gracefully_ignore [] end + + private + + def decode_source_content_addressable_tuples(source, tuples, latest: false) # :nodoc: + return tuples unless source.respond_to?(:decode_content_addressable_tuples) + + source.decode_content_addressable_tuples(tuples, latest: latest) + end + + def ruby_abi_match?(tuple) # :nodoc: + !tuple.ruby_abi || tuple.ruby_abi == current_ruby_abi + end + + def current_ruby_abi # :nodoc: + @current_ruby_abi ||= Gem.ruby_version.segments.first(2).join(".") + end end diff --git a/spec/install/gemfile/content_addressable_spec.rb b/spec/install/gemfile/content_addressable_spec.rb index 9184079f4d19..c82bb7a08892 100644 --- a/spec/install/gemfile/content_addressable_spec.rb +++ b/spec/install/gemfile/content_addressable_spec.rb @@ -458,6 +458,10 @@ 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 diff --git a/test/rubygems/helper.rb b/test/rubygems/helper.rb index 8389dbc70f62..7c1fc80ee0d7 100644 --- a/test/rubygems/helper.rb +++ b/test/rubygems/helper.rb @@ -1058,6 +1058,31 @@ def util_gem(name, version, deps = nil, ruby_abi: nil, &block) [spec, cache_file] end + ## + # Builds a platform gem and serves it through compact index as a + # content-addressable gem. Returns the specification, gem path, and content + # address. + + def util_setup_content_addressable_compact_index_gem(name, version, platform: "x86_64-linux", required_ruby_version: ">= 3.0", &block) + spec, gem_path = util_gem(name, version) do |s| + s.platform = platform + s.required_ruby_version = required_ruby_version + yield(s) if block + end + + content_address = Digest::SHA256.file(gem_path).hexdigest[0, 8] + ca_gem_path = File.join(File.dirname(gem_path), "#{spec.name}-#{spec.version}-#{content_address}.gem") + FileUtils.cp gem_path, ca_gem_path + spec.content_address = content_address + + util_setup_compact_index spec + @fetcher.data["#{@gem_repo}quick/Marshal.#{Gem.marshal_version}/#{spec.full_name}.gemspec.rz"] = util_zip(Marshal.dump(spec)) + add_to_fetcher spec, ca_gem_path + Gem::SpecFetcher.fetcher = nil + + [spec, ca_gem_path, content_address] + end + ## # Gzips +data+. 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_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_update_command.rb b/test/rubygems/test_gem_commands_update_command.rb index 9d15406bd13f..fe278745fd71 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", "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_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_source.rb b/test/rubygems/test_gem_source.rb index 97731be4e9bd..2bbb55360765 100644 --- a/test/rubygems/test_gem_source.rb +++ b/test/rubygems/test_gem_source.rb @@ -334,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_spec_fetcher.rb b/test/rubygems/test_gem_spec_fetcher.rb index 1f7b5984c335..59d90a5cb5a7 100644 --- a/test/rubygems/test_gem_spec_fetcher.rb +++ b/test/rubygems/test_gem_spec_fetcher.rb @@ -122,6 +122,87 @@ def test_spec_for_dependency_platform spec_names end + def test_decode_content_addressable_tuples_decodes_source_tuples + spec_fetcher + + ca_spec = util_ca_spec "a", "1", "abcdef12", ruby_abi: "3.3" + util_setup_compact_index ca_spec + + ca_tuple = Gem::NameTuple.new("a", v(1), "abcdef12", content_address: "abcdef12") + ruby_tuple = tuple("b", v(1), "ruby") + + decoded = @sf.decode_content_addressable_tuples([[ca_tuple, @source], [ruby_tuple, @source]]) + + decoded_ca_tuple, decoded_ca_source = decoded.find {|decoded_tuple,| decoded_tuple.name == "a" } + decoded_ruby_tuple, decoded_ruby_source = decoded.find {|decoded_tuple,| decoded_tuple.name == "b" } + + assert_equal @source, decoded_ca_source + assert_equal "a-1-abcdef12", decoded_ca_tuple.full_name + assert_equal "x86_64-linux", decoded_ca_tuple.platform + assert_equal "abcdef12", decoded_ca_tuple.content_address + assert_equal "3.3", decoded_ca_tuple.ruby_abi + + assert_equal @source, decoded_ruby_source + assert_equal ruby_tuple, decoded_ruby_tuple + end + + def test_decode_content_addressable_tuples_does_not_decode_non_content_addressable_gems + source = Object.new + original = [[tuple("a", v(1), "ruby"), source]] + + assert_equal original, @sf.decode_content_addressable_tuples(original) + end + + def test_search_for_dependency_decodes_content_addressable_tuples + spec_fetcher + util_set_arch "x86_64-linux" + + ruby_abi = Gem.ruby_version.segments.first(2).join(".") + other_abi = "#{Gem.ruby_version.segments[0] + 1}.0" + compatible = util_ca_spec "a", "1", "abcdef12", ruby_abi: ruby_abi + incompatible = util_ca_spec "a", "1", "fedcba98", ruby_abi: other_abi + util_setup_compact_index compatible, incompatible + + tuples, errors = @sf.search_for_dependency Gem::Dependency.new("a") + + assert_empty errors + assert_equal 1, tuples.length + + tuple, source = tuples.first + assert_equal @source, source + assert_equal "a-1-abcdef12", tuple.full_name + assert_equal "x86_64-linux", tuple.platform + assert_equal "abcdef12", tuple.content_address + assert_equal ruby_abi, tuple.ruby_abi + end + + def test_spec_for_dependency_preserves_content_address_from_tuple + spec_fetcher + util_set_arch "x86_64-linux" + + ruby_abi = Gem.ruby_version.segments.first(2).join(".") + ca_spec = util_ca_spec "a", "1", "abcdef12", ruby_abi: ruby_abi + util_setup_compact_index ca_spec + + fetched_spec = util_spec "a", "1" do |s| + s.platform = "x86_64-linux" + s.required_ruby_version = ">= 3.0" + end + refute fetched_spec.content_address + @fetcher.data["#{@gem_repo}quick/Marshal.#{Gem.marshal_version}/#{ca_spec.spec_name}.rz"] = util_zip(Marshal.dump(fetched_spec)) + + dep = Gem::Dependency.new "a" + specs_and_sources, errors = @sf.spec_for_dependency dep + + assert_empty errors + assert_equal 1, specs_and_sources.length + + spec, source = specs_and_sources.first + assert_equal @source, source + assert_equal "abcdef12", spec.content_address + assert_equal "a-1-abcdef12", spec.full_name + end + def test_spec_for_dependency_mismatched_platform util_set_arch "hrpa-989" From f4a688120b971495b972de6304af15cd8d0fa866 Mon Sep 17 00:00:00 2001 From: Gira Chawda Date: Tue, 1 Sep 2026 10:23:42 -0400 Subject: [PATCH 09/17] Scope CA plugin stubs by Ruby ABI --- lib/rubygems.rb | 22 +++- lib/rubygems/installer_uninstaller_utils.rb | 24 ++++- lib/rubygems/spec_fetcher.rb | 2 +- test/rubygems/test_gem.rb | 79 +++++++++++++++ test/rubygems/test_gem_installer.rb | 107 ++++++++++++++++++++ 5 files changed, 228 insertions(+), 6 deletions(-) diff --git a/lib/rubygems.rb b/lib/rubygems.rb index 2835862e2c1a..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 diff --git a/lib/rubygems/installer_uninstaller_utils.rb b/lib/rubygems/installer_uninstaller_utils.rb index c5c2a52bab35..232ce6e634cd 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,18 @@ 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) + ruby_abi = spec.to_spec.ruby_abi if Gem::ContentAddress.match?(spec.content_address) + ruby_abi ? File.join(plugins_dir, ruby_abi) : plugins_dir + end + + def ruby_abi_plugin_dir_for(spec, plugins_dir) + ruby_abi = spec.to_spec.ruby_abi if Gem::ContentAddress.match?(spec.content_address) + File.join plugins_dir, ruby_abi || Gem.ruby_abi end end diff --git a/lib/rubygems/spec_fetcher.rb b/lib/rubygems/spec_fetcher.rb index 200e08f0e352..4006c4d3f6f3 100644 --- a/lib/rubygems/spec_fetcher.rb +++ b/lib/rubygems/spec_fetcher.rb @@ -320,6 +320,6 @@ def ruby_abi_match?(tuple) # :nodoc: end def current_ruby_abi # :nodoc: - @current_ruby_abi ||= Gem.ruby_version.segments.first(2).join(".") + @current_ruby_abi ||= Gem.ruby_abi end end 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_installer.rb b/test/rubygems/test_gem_installer.rb index 3fa8bafcbce7..001c10cb62c8 100644 --- a/test/rubygems/test_gem_installer.rb +++ b/test/rubygems/test_gem_installer.rb @@ -811,6 +811,88 @@ 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" @@ -851,6 +933,31 @@ def test_install_raises_when_content_address_is_not_carried_by_package assert_path_exist platform_gem_dir 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| From 7bf773ba05eb7c6fc11845d87fb29df1cd976757 Mon Sep 17 00:00:00 2001 From: Jenny Shen Date: Tue, 1 Sep 2026 17:52:11 -0400 Subject: [PATCH 10/17] Move content addresses to a separate CONTENT ADDRESSES lockfile section A content-addressable gem was locked inline as name (version-address) platform. Bundler 4.0 parses the token after the version as the platform, gets unknown, materializes name-version-unknown, and fails: it then either re-resolves and rewrites the lockfile or fails outright under frozen. Lockfiles are committed and read by many Bundler versions during co-publication, so the inline form breaks the exact clients the transition is supposed to protect. The spec line is now an ordinary platform pin, and the content address moves to its own section: GEM specs: nokogiri (1.19.4-x86_64-linux) CONTENT ADDRESSES nokogiri (1.19.4-x86_64-linux) 86e5e59f sha256= CHECKSUMS nokogiri (1.19.4-x86_64-linux) sha256= Verified against the released Bundler 4.0.9: - unknown unindented sections set @parse_method = nil and are skipped silently (lockfile_parser.rb:137) - Definition#lockfiles_equal? subtracts unknown sections before comparing (definition.rb:1196), so a plain 4.0 bundle install does not rewrite the lockfile and the section survives; it is only dropped on a genuine 4.0 re-lock, and restored on the next 4.1 re-lock - 4.0 classifies a locked spec with a missing or empty CHECKSUMS entry as a lockfile change (definition.rb:619-620): a plain install re-resolves and frozen mode hard-fails. The platform lock name line must therefore carry a real checksum, and it must be the platform build's, since 4.0 attributes it to the artifact it installs for that lock name That last point drives the checksum rules: - the checksum store is keyed by full name instead of lock name, because a content-addressable build and the platform build of the same name, version, and platform share a lock name while being different files. Store#register accepts anything responding to full_name and lock_name, so the parser registers CHECKSUMS entries under the name tuple parsed from the line rather than the (possibly content-addressed) spec object - the content-addressable build's checksum is serialized next to its address in CONTENT ADDRESSES, the only place older Bundler never parses, and verifies the downloaded gem via the existing install-time registration - CHECKSUMS carries the platform build's checksum, which the compact index supplies for every row the fetcher sees, so it is captured during resolution without downloading the platform gem - when no platform build exists (skinny-only publication), the CHECKSUMS line is omitted entirely rather than written bare, so every CHECKSUMS line describes an artifact installable by its lock name The section registers in SECTIONS_BY_VERSION_INTRODUCED under 4.1.0, and its line format requires a platform, since content addressing only applies to platformed gems. --- lib/bundler/checksum.rb | 65 ++++++++++++------- lib/bundler/lazy_specification.rb | 16 +++-- lib/bundler/lockfile_generator.rb | 26 +++++++- lib/bundler/lockfile_parser.rb | 56 ++++++++++++---- lib/bundler/rubygems_ext.rb | 2 - spec/bundler/lockfile_parser_spec.rb | 21 ++++-- .../gemfile/content_addressable_spec.rb | 62 ++++++++++++++++-- spec/other/ext_spec.rb | 11 ++-- spec/support/checksums.rb | 8 +-- 9 files changed, 198 insertions(+), 69 deletions(-) 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/lazy_specification.rb b/lib/bundler/lazy_specification.rb index cc62608252ea..41f861505b7b 100644 --- a/lib/bundler/lazy_specification.rb +++ b/lib/bundler/lazy_specification.rb @@ -119,13 +119,19 @@ 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}" - # Append the platform additionally for content-addressable gems that contain a SHA - # where the platform would otherwise be - out << " #{platform}" if Gem::ContentAddress.match?(content_address) && platform != Gem::Platform::RUBY - out << "\n" + out << " #{lock_name}\n" dependencies.sort_by(&:to_s).uniq.each do |dep| next if dep.type == :development diff --git a/lib/bundler/lockfile_generator.rb b/lib/bundler/lockfile_generator.rb index f2b9afb388f6..11874eeda4c0 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.match?(spec.content_address) + + 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.match?(spec.content_address) + + line end add_section("CHECKSUMS", checksums + bundler_checksum) diff --git a/lib/bundler/lockfile_parser.rb b/lib/bundler/lockfile_parser.rb index 160d5583d67f..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 @@ -264,12 +278,11 @@ def parse_checksum(line) checksums = $6 name = $2 version = $3 - content_address = $4 if Gem::ContentAddress.match?($4) - platform = $4 unless content_address + platform = $4 version = Gem::Version.new(version) platform = platform ? Gem::Platform.new(platform) : Gem::Platform::RUBY - name_tuple = Gem::NameTuple.new(name, version, platform, content_address: content_address) + name_tuple = Gem::NameTuple.new(name, version, platform) full_name = name_tuple.full_name spec = @specs[full_name] @@ -282,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 @@ -297,17 +333,11 @@ def parse_spec(line) if spaces.size == 4 # only load platform for non-dependency (spec) line - if Gem::ContentAddress.match?($4) && $6 && $6 != Gem::Platform::RUBY.to_s - content_address = $4 - platform = $6 - else - platform = $4 - content_address = $6 if Gem::ContentAddress.match?($6) - end + platform = $4 version = Gem::Version.new(version) platform = platform ? Gem::Platform.new(platform) : Gem::Platform::RUBY - @current_spec = LazySpecification.new(name, version, platform, @current_source, content_address: content_address, strict: @strict) + @current_spec = LazySpecification.new(name, version, platform, @current_source, strict: @strict) @current_source.add_dependency_names(name) @specs[@current_spec.full_name] = @current_spec diff --git a/lib/bundler/rubygems_ext.rb b/lib/bundler/rubygems_ext.rb index 623de1836e77..3d0994d7e286 100644 --- a/lib/bundler/rubygems_ext.rb +++ b/lib/bundler/rubygems_ext.rb @@ -464,8 +464,6 @@ def initialize(name, version, platform = Gem::Platform::RUBY, content_address: n end def lock_name - return "#{name} (#{version}-#{content_address})" if Gem::ContentAddress.match?(content_address) - if platform == Gem::Platform::RUBY "#{name} (#{version})" else diff --git a/spec/bundler/lockfile_parser_spec.rb b/spec/bundler/lockfile_parser_spec.rb index c54ae5d1fa92..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 @@ -151,7 +151,7 @@ GEM remote: https://rubygems.org/ specs: - mygem (1.0-abcdef1234) x86_64-linux + mygem (1.0-x86_64-linux) PLATFORMS x86_64-linux @@ -159,8 +159,11 @@ DEPENDENCIES mygem + CONTENT ADDRESSES + mygem (1.0-x86_64-linux) abcdef1234 sha256=abcdef1234f1315d7e7b7e8295184577cc4e969bad6156ac069d02d63f58d82e + CHECKSUMS - mygem (1.0-abcdef1234) sha256=814828c34f1315d7e7b7e8295184577cc4e969bad6156ac069d02d63f58d82e8 + mygem (1.0-x86_64-linux) sha256=814828c34f1315d7e7b7e8295184577cc4e969bad6156ac069d02d63f58d82e8 BUNDLED WITH 1.12.0.rc.2 @@ -172,9 +175,15 @@ 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 - checksums = subject.sources.first.checksum_store.to_lock(spec) - expect(checksums).to eq("#{spec.lock_name} sha256=814828c34f1315d7e7b7e8295184577cc4e969bad6156ac069d02d63f58d82e8") + 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 diff --git a/spec/install/gemfile/content_addressable_spec.rb b/spec/install/gemfile/content_addressable_spec.rb index c82bb7a08892..e786d83a115e 100644 --- a/spec/install/gemfile/content_addressable_spec.rb +++ b/spec/install/gemfile/content_addressable_spec.rb @@ -35,18 +35,24 @@ expect(cached_files.size).to eq(1), "expected exactly one cached gem file, found: #{cached_files}" expect(cached_files.first).to match(/mygem-1\.0-[0-9a-f]{8,64}\.gem$/) expect(default_bundle_path("cache", "mygem-1.0-x86_64-linux.gem")).not_to exist - expect(lockfile).to match(/^ mygem \(1\.0-[0-9a-f]{8,64}\) x86_64-linux$/) + + 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", content_address: content_address) + c.checksum(gem_repo2, "mygem", "1.0", "x86_64-linux") end expect(lockfile).to include(checksums.to_s) - expect(lockfile).to include("mygem (1.0-#{content_address}) sha256=#{digest}") - expect(lockfile).not_to include("mygem (1.0-x86_64-linux)") + expect(lockfile).not_to include("(1.0-#{content_address})") end end @@ -399,11 +405,13 @@ 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-#{mismatched_address}) x86_64-linux + mygem (1.0-x86_64-linux) PLATFORMS x86_64-linux @@ -411,8 +419,11 @@ DEPENDENCIES mygem + CONTENT ADDRESSES + mygem (1.0-x86_64-linux) #{mismatched_address} sha256=#{mismatched_checksum} + CHECKSUMS - mygem (1.0-#{mismatched_address}) sha256=#{mismatched_checksum} + mygem (1.0-x86_64-linux) sha256=#{fat_checksum} BUNDLED WITH #{Bundler::VERSION} @@ -423,7 +434,44 @@ expect(last_command).to be_failure expect(the_bundle).not_to include_gems "mygem 1.0 content_addressed" - expect(lockfile).to include("mygem (1.0-#{mismatched_address}) x86_64-linux") + 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 diff --git a/spec/other/ext_spec.rb b/spec/other/ext_spec.rb index 056371f5eb80..ca6d8236132b 100644 --- a/spec/other/ext_spec.rb +++ b/spec/other/ext_spec.rb @@ -47,19 +47,20 @@ expect(Gem::NameTuple.new("a", v("1.0.0")).lock_name).to eq("a (1.0.0)") end - it "uses content_address in the lock name when set" do - expect(Gem::NameTuple.new("a", v("1.0.0"), "x86_64-linux", content_address: "abcdef12").lock_name).to eq("a (1.0.0-abcdef12)") - expect(Gem::NameTuple.new("a", v("1.0.0"), "ruby", content_address: "abcdef12").lock_name).to eq("a (1.0.0-abcdef12)") + 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 "appends the content address after the platform lock name when set" 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-abcdef1234) x86_64-linux\n") + 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/checksums.rb b/spec/support/checksums.rb index 638c9cf421f3..7b69bba6680b 100644 --- a/spec/support/checksums.rb +++ b/spec/support/checksums.rb @@ -16,18 +16,18 @@ def initialize_copy(original) @checksums = @checksums.dup end - def checksum(repo, name, version, platform = Gem::Platform::RUBY, folder = "gems", content_address: nil) + def checksum(repo, name, version, platform = Gem::Platform::RUBY, folder = "gems") @bundler_registered = true if name == "bundler" - name_tuple = Gem::NameTuple.new(name, version, platform, content_address: content_address) + name_tuple = Gem::NameTuple.new(name, version, platform) gem_file = File.join(repo, folder, "#{name_tuple.full_name}.gem") File.open(gem_file, "rb") do |f| register(name_tuple, Bundler::Checksum.from_gem(f, "#{gem_file} (via ChecksumsBuilder#checksum)")) end end - def no_checksum(name, version, platform = Gem::Platform::RUBY, content_address: nil) - name_tuple = Gem::NameTuple.new(name, version, platform, content_address: content_address) + def no_checksum(name, version, platform = Gem::Platform::RUBY) + name_tuple = Gem::NameTuple.new(name, version, platform) register(name_tuple, nil) end From a8ac2ed87f352d0dcf321181e5a591bf613b3b6e Mon Sep 17 00:00:00 2001 From: Jenny Shen Date: Wed, 2 Sep 2026 14:43:45 -0400 Subject: [PATCH 11/17] Centralize content addressing semantics in Gem::ContentAddress and route every eligibility, naming, lockfile, plugin directory, and spec construction decision through its shared predicates --- lib/bundler/endpoint_specification.rb | 2 +- lib/bundler/lazy_specification.rb | 2 +- lib/bundler/lockfile_generator.rb | 4 +- lib/bundler/match_platform.rb | 2 +- lib/bundler/remote_specification.rb | 2 +- lib/bundler/rubygems_ext.rb | 8 +- lib/rubygems/commands/push_command.rb | 2 +- lib/rubygems/content_address.rb | 175 ++++++++++-- lib/rubygems/installer_uninstaller_utils.rb | 11 +- lib/rubygems/package.rb | 31 +-- lib/rubygems/resolver/api_specification.rb | 4 +- lib/rubygems/source.rb | 12 +- lib/rubygems/specification.rb | 10 +- lib/rubygems/stub_specification.rb | 2 +- lib/rubygems/version_option.rb | 2 +- spec/bundler/endpoint_specification_spec.rb | 20 +- spec/bundler/lockfile_generator_spec.rb | 41 +++ spec/bundler/remote_specification_spec.rb | 16 ++ .../gemfile/content_addressable_spec.rb | 1 + .../artifice/helpers/compact_index_v2.rb | 2 +- test/rubygems/helper.rb | 2 +- test/rubygems/test_gem_content_address.rb | 253 +++++++++++++++++- test/rubygems/test_gem_installer.rb | 49 +++- .../test_gem_resolver_api_specification.rb | 35 ++- test/rubygems/test_gem_spec_fetcher.rb | 2 +- test/rubygems/test_gem_specification.rb | 4 +- 26 files changed, 589 insertions(+), 105 deletions(-) create mode 100644 spec/bundler/lockfile_generator_spec.rb diff --git a/lib/bundler/endpoint_specification.rb b/lib/bundler/endpoint_specification.rb index f18b63220f02..479e3f7b0a3c 100644 --- a/lib/bundler/endpoint_specification.rb +++ b/lib/bundler/endpoint_specification.rb @@ -25,7 +25,7 @@ def initialize(name, version, suffix, spec_fetcher, dependencies, metadata = nil parse_metadata(metadata) - if Gem::ContentAddress.match?(suffix) && @required_platform + if Gem::ContentAddress.content_addressed_row?(suffix, @required_platform, @required_ruby_version) @content_address = suffix @platform = @required_platform @required_rubygems_version ||= Gem::Requirement.default diff --git a/lib/bundler/lazy_specification.rb b/lib/bundler/lazy_specification.rb index 41f861505b7b..409249aea938 100644 --- a/lib/bundler/lazy_specification.rb +++ b/lib/bundler/lazy_specification.rb @@ -66,7 +66,7 @@ def source_changed? end def full_name - @full_name ||= if Gem::ContentAddress.match?(@content_address) && 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}" diff --git a/lib/bundler/lockfile_generator.rb b/lib/bundler/lockfile_generator.rb index 11874eeda4c0..5fecaf1bbf39 100644 --- a/lib/bundler/lockfile_generator.rb +++ b/lib/bundler/lockfile_generator.rb @@ -69,7 +69,7 @@ def add_dependencies def add_content_addresses content_addresses = definition.resolve.filter_map do |spec| - next unless Gem::ContentAddress.match?(spec.content_address) + next unless Gem::ContentAddress.content_addressed?(spec, validate_ruby_abi: false) line = "#{spec.lock_name} #{spec.content_address}" @@ -89,7 +89,7 @@ def add_checksums checksums = definition.resolve.filter_map do |spec| line = spec.source.checksum_store.to_lock(spec) - next if line == spec.lock_name && Gem::ContentAddress.match?(spec.content_address) + next if line == spec.lock_name && Gem::ContentAddress.content_addressed?(spec, validate_ruby_abi: false) line end diff --git a/lib/bundler/match_platform.rb b/lib/bundler/match_platform.rb index c07253bfafd0..566067c905a0 100644 --- a/lib/bundler/match_platform.rb +++ b/lib/bundler/match_platform.rb @@ -27,7 +27,7 @@ def self.select_all_content_address_match(specs, content_address) end def self.prefer_content_addressable(matching) - addressable, non_addressable = matching.partition {|s| Gem::ContentAddress.match?(s.content_address) } + 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?) diff --git a/lib/bundler/remote_specification.rb b/lib/bundler/remote_specification.rb index bf899693f536..cc6372e9e58e 100644 --- a/lib/bundler/remote_specification.rb +++ b/lib/bundler/remote_specification.rb @@ -36,7 +36,7 @@ def fetch_platform end def full_name - @full_name ||= if Gem::ContentAddress.match?(@content_address) && @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}" diff --git a/lib/bundler/rubygems_ext.rb b/lib/bundler/rubygems_ext.rb index 3d0994d7e286..becbb944cfb3 100644 --- a/lib/bundler/rubygems_ext.rb +++ b/lib/bundler/rubygems_ext.rb @@ -31,11 +31,15 @@ def self.match?(token) false end - def self.applicable?(spec) + def self.eligible?(spec, validate_ruby_abi: true) false end - def self.content_addressed?(spec) + 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 diff --git a/lib/rubygems/commands/push_command.rb b/lib/rubygems/commands/push_command.rb index b4dbf5881ee8..5ac3ca4234cb 100644 --- a/lib/rubygems/commands/push_command.rb +++ b/lib/rubygems/commands/push_command.rb @@ -122,7 +122,7 @@ def resolve_gem_name(names) matches = candidates.select do |_, spec| (!platform || spec.platform == platform) && - (!ruby_abi || (Gem::ContentAddress.applicable?(spec) && spec.ruby_abi == ruby_abi)) + (!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? diff --git a/lib/rubygems/content_address.rb b/lib/rubygems/content_address.rb index 5ed8a86d313f..6587086557ee 100644 --- a/lib/rubygems/content_address.rb +++ b/lib/rubygems/content_address.rb @@ -1,40 +1,179 @@ # frozen_string_literal: true ## -# Gem::ContentAddress encapsulates the pattern for recognizing -# content-addressable gem file names. - +# 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 - # :nodoc: + ## + # 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/ ## - # Whether +spec+ is eligible for content addressing. A gem must - # pin a required_ruby_version and declare a non-RUBY platform to be - # content addressed. + # A Ruby ABI is a major and minor version pair ("X.Y"). - def self.applicable?(spec) - required_ruby_version = spec.required_ruby_version - !required_ruby_version.nil? && !required_ruby_version.none? && - !spec.platform.nil? && spec.platform != Gem::Platform::RUBY - end + RUBY_ABI_PATTERN = /\A\d+\.\d+\z/ + + private_constant :PATTERN, :RUBY_ABI_PATTERN ## - # Whether +spec+ is content-addressed: it is eligible for content - # addressing and has a valid content address set. + # Default number of hexadecimal characters in a generated content address. - def self.content_addressed?(spec) - applicable?(spec) && match?(spec.content_address) - end + DEFAULT_LENGTH = 8 ## # Whether +value+ is a valid content address (a string of 8-64 - # lowercase hexadecimal characters). + # 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 + return nil if op != "~>" || version.segments.size != 3 || version.segments[2] != 0 + + version.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 diff --git a/lib/rubygems/installer_uninstaller_utils.rb b/lib/rubygems/installer_uninstaller_utils.rb index 232ce6e634cd..783ec8ac5754 100644 --- a/lib/rubygems/installer_uninstaller_utils.rb +++ b/lib/rubygems/installer_uninstaller_utils.rb @@ -34,12 +34,15 @@ def remove_plugins_for(spec, plugins_dir) private def plugin_stub_dir_for(spec, plugins_dir) - ruby_abi = spec.to_spec.ruby_abi if Gem::ContentAddress.match?(spec.content_address) - ruby_abi ? File.join(plugins_dir, ruby_abi) : 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) - ruby_abi = spec.to_spec.ruby_abi if Gem::ContentAddress.match?(spec.content_address) - File.join plugins_dir, ruby_abi || Gem.ruby_abi + 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/package.rb b/lib/rubygems/package.rb index ce4703023f46..2bba85cbd805 100644 --- a/lib/rubygems/package.rb +++ b/lib/rubygems/package.rb @@ -129,12 +129,6 @@ class TarInvalidError < Error; end # Permission for other files attr_accessor :data_mode - ## - # The number of characters of the SHA-256 digest of the gem contents used - # in a content-addressable gem file name. - - DEFAULT_CONTENT_ADDRESS_LENGTH = 8 - ## # The minimum RubyGems version that can install content-addressable gems. # Built into +required_rubygems_version+ so older clients reject skinny @@ -262,18 +256,11 @@ def content_address path = @gem&.path return unless path - return nil unless Gem::ContentAddress.applicable?(spec) - - filename = File.basename(path, ".gem") - base = "#{spec.name}-#{spec.version}" - suffix = filename.delete_prefix("#{base}-") - return nil if suffix == filename - return nil unless Gem::ContentAddress.match?(suffix) + address = Gem::ContentAddress.verified_file_name_claim(path, spec) + return nil unless address + return nil unless Gem::ContentAddress.eligible?(spec) - require "digest" - digest = Digest::SHA256.file(path).hexdigest - raise Gem::InstallError, "content address mismatch for #{File.basename(path)}" unless digest.start_with?(suffix) - suffix + address end ## @@ -401,12 +388,12 @@ def build(skip_validation = false, strict_validation = false) 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::Requirement.new("~> #{ruby_abi}.0") + @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}-#{Digest::SHA256.hexdigest(bytes)[0, DEFAULT_CONTENT_ADDRESS_LENGTH]}.gem" + gem_file = "#{@spec.name}-#{@spec.version}-#{Gem::ContentAddress.address_for(bytes)}.gem" File.binwrite(gem_file, bytes) say " File: #{gem_file}" @@ -809,11 +796,11 @@ def satisfies_rubygems_floor?(requirement, floor) # the ABI. def validate_ruby_abi(ruby_abi) - if !/\A\d+\.\d+\z/.match?(ruby_abi) + if !Gem::ContentAddress.valid_ruby_abi?(ruby_abi) raise ArgumentError, "Ruby ABI must be in X.Y format" - elsif @spec.platform.nil? || @spec.platform == Gem::Platform::RUBY + 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 @spec.required_ruby_version && @spec.required_ruby_version != Gem::Requirement.default && @spec.ruby_abi != ruby_abi + 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 diff --git a/lib/rubygems/resolver/api_specification.rb b/lib/rubygems/resolver/api_specification.rb index a37b7ef417d5..377b2428d5b2 100644 --- a/lib/rubygems/resolver/api_specification.rb +++ b/lib/rubygems/resolver/api_specification.rb @@ -33,13 +33,13 @@ def initialize(set, api_data) @set = set @name = api_data[:name] @version = Gem::Version.new(api_data[:number]).freeze - assign_platform(api_data) @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: @@ -120,7 +120,7 @@ def assign_platform(api_data) suffix = api_data[:suffix] required_platform = required_platform_from(api_data.dig(:requirements, :platform)) - if Gem::ContentAddress.match?(suffix) && required_platform + 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 diff --git a/lib/rubygems/source.rb b/lib/rubygems/source.rb index 5ce5d19e926d..94333bfc3ae8 100644 --- a/lib/rubygems/source.rb +++ b/lib/rubygems/source.rb @@ -418,7 +418,8 @@ def content_addressable_metadata(name, rows) next unless platform next unless requirements[:ruby] - ContentAddressableInfo.new(version, suffix, ruby_abi_from(requirements[:ruby]), platform) + ruby_abi = Gem::ContentAddress.ruby_abi_for(Gem::Requirement.new(requirements[:ruby])) + ContentAddressableInfo.new(version, suffix, ruby_abi, platform) end available_rows & wanted_rows @@ -438,15 +439,6 @@ def required_platform_from(requirement) platform end - def ruby_abi_from(requirement) - Array(requirement).each do |ruby_requirement| - match = ruby_requirement.to_s.match(/\A~>\s*(\d+)\.(\d+)\.0\z/) - return "#{match[1]}.#{match[2]}" if match - end - - nil - end - def max_versions_by_platform(tuples) grouped_tuples = tuples.group_by {|tuple| latest_platform_key(tuple) } grouped_tuples.map do |_, platform_tuples| diff --git a/lib/rubygems/specification.rb b/lib/rubygems/specification.rb index a3018912a041..036c37adbf98 100644 --- a/lib/rubygems/specification.rb +++ b/lib/rubygems/specification.rb @@ -581,15 +581,7 @@ def add_dependency(gem, *requirements) # Returns nil if the required_ruby_version does not specify a single Ruby ABI def ruby_abi - return nil if required_ruby_version.nil? || required_ruby_version == Gem::Requirement.default - - requirements = required_ruby_version.requirements - return nil if requirements.size != 1 - - op, version = requirements.first - return nil if op != "~>" || version.segments.size != 3 || version.segments[2] != 0 - - version.segments[0..1].join(".") + Gem::ContentAddress.ruby_abi_for(required_ruby_version) end ## diff --git a/lib/rubygems/stub_specification.rb b/lib/rubygems/stub_specification.rb index 825f1ec11d5e..e2257290bcf9 100644 --- a/lib/rubygems/stub_specification.rb +++ b/lib/rubygems/stub_specification.rb @@ -49,7 +49,7 @@ def initialize(data, extensions, target = NO_TARGET) suffix = parts[2] target_platform = target["platform"] @platform = Gem::Platform.new(target_platform || suffix) - @content_address = suffix if target_platform && Gem::ContentAddress.match?(suffix) + @content_address = suffix if Gem::ContentAddress.content_addressed_row?(suffix, target_platform, validate_ruby_abi: false) @extensions = extensions @full_name = if @content_address "#{name}-#{version}-#{content_address}" diff --git a/lib/rubygems/version_option.rb b/lib/rubygems/version_option.rb index 3da18dd98253..b7423bb9927e 100644 --- a/lib/rubygems/version_option.rb +++ b/lib/rubygems/version_option.rb @@ -52,7 +52,7 @@ def add_prerelease_option(*wrap) def add_ruby_abi_option(task = command, *wrap) add_option("--ruby-abi RUBY_ABI", "Specify the Ruby ABI of gem to #{task}", *wrap) do |value, options| - unless /\A\d+\.\d+\z/.match?(value) + unless Gem::ContentAddress.valid_ruby_abi?(value) raise Gem::OptionParser::InvalidArgument, "#{value}: Ruby ABI must be in X.Y format" end diff --git a/spec/bundler/endpoint_specification_spec.rb b/spec/bundler/endpoint_specification_spec.rb index 26e049899087..d139d7fe7d60 100644 --- a/spec/bundler/endpoint_specification_spec.rb +++ b/spec/bundler/endpoint_specification_spec.rb @@ -46,7 +46,7 @@ def with_tz(tz) describe "#parse_metadata" do context "when a content-addressed suffix has platform metadata" do let(:suffix) { "abc1234567" } - let(:metadata) { { "platform" => ["= arm64-darwin"], "ruby" => [">= 3.0.0"] } } + 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")) @@ -75,6 +75,24 @@ def with_tz(tz) 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 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/remote_specification_spec.rb b/spec/bundler/remote_specification_spec.rb index fa794b7d3d80..6ee58f2cbef1 100644 --- a/spec/bundler/remote_specification_spec.rb +++ b/spec/bundler/remote_specification_spec.rb @@ -48,6 +48,22 @@ 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 diff --git a/spec/install/gemfile/content_addressable_spec.rb b/spec/install/gemfile/content_addressable_spec.rb index e786d83a115e..31ab0f39626e 100644 --- a/spec/install/gemfile/content_addressable_spec.rb +++ b/spec/install/gemfile/content_addressable_spec.rb @@ -480,6 +480,7 @@ 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 diff --git a/spec/support/artifice/helpers/compact_index_v2.rb b/spec/support/artifice/helpers/compact_index_v2.rb index 116a1e39e1d5..9f8bb853f57d 100644 --- a/spec/support/artifice/helpers/compact_index_v2.rb +++ b/spec/support/artifice/helpers/compact_index_v2.rb @@ -21,7 +21,7 @@ def content_addressable_specs(gem_repo) next unless Gem::ContentAddress.match?(token) spec = Gem::Package.new(file).spec - next unless Gem::ContentAddress.applicable?(spec) + next unless Gem::ContentAddress.eligible?(spec) spec.content_address = token spec end diff --git a/test/rubygems/helper.rb b/test/rubygems/helper.rb index 7c1fc80ee0d7..68ffd1509f59 100644 --- a/test/rubygems/helper.rb +++ b/test/rubygems/helper.rb @@ -1063,7 +1063,7 @@ def util_gem(name, version, deps = nil, ruby_abi: nil, &block) # content-addressable gem. Returns the specification, gem path, and content # address. - def util_setup_content_addressable_compact_index_gem(name, version, platform: "x86_64-linux", required_ruby_version: ">= 3.0", &block) + 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 diff --git a/test/rubygems/test_gem_content_address.rb b/test/rubygems/test_gem_content_address.rb index a935946304b8..e06d4687b06b 100644 --- a/test/rubygems/test_gem_content_address.rb +++ b/test/rubygems/test_gem_content_address.rb @@ -26,50 +26,121 @@ def test_match_rejects_uppercase refute Gem::ContentAddress.match?("ABCDEF12") end - def test_applicable_with_required_ruby_version_and_platform + 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" - assert Gem::ContentAddress.applicable?(spec) + refute Gem::ContentAddress.eligible?(spec) end - def test_applicable_without_required_ruby_version + def test_eligible_without_required_ruby_version spec = Gem::Specification.new "a", 1 spec.platform = "x86_64-linux" - refute Gem::ContentAddress.applicable?(spec) + refute Gem::ContentAddress.eligible?(spec) end - def test_applicable_with_ruby_platform + def test_eligible_with_ruby_platform spec = Gem::Specification.new "a", 1 - spec.required_ruby_version = ">= 3.0" - refute Gem::ContentAddress.applicable?(spec) + spec.required_ruby_version = "~> 3.4.0" + refute Gem::ContentAddress.eligible?(spec) end - def test_applicable_with_nil_platform + def test_eligible_with_nil_platform spec = Gem::Specification.new "a", 1 - spec.required_ruby_version = ">= 3.0" + spec.required_ruby_version = "~> 3.4.0" spec.platform = nil - refute Gem::ContentAddress.applicable?(spec) + refute Gem::ContentAddress.eligible?(spec) end def test_content_addressed_with_eligible_spec_and_valid_address spec = Gem::Specification.new "a", 1 - spec.required_ruby_version = ">= 3.0" + spec.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_eligible_spec_and_no_address + 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.0" + spec.required_ruby_version = "~> 3.4.0" spec.platform = "x86_64-linux" spec.content_address = "x86_64-linux" refute Gem::ContentAddress.content_addressed?(spec) @@ -81,6 +152,162 @@ def test_content_addressed_with_ineligible_spec_and_valid_address 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" diff --git a/test/rubygems/test_gem_installer.rb b/test/rubygems/test_gem_installer.rb index 001c10cb62c8..8b20b034190c 100644 --- a/test/rubygems/test_gem_installer.rb +++ b/test/rubygems/test_gem_installer.rb @@ -933,6 +933,39 @@ def test_install_raises_when_content_address_is_not_carried_by_package 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 @@ -1180,7 +1213,7 @@ def test_install_dir_takes_precedence_to_user_install def test_install_assigns_content_address_from_filename _, a_gem = util_gem("a", 2) do |spec| - spec.required_ruby_version = ">= 3.0" + spec.required_ruby_version = "~> 3.4.0" spec.platform = "x86_64-linux" end @@ -1203,7 +1236,7 @@ def test_install_assigns_content_address_from_filename def test_install_raises_for_mismatched_content_address _, a_gem = util_gem("a", 2) do |spec| - spec.required_ruby_version = ">= 3.0" + spec.required_ruby_version = "~> 3.4.0" spec.platform = "x86_64-linux" end dir = File.dirname(a_gem) @@ -1282,7 +1315,7 @@ def test_hex_suffix_without_matching_spec_prefix_is_not_content_addressed def test_content_address_not_set_with_only_required_ruby_version _, a_gem = util_gem("a", 2) do |spec| - spec.required_ruby_version = ">= 3.0" + spec.required_ruby_version = "~> 3.4.0" end digest = Digest::SHA256.file(a_gem).hexdigest address = digest[0, 8] @@ -1313,7 +1346,7 @@ def test_content_address_not_set_with_only_platform def test_require_works_after_content_addressed_install source_spec, a_gem = util_gem("a", 2) do |spec| spec.files = ["lib/ca_activation_test.rb"] - spec.required_ruby_version = ">= 3.0" + spec.required_ruby_version = "~> 3.4.0" spec.platform = "x86_64-linux" end FileUtils.rm_rf source_spec.gem_dir @@ -1334,7 +1367,7 @@ def test_require_works_after_content_addressed_install def test_reinstalling_content_addressed_gem_is_idempotent source_spec, a_gem = util_gem("a", 2) do |spec| - spec.required_ruby_version = ">= 3.0" + spec.required_ruby_version = "~> 3.4.0" spec.platform = "x86_64-linux" end FileUtils.rm_rf source_spec.gem_dir @@ -1359,7 +1392,7 @@ def test_reinstalling_content_addressed_gem_is_idempotent def test_install_assigns_content_address_from_filename_with_full_sha _, a_gem = util_gem("a", 2) do |spec| - spec.required_ruby_version = ">= 3.0" + spec.required_ruby_version = "~> 3.4.0" spec.platform = "x86_64-linux" end @@ -1379,7 +1412,7 @@ def test_install_assigns_content_address_from_filename_with_full_sha def test_two_content_addressed_gems_with_same_name_version_coexist _, gem1 = util_gem("a", 2) do |spec| - spec.required_ruby_version = ">= 3.0" + spec.required_ruby_version = "~> 3.4.0" spec.platform = "x86_64-linux" spec.summary = "variant 1" end @@ -1387,7 +1420,7 @@ def test_two_content_addressed_gems_with_same_name_version_coexist FileUtils.cp gem1, gem1_backup _, gem2 = util_gem("a", 2) do |spec| - spec.required_ruby_version = ">= 3.0" + spec.required_ruby_version = "~> 3.4.0" spec.platform = "x86_64-linux" spec.summary = "variant 2" end diff --git a/test/rubygems/test_gem_resolver_api_specification.rb b/test/rubygems/test_gem_resolver_api_specification.rb index d3fe3917ed0e..c2028348777f 100644 --- a/test/rubygems/test_gem_resolver_api_specification.rb +++ b/test/rubygems/test_gem_resolver_api_specification.rb @@ -37,7 +37,7 @@ def test_initialize_content_address number: "3.0.3", suffix: "abc1234567", dependencies: [], - requirements: { platform: ["= #{Gem::Platform.local}"] }, + requirements: { platform: ["= #{Gem::Platform.local}"], ruby: ["~> 3.4.0"] }, } spec = Gem::Resolver::APISpecification.new set, data @@ -48,6 +48,36 @@ def test_initialize_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}"] }, + } + + 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}"], 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 = { @@ -75,6 +105,7 @@ def test_content_addressed_specs_with_different_addresses_are_distinct requirements: { platform: ["= #{Gem::Platform.local}"] }, } + data[:requirements][:ruby] = ["~> 3.4.0"] first = Gem::Resolver::APISpecification.new set, data second = Gem::Resolver::APISpecification.new set, data.merge(suffix: "def1234567") @@ -226,7 +257,7 @@ def test_fetch_development_dependencies_for_content_addressed_spec number: "3.0.3", suffix: "abc1234567", dependencies: [], - requirements: { platform: ["= #{Gem::Platform.local}"] }, + requirements: { platform: ["= #{Gem::Platform.local}"], ruby: ["~> 3.4.0"] }, } spec = Gem::Resolver::APISpecification.new set, data diff --git a/test/rubygems/test_gem_spec_fetcher.rb b/test/rubygems/test_gem_spec_fetcher.rb index 59d90a5cb5a7..e97171965260 100644 --- a/test/rubygems/test_gem_spec_fetcher.rb +++ b/test/rubygems/test_gem_spec_fetcher.rb @@ -186,7 +186,7 @@ def test_spec_for_dependency_preserves_content_address_from_tuple fetched_spec = util_spec "a", "1" do |s| s.platform = "x86_64-linux" - s.required_ruby_version = ">= 3.0" + 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)) diff --git a/test/rubygems/test_gem_specification.rb b/test/rubygems/test_gem_specification.rb index 9757885f251e..1e6be0c3e99c 100644 --- a/test/rubygems/test_gem_specification.rb +++ b/test/rubygems/test_gem_specification.rb @@ -1968,7 +1968,7 @@ def test_full_name def test_content_addressable_full_name @a1 = Gem::Specification.new "a", 1 - @a1.required_ruby_version = ">= 3.0" + @a1.required_ruby_version = "~> 3.4.0" @a1.platform = "x86_64-linux" @a1.content_address = "abcdef12" assert_equal "a-1-abcdef12", @a1.full_name @@ -2411,7 +2411,7 @@ def test_to_ruby def test_to_ruby_content_addressable spec = Gem::Specification.new "a", 1 - spec.required_ruby_version = ">= 3.0" + spec.required_ruby_version = "~> 3.4.0" spec.platform = "x86_64-linux" spec.content_address = "abcdef12" spec.extensions = ["ext/a/extconf.rb"] From 597fc8dc43775b22b912064b7132ffaff560841d Mon Sep 17 00:00:00 2001 From: Jenny Shen Date: Thu, 3 Sep 2026 14:17:38 -0400 Subject: [PATCH 12/17] Install content-addressable gemspecs under specifications// --- lib/bundler/endpoint_specification.rb | 3 +- lib/bundler/rubygems_ext.rb | 15 +++ lib/bundler/rubygems_integration.rb | 2 +- lib/rubygems/commands/lock_command.rb | 4 +- lib/rubygems/content_address.rb | 6 +- lib/rubygems/installer.rb | 39 +++++- lib/rubygems/package.rb | 2 +- lib/rubygems/request_set.rb | 6 +- lib/rubygems/resolver/lock_specification.rb | 6 +- lib/rubygems/specification.rb | 23 +++- lib/rubygems/specification_record.rb | 43 ++++++- .../gemfile/content_addressable_spec.rb | 5 +- .../test_gem_commands_pristine_command.rb | 31 +++++ .../test_gem_commands_update_command.rb | 2 +- test/rubygems/test_gem_content_address.rb | 4 + test/rubygems/test_gem_installer.rb | 86 +++++++++++-- test/rubygems/test_gem_specification.rb | 115 ++++++++++++++++++ test/rubygems/test_gem_uninstaller.rb | 28 +++++ 18 files changed, 385 insertions(+), 35 deletions(-) diff --git a/lib/bundler/endpoint_specification.rb b/lib/bundler/endpoint_specification.rb index 479e3f7b0a3c..a6c9e72f2805 100644 --- a/lib/bundler/endpoint_specification.rb +++ b/lib/bundler/endpoint_specification.rb @@ -161,7 +161,8 @@ def _remote_specification 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) diff --git a/lib/bundler/rubygems_ext.rb b/lib/bundler/rubygems_ext.rb index becbb944cfb3..730d1e522b4c 100644 --- a/lib/bundler/rubygems_ext.rb +++ b/lib/bundler/rubygems_ext.rb @@ -45,6 +45,21 @@ def self.content_addressed_row?(suffix, platform, required_ruby_version = nil, v 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? diff --git a/lib/bundler/rubygems_integration.rb b/lib/bundler/rubygems_integration.rb index 06d17d0cacdc..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 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/content_address.rb b/lib/rubygems/content_address.rb index 6587086557ee..e815c45191c0 100644 --- a/lib/rubygems/content_address.rb +++ b/lib/rubygems/content_address.rb @@ -53,9 +53,11 @@ def self.ruby_abi_for(required_ruby_version) return nil if requirements.size != 1 op, version = requirements.first - return nil if op != "~>" || version.segments.size != 3 || version.segments[2] != 0 + 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) - version.segments[0..1].join(".") + segments[0..1].join(".") end ## diff --git a/lib/rubygems/installer.rb b/lib/rubygems/installer.rb index 7d41be94d95d..b8d95df7ae7b 100644 --- a/lib/rubygems/installer.rb +++ b/lib/rubygems/installer.rb @@ -308,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 @@ -357,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 @@ -395,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 @@ -419,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 ## @@ -973,6 +996,10 @@ 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] diff --git a/lib/rubygems/package.rb b/lib/rubygems/package.rb index 2bba85cbd805..8e4ab4a4b8a9 100644 --- a/lib/rubygems/package.rb +++ b/lib/rubygems/package.rb @@ -556,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 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/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/specification.rb b/lib/rubygems/specification.rb index 036c37adbf98..e00a70a430b5 100644 --- a/lib/rubygems/specification.rb +++ b/lib/rubygems/specification.rb @@ -1263,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 @@ -2026,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 @@ -2328,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 ## 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/spec/install/gemfile/content_addressable_spec.rb b/spec/install/gemfile/content_addressable_spec.rb index 31ab0f39626e..8493c89cc203 100644 --- a/spec/install/gemfile/content_addressable_spec.rb +++ b/spec/install/gemfile/content_addressable_spec.rb @@ -536,7 +536,10 @@ expect(the_bundle).to include_gems "mygem 1.0 content_addressed" - installed_gemspec = Dir[default_bundle_path("specifications", "mygem-1.0-*.gemspec").to_s].first + 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")) 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_update_command.rb b/test/rubygems/test_gem_commands_update_command.rb index fe278745fd71..fc42baa4a7bf 100644 --- a/test/rubygems/test_gem_commands_update_command.rb +++ b/test/rubygems/test_gem_commands_update_command.rb @@ -72,7 +72,7 @@ def test_execute_content_addressable_compact_index_gem assert_equal "Gems updated: ca_update", out.shift assert_empty out - assert_path_exist File.join(@gemhome, "specifications", "ca_update-1.0.0-#{content_address}.gemspec") + assert_path_exist File.join(@gemhome, "specifications", Gem.ruby_abi, "ca_update-1.0.0-#{content_address}.gemspec") end end diff --git a/test/rubygems/test_gem_content_address.rb b/test/rubygems/test_gem_content_address.rb index e06d4687b06b..868d4ad6866d 100644 --- a/test/rubygems/test_gem_content_address.rb +++ b/test/rubygems/test_gem_content_address.rb @@ -115,6 +115,10 @@ def test_eligible_with_nil_platform 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" diff --git a/test/rubygems/test_gem_installer.rb b/test/rubygems/test_gem_installer.rb index 8b20b034190c..162f8dc347a9 100644 --- a/test/rubygems/test_gem_installer.rb +++ b/test/rubygems/test_gem_installer.rb @@ -1231,7 +1231,8 @@ def test_install_assigns_content_address_from_filename assert_equal "a-2-#{address}", spec.full_name assert_path_exist File.join(@gemhome, "gems", "a-2-#{address}") assert_path_exist File.join(@gemhome, "cache", "a-2-#{address}.gem") - assert_path_exist File.join(@gemhome, "specifications", "a-2-#{address}.gemspec") + 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 @@ -1346,8 +1347,8 @@ def test_content_address_not_set_with_only_platform def test_require_works_after_content_addressed_install source_spec, a_gem = util_gem("a", 2) do |spec| spec.files = ["lib/ca_activation_test.rb"] - spec.required_ruby_version = "~> 3.4.0" - spec.platform = "x86_64-linux" + spec.required_ruby_version = "~> #{Gem.ruby_abi}.0" + spec.platform = Gem::Platform.local.to_s end FileUtils.rm_rf source_spec.gem_dir @@ -1385,9 +1386,9 @@ def test_reinstalling_content_addressed_gem_is_idempotent assert_equal "a-2-#{address}", spec2.full_name assert_path_exist File.join(@gemhome, "gems", "a-2-#{address}") - assert_path_exist File.join(@gemhome, "specifications", "a-2-#{address}.gemspec") + assert_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", "a-2*.gemspec")].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 @@ -1407,7 +1408,7 @@ def test_install_assigns_content_address_from_filename_with_full_sha assert_equal "a-2-#{digest}", spec.full_name assert_path_exist File.join(@gemhome, "gems", "a-2-#{digest}") assert_path_exist File.join(@gemhome, "cache", "a-2-#{digest}.gem") - assert_path_exist File.join(@gemhome, "specifications", "a-2-#{digest}.gemspec") + 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 @@ -1446,10 +1447,77 @@ def test_two_content_addressed_gems_with_same_name_version_coexist assert_path_exist File.join(@gemhome, "gems", "a-2-#{address1}") assert_path_exist File.join(@gemhome, "gems", "a-2-#{address2}") - assert_path_exist File.join(@gemhome, "specifications", "a-2-#{address1}.gemspec") - assert_path_exist File.join(@gemhome, "specifications", "a-2-#{address2}.gemspec") + assert_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", "a-2-*.gemspec")].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 diff --git a/test/rubygems/test_gem_specification.rb b/test/rubygems/test_gem_specification.rb index 1e6be0c3e99c..025fec7e66eb 100644 --- a/test/rubygems/test_gem_specification.rb +++ b/test/rubygems/test_gem_specification.rb @@ -1948,6 +1948,121 @@ def test_ruby_abi_returns_nil_for_default_required_ruby_version 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 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" From ff7ad35c447efab8bba57e39509acb84513208d9 Mon Sep 17 00:00:00 2001 From: Jenny Shen Date: Fri, 4 Sep 2026 00:31:48 -0400 Subject: [PATCH 13/17] Use CompactIndexV2API in CompactIndexCooldownBadCreatedAt --- spec/install/cooldown_spec.rb | 14 +++++++------- .../compact_index_cooldown_bad_created_at.rb | 13 +++++++++---- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/spec/install/cooldown_spec.rb b/spec/install/cooldown_spec.rb index b1f3bbcc7d25..8429f20a941b 100644 --- a/spec/install/cooldown_spec.rb +++ b/spec/install/cooldown_spec.rb @@ -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") 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 From 69c8f657920c19957bbe959639d2c639cae5f5ed Mon Sep 17 00:00:00 2001 From: Jenny Shen Date: Thu, 3 Sep 2026 16:22:21 -0400 Subject: [PATCH 14/17] Support ABI-scoped specification dirs in gem doctor Assisted-By: devx/9c2464e2-74cd-4d36-a6c0-50aef8c2ad01 --- lib/rubygems/doctor.rb | 6 ++ test/rubygems/test_gem_doctor.rb | 99 ++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) 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/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 From f6650874ca787adb10bf7c2e289cdf3201d8c6b3 Mon Sep 17 00:00:00 2001 From: Jenny Shen Date: Thu, 3 Sep 2026 16:33:21 -0400 Subject: [PATCH 15/17] Support ABI-scoped specification dirs in bundle clean Assisted-By: devx/9c2464e2-74cd-4d36-a6c0-50aef8c2ad01 --- lib/bundler/runtime.rb | 4 ++- spec/commands/clean_spec.rb | 52 +++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) 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/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" From f2c0576a296b8071d7f3e381e36a8a13dad6c87d Mon Sep 17 00:00:00 2001 From: Jenny Shen Date: Thu, 3 Sep 2026 16:39:49 -0400 Subject: [PATCH 16/17] Include ABI-scoped dirs in gem contents --spec-dir output Assisted-By: devx/9c2464e2-74cd-4d36-a6c0-50aef8c2ad01 --- lib/rubygems/commands/contents_command.rb | 2 +- .../test_gem_commands_contents_command.rb | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) 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/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" From 039a4bfae88aabb9331ed714057ec53580895d5a Mon Sep 17 00:00:00 2001 From: Jenny Shen Date: Sun, 6 Sep 2026 16:18:23 -0400 Subject: [PATCH 17/17] Update compact index platform field format --- Rakefile | 2 +- lib/bundler/endpoint_specification.rb | 6 +++--- lib/rubygems/resolver/api_specification.rb | 6 ++---- lib/rubygems/source.rb | 5 ++--- spec/bundler/endpoint_specification_spec.rb | 6 +++--- .../compact_index/lib/compact_index/gem_version.rb | 2 +- test/rubygems/helper.rb | 2 +- test/rubygems/test_gem_resolver_api_specification.rb | 12 ++++++------ test/rubygems/test_gem_source.rb | 2 +- 9 files changed, 20 insertions(+), 23 deletions(-) diff --git a/Rakefile b/Rakefile index 120a1a8a2c65..674ec74ed87f 100644 --- a/Rakefile +++ b/Rakefile @@ -168,7 +168,7 @@ namespace :vendor do # Pinned upstream revision of rubygems/rubygems.org that the vendored # compact_index copy is generated from. Bump this (or pass COMPACT_INDEX_REF) # and re-run the task to refresh. - COMPACT_INDEX_REF = "572cf8948f53520668c7a07808760566f07129f8" + COMPACT_INDEX_REF = "30703392778df8f2fb63d4cc1f45a64ffbeb7629" COMPACT_INDEX_FILES = %w[ lib/compact_index.rb lib/compact_index/dependency.rb diff --git a/lib/bundler/endpoint_specification.rb b/lib/bundler/endpoint_specification.rb index a6c9e72f2805..fa4b843d23f7 100644 --- a/lib/bundler/endpoint_specification.rb +++ b/lib/bundler/endpoint_specification.rb @@ -230,10 +230,10 @@ def build_dependency(name, requirements) end def required_platform_from(value) - op, platform = value.to_s.split(" ", 2) - return unless op == "=" && platform + value = value.to_s + return if value.empty? - Gem::Platform.new(platform) + Gem::Platform.new(value) end end end diff --git a/lib/rubygems/resolver/api_specification.rb b/lib/rubygems/resolver/api_specification.rb index 377b2428d5b2..a6a032664267 100644 --- a/lib/rubygems/resolver/api_specification.rb +++ b/lib/rubygems/resolver/api_specification.rb @@ -132,10 +132,8 @@ def assign_platform(api_data) end def required_platform_from(requirement) - return unless requirement - - op, platform = requirement.last&.split(" ", 2) - return unless op == "=" && platform + platform = Array(requirement).last + return if platform.nil? || platform.empty? Gem::Platform.new(platform) end diff --git a/lib/rubygems/source.rb b/lib/rubygems/source.rb index 94333bfc3ae8..9f5ce4dc9fa4 100644 --- a/lib/rubygems/source.rb +++ b/lib/rubygems/source.rb @@ -432,9 +432,8 @@ def compact_index_requirements(info_row) end def required_platform_from(requirement) - platform_requirement = Array(requirement).last.to_s - operator, platform = platform_requirement.split(" ", 2) - return unless operator == "=" && platform + platform = Array(requirement).last.to_s + return if platform.empty? platform end diff --git a/spec/bundler/endpoint_specification_spec.rb b/spec/bundler/endpoint_specification_spec.rb index d139d7fe7d60..b045f750ab37 100644 --- a/spec/bundler/endpoint_specification_spec.rb +++ b/spec/bundler/endpoint_specification_spec.rb @@ -46,7 +46,7 @@ def with_tz(tz) 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"] } } + 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")) @@ -77,7 +77,7 @@ def with_tz(tz) context "when a content-addressed suffix has no ruby metadata" do let(:suffix) { "abc1234567" } - let(:metadata) { { "platform" => ["= arm64-darwin"] } } + let(:metadata) { { "platform" => ["arm64-darwin"] } } it "does not assign a content address" do expect(spec.content_address).to be_nil @@ -86,7 +86,7 @@ def with_tz(tz) context "when a content-addressed suffix has non-ABI ruby metadata" do let(:suffix) { "abc1234567" } - let(:metadata) { { "platform" => ["= arm64-darwin"], "ruby" => [">= 3.0"] } } + let(:metadata) { { "platform" => ["arm64-darwin"], "ruby" => [">= 3.0"] } } it "does not assign a content address" do expect(spec.content_address).to be_nil 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 index 192eb4d7a828..7c07e481e4a2 100644 --- a/spec/support/vendor/compact_index/lib/compact_index/gem_version.rb +++ b/spec/support/vendor/compact_index/lib/compact_index/gem_version.rb @@ -27,7 +27,7 @@ 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 << ",platform:#{platform}" if content_address? line end diff --git a/test/rubygems/helper.rb b/test/rubygems/helper.rb index 68ffd1509f59..4392fe583a72 100644 --- a/test/rubygems/helper.rb +++ b/test/rubygems/helper.rb @@ -1312,7 +1312,7 @@ def util_compact_index_info_line(spec, created_at = nil) metadata << ",rubygems:#{util_compact_index_requirement(spec.required_rubygems_version)}" end if spec.content_address - metadata << ",platform:= #{spec.platform}" + metadata << ",platform:#{spec.platform}" end metadata << ",created_at:#{created_at}" if created_at diff --git a/test/rubygems/test_gem_resolver_api_specification.rb b/test/rubygems/test_gem_resolver_api_specification.rb index c2028348777f..5a563f4161eb 100644 --- a/test/rubygems/test_gem_resolver_api_specification.rb +++ b/test/rubygems/test_gem_resolver_api_specification.rb @@ -37,7 +37,7 @@ def test_initialize_content_address number: "3.0.3", suffix: "abc1234567", dependencies: [], - requirements: { platform: ["= #{Gem::Platform.local}"], ruby: ["~> 3.4.0"] }, + requirements: { platform: [Gem::Platform.local.to_s], ruby: ["~> 3.4.0"] }, } spec = Gem::Resolver::APISpecification.new set, data @@ -55,7 +55,7 @@ def test_initialize_does_not_assign_content_address_without_ruby_requirement number: "3.0.3", suffix: "abc1234567", dependencies: [], - requirements: { platform: ["= #{Gem::Platform.local}"] }, + requirements: { platform: [Gem::Platform.local.to_s] }, } spec = Gem::Resolver::APISpecification.new set, data @@ -70,7 +70,7 @@ def test_initialize_does_not_assign_content_address_with_non_abi_ruby_requiremen number: "3.0.3", suffix: "abc1234567", dependencies: [], - requirements: { platform: ["= #{Gem::Platform.local}"], ruby: [">= 3.0"] }, + requirements: { platform: [Gem::Platform.local.to_s], ruby: [">= 3.0"] }, } spec = Gem::Resolver::APISpecification.new set, data @@ -85,7 +85,7 @@ def test_initialize_does_not_treat_non_content_address_suffix_as_content_address number: "3.0.3", suffix: Gem::Platform.local.to_s, dependencies: [], - requirements: { platform: ["= #{Gem::Platform.local}"] }, + requirements: { platform: [Gem::Platform.local.to_s] }, } spec = Gem::Resolver::APISpecification.new set, data @@ -102,7 +102,7 @@ def test_content_addressed_specs_with_different_addresses_are_distinct number: "3.0.3", suffix: "abc1234567", dependencies: [], - requirements: { platform: ["= #{Gem::Platform.local}"] }, + requirements: { platform: [Gem::Platform.local.to_s] }, } data[:requirements][:ruby] = ["~> 3.4.0"] @@ -257,7 +257,7 @@ def test_fetch_development_dependencies_for_content_addressed_spec number: "3.0.3", suffix: "abc1234567", dependencies: [], - requirements: { platform: ["= #{Gem::Platform.local}"], ruby: ["~> 3.4.0"] }, + requirements: { platform: [Gem::Platform.local.to_s], ruby: ["~> 3.4.0"] }, } spec = Gem::Resolver::APISpecification.new set, data diff --git a/test/rubygems/test_gem_source.rb b/test/rubygems/test_gem_source.rb index 2bbb55360765..929d42a6c1cf 100644 --- a/test/rubygems/test_gem_source.rb +++ b/test/rubygems/test_gem_source.rb @@ -264,7 +264,7 @@ def test_load_specs_compact_index_skips_content_addressable_rows_without_ruby_fi 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") + @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))