From 838481db0d4d92024180b15e122b75c7c8a72953 Mon Sep 17 00:00:00 2001 From: Harriet Oughton Date: Fri, 28 Aug 2026 00:27:39 -0400 Subject: [PATCH 1/9] Support content addressable gems in gem build Co-authored-by: Jenny Shen --- lib/rubygems/commands/build_command.rb | 11 +- lib/rubygems/package.rb | 71 +++++- lib/rubygems/package_task.rb | 4 +- lib/rubygems/specification.rb | 17 ++ .../test_gem_commands_build_command.rb | 188 +++++++++++++- test/rubygems/test_gem_package.rb | 236 ++++++++++++++++++ test/rubygems/test_gem_package_task.rb | 41 +++ test/rubygems/test_gem_specification.rb | 36 +++ 8 files changed, 593 insertions(+), 11 deletions(-) diff --git a/lib/rubygems/commands/build_command.rb b/lib/rubygems/commands/build_command.rb index cfe1f8ec3c86..e2b970e9c851 100644 --- a/lib/rubygems/commands/build_command.rb +++ b/lib/rubygems/commands/build_command.rb @@ -25,6 +25,10 @@ def initialize add_option "-o", "--output FILE", "output gem with the given filename" do |value, options| options[:output] = value end + + add_option "--ruby-abi RUBY_ABI", "build a content addressable gem for the given Ruby ABI" do |value, options| + options[:ruby_abi] = value + end end def arguments # :nodoc: @@ -52,6 +56,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 +96,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..8bcb52bc35e5 100644 --- a/lib/rubygems/package.rb +++ b/lib/rubygems/package.rb @@ -129,16 +129,54 @@ 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 + + def self.build(spec, skip_validation = false, strict_validation = false, file_name = nil, ruby_abi = nil) + if ruby_abi && file_name + raise ArgumentError, "Cannot specify both a Ruby ABI and an output file name because content addressable gems must use the generated file name." + end + if ruby_abi + require "digest" + require "stringio" + + validate_ruby_abi(spec, ruby_abi) + + build_spec = spec.dup + build_spec.required_ruby_version = Gem::Requirement.new("~> #{ruby_abi}.0") - package = new gem_file - package.spec = spec - package.build skip_validation, strict_validation + io = StringIO.new + io.set_encoding(Encoding::BINARY) + package = new io + package.spec = build_spec + gem_file = package.build_content_addressable_file skip_validation, strict_validation + + spec.required_ruby_version = build_spec.required_ruby_version + else + gem_file = file_name || spec.file_name + + package = new gem_file + package.spec = spec + package.build skip_validation, strict_validation + end gem_file end + def self.validate_ruby_abi(spec, ruby_abi) + if !/\A\d+\.\d+\z/.match?(ruby_abi) + raise ArgumentError, "Ruby ABI must be in X.Y format" + elsif spec.platform.nil? || spec.platform == Gem::Platform::RUBY + raise ArgumentError, "Cannot build a gem scoped to a single Ruby ABI as no platform or a Ruby platform has been set" + elsif spec.required_ruby_version && spec.required_ruby_version != Gem::Requirement.default && spec.ruby_abi != ruby_abi + raise ArgumentError, "Cannot build gem for Ruby ABI #{ruby_abi} because required_ruby_version is set to #{spec.required_ruby_version}. Please set required_ruby_version to \"~> #{ruby_abi}.0\"." + end + end + private_class_method :validate_ruby_abi + ## # Creates a new Gem::Package for the file at +gem+. +gem+ can also be # provided as an IO object. @@ -315,16 +353,35 @@ def build(skip_validation = false, strict_validation = false) end end - say <<-EOM + message = <<-EOM Successfully built RubyGem Name: #{@spec.name} Version: #{@spec.version} - File: #{File.basename @gem.path} EOM + + message += " File: #{File.basename(@gem.path)}\n" if @gem.path + say message ensure @signer = nil end + ## + # Builds this package, then writes it to a content-addressable file name + # derived from the SHA-256 digest of the gem contents, e.g. + # "example-1.0-01234567.gem". Returns the file name of the written gem. + + def build_content_addressable_file(skip_validation = false, strict_validation = false) + build skip_validation, strict_validation + + bytes = @gem.with_read_io(&:read) + gem_file = "#{@spec.name}-#{@spec.version}-#{Digest::SHA256.hexdigest(bytes)[0, DEFAULT_CONTENT_ADDRESS_LENGTH]}.gem" + File.binwrite(gem_file, bytes) + + say " File: #{gem_file}" + + gem_file + end + ## # A list of file names contained in this gem diff --git a/lib/rubygems/package_task.rb b/lib/rubygems/package_task.rb index d26411684dd0..398f7820ad77 100644 --- a/lib/rubygems/package_task.rb +++ b/lib/rubygems/package_task.rb @@ -111,10 +111,10 @@ def define file gem_path => [package_dir, gem_dir] + @gem_spec.files do chdir(gem_dir) do when_writing "Creating #{gem_spec.file_name}" do - Gem::Package.build gem_spec + built_gem_file = Gem::Package.build gem_spec verbose trace do - mv gem_file, ".." + mv built_gem_file, ".." end end end diff --git a/lib/rubygems/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/test/rubygems/test_gem_commands_build_command.rb b/test/rubygems/test_gem_commands_build_command.rb index cd88421c0754..aef8444a5f84 100644 --- a/test/rubygems/test_gem_commands_build_command.rb +++ b/test/rubygems/test_gem_commands_build_command.rb @@ -28,7 +28,7 @@ def setup @cmd = Gem::Commands::BuildCommand.new end - def test_handle_options + def test_handle_options_force_strict_platform @cmd.handle_options %w[--force --strict] assert @cmd.options[:force] @@ -37,6 +37,41 @@ def test_handle_options assert_includes Gem.platforms, Gem::Platform.local end + def test_options_ruby_abi + gem = util_spec "platformed_gem" do |s| + s.license = "AGPL-3.0-only" + s.files = ["README.md"] + s.platform = "arm64-darwin" + s.required_ruby_version = "~> 3.4.0" + end + + gemspec_file = File.join(@tempdir, gem.spec_name) + + File.open gemspec_file, "w" do |gs| + gs.write gem.to_ruby + end + + @cmd.handle_options [gemspec_file, "--ruby-abi", "3.4"] + assert_equal "3.4", @cmd.options[:ruby_abi] + + use_ui @ui do + Dir.chdir @tempdir do + @cmd.execute + end + end + + files = Dir[File.join(@tempdir, "platformed_gem-2-*.gem")] + assert_equal 1, files.size + assert_match(/\Aplatformed_gem-2-[0-9a-f]{8}\.gem\z/, File.basename(files.first)) + + output = @ui.output.split "\n" + assert_equal " Successfully built RubyGem", output.shift + assert_equal " Name: platformed_gem", output.shift + assert_equal " Version: 2", output.shift + assert_match(/\A File: platformed_gem-2-[0-9a-f]{8}\.gem\z/, output.shift) + assert_equal [], output + end + def test_options_filename gemspec_file = File.join(@tempdir, @gem.spec_name) @@ -70,6 +105,7 @@ def test_handle_options_defaults refute @cmd.options[:force] refute @cmd.options[:strict] assert_nil @cmd.options[:output] + assert_nil @cmd.options[:ruby_abi] end def test_execute @@ -84,6 +120,156 @@ def test_execute util_test_build_gem @gem end + def test_ruby_abi_rejects_invalid_format + 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 + + ["3", "3.4.1", "abc", "3.x"].each do |invalid| + @cmd.handle_options [gemspec_file, "--ruby-abi", invalid] + error = assert_raise(ArgumentError) do + use_ui @ui do + Dir.chdir @tempdir do + @cmd.execute + end + end + end + assert_match(/Ruby ABI must be in X\.Y format/, error.message) + end + end + + def test_ruby_abi_rejects_ruby_platform + gem = util_spec "some_gem" do |s| + s.license = "AGPL-3.0-only" + s.files = ["README.md"] + end + + gemspec_file = File.join(@tempdir, gem.spec_name) + File.open gemspec_file, "w" do |gs| + gs.write gem.to_ruby + end + + @cmd.handle_options [gemspec_file, "--ruby-abi", "3.4"] + error = assert_raise(ArgumentError) do + use_ui @ui do + Dir.chdir @tempdir do + @cmd.execute + end + end + end + assert_match(/no platform or a Ruby platform has been set/, error.message) + end + + def test_ruby_abi_rejects_mismatched_required_ruby_version + gem = util_spec "platformed_gem" do |s| + s.license = "AGPL-3.0-only" + s.files = ["README.md"] + s.platform = "arm64-darwin" + s.required_ruby_version = "~> 3.3.0" + end + + gemspec_file = File.join(@tempdir, gem.spec_name) + File.open gemspec_file, "w" do |gs| + gs.write gem.to_ruby + end + + @cmd.handle_options [gemspec_file, "--ruby-abi", "3.4"] + error = assert_raise(ArgumentError) do + use_ui @ui do + Dir.chdir @tempdir do + @cmd.execute + end + end + end + assert_match(/Cannot build gem for Ruby ABI 3\.4 because required_ruby_version/, error.message) + end + + def test_ruby_abi_defaults_required_ruby_version_when_unset + gem = util_spec "platformed_gem" do |s| + s.license = "AGPL-3.0-only" + s.files = ["README.md"] + s.platform = "arm64-darwin" + end + + gemspec_file = File.join(@tempdir, gem.spec_name) + File.open gemspec_file, "w" do |gs| + gs.write gem.to_ruby + end + + @cmd.handle_options [gemspec_file, "--ruby-abi", "3.4"] + use_ui @ui do + Dir.chdir @tempdir do + @cmd.execute + end + end + + files = Dir[File.join(@tempdir, "platformed_gem-2-*.gem")] + assert_equal 1, files.size + spec = Gem::Package.new(files.first).spec + assert_equal Gem::Requirement.new("~> 3.4.0"), spec.required_ruby_version + end + + def test_ruby_abi_produces_deterministic_content_address + gemspec = lambda do + gem = util_spec "platformed_gem" do |s| + s.license = "AGPL-3.0-only" + s.files = ["README.md"] + s.platform = "arm64-darwin" + s.required_ruby_version = "~> 3.4.0" + end + + gemspec_file = File.join(@tempdir, gem.spec_name) + File.open gemspec_file, "w" do |gs| + gs.write gem.to_ruby + end + + @cmd.handle_options [gemspec_file, "--ruby-abi", "3.4"] + use_ui @ui do + Dir.chdir @tempdir do + @cmd.execute + end + end + + Dir[File.join(@tempdir, "platformed_gem-2-*.gem")].first + end + + first_build = gemspec.call + second_build = gemspec.call + + assert_equal File.basename(first_build), File.basename(second_build) + end + + def test_ruby_abi_with_output_raises + gem = util_spec "platformed_gem" do |s| + s.license = "AGPL-3.0-only" + s.files = ["README.md"] + s.platform = "arm64-darwin" + s.required_ruby_version = "~> 3.4.0" + end + + gemspec_file = File.join(@tempdir, gem.spec_name) + File.open gemspec_file, "w" do |gs| + gs.write gem.to_ruby + end + + @cmd.handle_options [gemspec_file, "--ruby-abi", "3.4", "--output", "test.gem"] + error = assert_raise(ArgumentError) do + use_ui @ui do + Dir.chdir @tempdir do + @cmd.execute + end + end + end + assert_match(/Cannot specify both a Ruby ABI and an output file name/, error.message) + end + def test_execute_platform gemspec_file = File.join(@tempdir, @gem.spec_name) diff --git a/test/rubygems/test_gem_package.rb b/test/rubygems/test_gem_package.rb index 7b8ac4736d2f..fe5505a70afb 100644 --- a/test/rubygems/test_gem_package.rb +++ b/test/rubygems/test_gem_package.rb @@ -236,6 +236,242 @@ def test_add_files_symlink assert_equal [{ "lib/code_sym.rb" => "code.rb" }, { "lib/code_sym2.rb" => "../lib/code.rb" }], symlinks end + def test_ruby_abi_creates_content_addressed_file + spec = Gem::Specification.new "platformed", "1" + spec.summary = "platformed" + spec.authors = "platformed" + spec.files = ["lib/code.rb"] + spec.platform = "arm64-darwin" + spec.required_ruby_version = Gem::Requirement.new("~> 3.4.0") + + FileUtils.mkdir "lib" + + File.open "lib/code.rb", "w" do |io| + io.write "# lib/code.rb" + end + + built_file = Gem::Package.build(spec, false, false, nil, "3.4") + + assert_path_not_exist spec.file_name + assert_path_exist built_file + assert_match(/\Aplatformed-1-[0-9a-f]{8}\.gem\z/, built_file) + end + + def test_ruby_abi_built_gem_preserves_derived_metadata + spec = Gem::Specification.new "platformed", "1" + spec.summary = "platformed" + spec.authors = "platformed" + spec.files = ["lib/code.rb"] + spec.platform = "arm64-darwin" + spec.required_ruby_version = Gem::Requirement.new("~> 3.4.0") + + FileUtils.mkdir "lib" + + File.open "lib/code.rb", "w" do |io| + io.write "# lib/code.rb" + end + + built_file = Gem::Package.build(spec, false, false, nil, "3.4") + + loaded_spec = Gem::Package.new(built_file).spec + assert_equal "platformed", loaded_spec.name + assert_equal Gem::Version.new("1"), loaded_spec.version + assert_equal Gem::Platform.new("arm64-darwin"), loaded_spec.platform + assert_equal Gem::Requirement.new("~> 3.4.0"), loaded_spec.required_ruby_version + assert_equal "3.4", loaded_spec.ruby_abi + end + + def test_required_ruby_version_unchanged_after_successful_matching_build + spec = Gem::Specification.new "platformed", "1" + spec.summary = "platformed" + spec.authors = "platformed" + spec.files = ["lib/code.rb"] + spec.platform = "arm64-darwin" + spec.required_ruby_version = Gem::Requirement.new("~> 3.4.0") + + FileUtils.mkdir "lib" + + File.open "lib/code.rb", "w" do |io| + io.write "# lib/code.rb" + end + + original_rrv = spec.required_ruby_version + + Gem::Package.build(spec, false, false, nil, "3.4") + + assert_equal original_rrv, spec.required_ruby_version + assert_equal Gem::Requirement.new("~> 3.4.0"), spec.required_ruby_version + end + + def test_ruby_abi_not_passed_does_not_create_content_addressed_file + spec = Gem::Specification.new "platformed", "1" + spec.summary = "platformed" + spec.authors = "platformed" + spec.files = ["lib/code.rb"] + spec.platform = "arm64-darwin" + spec.required_ruby_version = Gem::Requirement.new("~> 3.4.0") + + FileUtils.mkdir "lib" + + File.open "lib/code.rb", "w" do |io| + io.write "# lib/code.rb" + end + + built_file = Gem::Package.build(spec) + + assert_path_exist built_file + assert_equal("platformed-1-arm64-darwin.gem", built_file) + end + + def test_required_ruby_version_is_set_by_ruby_abi_if_default + spec = Gem::Specification.new "platformed", "1" + spec.summary = "platformed" + spec.authors = "platformed" + spec.files = ["lib/code.rb"] + spec.platform = "arm64-darwin" + spec.required_ruby_version = Gem::Requirement.default + + FileUtils.mkdir "lib" + + File.open "lib/code.rb", "w" do |io| + io.write "# lib/code.rb" + end + + built_file = Gem::Package.build(spec, false, false, nil, "3.4") + + assert_path_exist built_file + assert_match(/\Aplatformed-1-[0-9a-f]{8}\.gem\z/, built_file) + assert_equal Gem::Requirement.new("~> 3.4.0"), spec.required_ruby_version + end + + def test_required_ruby_version_is_not_modified_if_build_fails + spec = Gem::Specification.new "platformed", "1" + spec.summary = "platformed" + spec.files = ["lib/code.rb"] + spec.platform = "arm64-darwin" + spec.required_ruby_version = Gem::Requirement.default + + FileUtils.mkdir "lib" + + File.open "lib/code.rb", "w" do |io| + io.write "# lib/code.rb" + end + + # missing authors makes validation during the build raise + assert_raise Gem::InvalidSpecificationException do + Gem::Package.build(spec, false, false, nil, "3.4") + end + + assert_equal Gem::Requirement.default, spec.required_ruby_version + end + + def test_raise_if_required_ruby_version_conflicts_with_ruby_abi + spec = Gem::Specification.new "platformed", "1" + spec.summary = "platformed" + spec.authors = "platformed" + spec.files = ["lib/code.rb"] + spec.platform = "arm64-darwin" + spec.required_ruby_version = Gem::Requirement.new("~> 3.5.0") + + FileUtils.mkdir "lib" + + File.open "lib/code.rb", "w" do |io| + io.write "# lib/code.rb" + end + + e = assert_raise ArgumentError do + Gem::Package.build(spec, false, false, nil, "3.4") + end + + assert_match "Cannot build gem for Ruby ABI 3.4 because required_ruby_version is set to ~> 3.5.0", e.message + assert_match "Please set required_ruby_version to \"~> 3.4.0\"", e.message + assert_equal Gem::Requirement.new("~> 3.5.0"), spec.required_ruby_version + end + + def test_raise_if_ruby_abi_is_not_in_x_y_format + spec = Gem::Specification.new "platformed", "1" + spec.summary = "platformed" + spec.authors = "platformed" + spec.files = ["lib/code.rb"] + spec.platform = "arm64-darwin" + spec.required_ruby_version = Gem::Requirement.new("~> 3.4.0") + + FileUtils.mkdir "lib" + + File.open "lib/code.rb", "w" do |io| + io.write "# lib/code.rb" + end + + e = assert_raise ArgumentError do + Gem::Package.build(spec, false, false, nil, "3.4.5") + end + + assert_match "Ruby ABI must be in X.Y format", e.message + end + + def test_raise_if_spec_is_non_platformed_but_ruby_abi_is_passed + spec = Gem::Specification.new "non-platformed", "1" + spec.summary = "non-platformed" + spec.authors = "non-platformed" + spec.files = ["lib/code.rb"] + spec.required_ruby_version = Gem::Requirement.new("~> 3.4") + + FileUtils.mkdir "lib" + + File.open "lib/code.rb", "w" do |io| + io.write "# lib/code.rb" + end + + e = assert_raise ArgumentError do + Gem::Package.build(spec, false, false, nil, "3.4") + end + + assert_match "no platform or a Ruby platform has been set", e.message + end + + def test_explicit_output_keeps_requested_filename + spec = Gem::Specification.new "explicit", "1" + spec.summary = "explicit" + spec.authors = "explicit" + spec.files = ["lib/code.rb"] + spec.platform = "arm64-darwin" + spec.required_ruby_version = Gem::Requirement.new("~> 3.4.0") + + FileUtils.mkdir "lib" + + File.open "lib/code.rb", "w" do |io| + io.write "# lib/code.rb" + end + + built_file = Gem::Package.build(spec, false, false, "explicit-output.gem") + + assert_path_exist built_file + assert_equal("explicit-output.gem", built_file) + end + + def test_explicit_output_and_ruby_abi_raises + spec = Gem::Specification.new "explicit", "1" + spec.summary = "explicit" + spec.authors = "explicit" + spec.files = ["lib/code.rb"] + spec.platform = "arm64-darwin" + spec.required_ruby_version = Gem::Requirement.default + + FileUtils.mkdir "lib" + + File.open "lib/code.rb", "w" do |io| + io.write "# lib/code.rb" + end + + e = assert_raise ArgumentError do + Gem::Package.build(spec, false, false, "explicit-output.gem", "3.4") + end + + assert_match "Cannot specify both a Ruby ABI and an output file name", e.message + assert_equal Gem::Requirement.default, spec.required_ruby_version + assert_path_not_exist "explicit-output.gem" + end + def test_build spec = Gem::Specification.new "build", "1" spec.summary = "build" diff --git a/test/rubygems/test_gem_package_task.rb b/test/rubygems/test_gem_package_task.rb index 6f322ad61e16..f03af2a3a39f 100644 --- a/test/rubygems/test_gem_package_task.rb +++ b/test/rubygems/test_gem_package_task.rb @@ -43,6 +43,47 @@ def test_gem_package RakeFileUtils.verbose_flag = original_rake_fileutils_verbosity end + def test_moves_filename_returned_by_build + gem = Gem::Specification.new do |g| + g.name = "pkgr" + g.version = "1.2.3" + g.platform = "arm64-darwin" + g.required_ruby_version = "~> 3.4.0" + + g.authors = %w[author] + g.files = %w[x] + g.summary = "summary" + end + + Rake.application = Rake::Application.new + + pkg = Gem::PackageTask.new(gem) do |p| + p.package_files << "y" + end + + assert_equal %w[x y], pkg.package_files + + Dir.chdir @tempdir do + FileUtils.touch "x" + FileUtils.touch "y" + + built_gem_file = "pkgr-1.2.3-01234567.gem" + + Gem::Package.stub :build, ->(_) { + FileUtils.touch built_gem_file + built_gem_file + } do + Rake.application["package"].invoke + end + + built_files = Dir["pkg/pkgr-1.2.3-*.gem"] + + assert_equal 1, built_files.length + assert_equal "pkg/pkgr-1.2.3-01234567.gem", built_files.first + assert_path_not_exist "pkg/pkgr-1.2.3-arm64-darwin.gem" + end + end + def test_gem_package_prints_to_stdout_by_default gem = Gem::Specification.new do |g| g.name = "pkgr" diff --git a/test/rubygems/test_gem_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 From 388475822271e9c13d44e78b30b0c78489162d08 Mon Sep 17 00:00:00 2001 From: Harriet Oughton Date: Fri, 28 Aug 2026 00:27:42 -0400 Subject: [PATCH 2/9] Support content addressable gems in gem install Co-authored-by: Gira Chawda Co-authored-by: Jenny Shen --- Manifest.txt | 1 + lib/rubygems.rb | 1 + lib/rubygems/basic_specification.rb | 14 +- lib/rubygems/content_address.rb | 36 +++ lib/rubygems/installer.rb | 13 + lib/rubygems/name_tuple.rb | 44 +++- lib/rubygems/package.rb | 22 ++ lib/rubygems/resolver.rb | 5 +- 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 | 6 + lib/rubygems/safe_marshal.rb | 2 +- lib/rubygems/source.rb | 92 ++++++- lib/rubygems/source/local.rb | 3 +- lib/rubygems/specification.rb | 17 +- lib/rubygems/stub_specification.rb | 47 +++- test/rubygems/test_gem_content_address.rb | 83 ++++++ .../rubygems/test_gem_dependency_installer.rb | 26 +- test/rubygems/test_gem_installer.rb | 242 ++++++++++++++++++ test/rubygems/test_gem_name_tuple.rb | 24 ++ test/rubygems/test_gem_remote_fetcher.rb | 23 ++ test/rubygems/test_gem_resolver.rb | 96 +++++++ test/rubygems/test_gem_resolver_api_set.rb | 22 +- .../test_gem_resolver_api_specification.rb | 104 +++++++- .../test_gem_resolver_index_specification.rb | 15 ++ test/rubygems/test_gem_safe_marshal.rb | 35 +++ test/rubygems/test_gem_source.rb | 66 +++++ test/rubygems/test_gem_source_local.rb | 13 + test/rubygems/test_gem_specification.rb | 54 ++++ test/rubygems/test_gem_stub_specification.rb | 45 ++++ 33 files changed, 1138 insertions(+), 71 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 f2ddb0fb2653..5188114aa649 100644 --- a/Manifest.txt +++ b/Manifest.txt @@ -357,6 +357,7 @@ lib/rubygems/compact_index_client/http_fetcher.rb lib/rubygems/compact_index_client/parser.rb lib/rubygems/compact_index_client/updater.rb lib/rubygems/config_file.rb +lib/rubygems/content_address.rb lib/rubygems/cooldown.rb lib/rubygems/cooldown_option.rb lib/rubygems/core_ext/kernel_gem.rb diff --git a/lib/rubygems.rb b/lib/rubygems.rb index 1cac0433cd81..2835862e2c1a 100644 --- a/lib/rubygems.rb +++ b/lib/rubygems.rb @@ -1411,6 +1411,7 @@ def default_gem_load_paths MARSHAL_SPEC_DIR = "quick/Marshal.#{Gem.marshal_version}/".freeze autoload :ConfigFile, File.expand_path("rubygems/config_file", __dir__) + autoload :ContentAddress, File.expand_path("rubygems/content_address", __dir__) autoload :CIDetector, File.expand_path("rubygems/ci_detector", __dir__) autoload :Dependency, File.expand_path("rubygems/dependency", __dir__) autoload :DependencyList, File.expand_path("rubygems/dependency_list", __dir__) diff --git a/lib/rubygems/basic_specification.rb b/lib/rubygems/basic_specification.rb index 61d35307f4ea..1c75f919ef8f 100644 --- a/lib/rubygems/basic_specification.rb +++ b/lib/rubygems/basic_specification.rb @@ -140,18 +140,26 @@ 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 + def content_address # :nodoc: + raise NotImplementedError + 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 new file mode 100644 index 000000000000..fe3420371b13 --- /dev/null +++ b/lib/rubygems/content_address.rb @@ -0,0 +1,36 @@ +# 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) + spec.required_ruby_version.to_s != ">= 0" && + !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/installer.rb b/lib/rubygems/installer.rb index 2599c0ff6ec8..bb22bb1edb6a 100644 --- a/lib/rubygems/installer.rb +++ b/lib/rubygems/installer.rb @@ -114,6 +114,12 @@ def extract_files(destination_dir, pattern = "*") def copy_to(path) end + + def gem + end + + def content_address + end end ## @@ -266,6 +272,7 @@ def spec # specifications/.gemspec #=> the Gem::Specification def install + assign_content_address pre_install_checks run_pre_install_hooks @@ -966,6 +973,12 @@ def ensure_writable_dir(dir) # :nodoc: private + def assign_content_address + address = @package.content_address + @gem_dir = nil if address != spec.content_address + spec.content_address = address + end + def user_install_dir # never install to user home in --build-root mode return unless @build_root.nil? diff --git a/lib/rubygems/name_tuple.rb b/lib/rubygems/name_tuple.rb index cbdf4d7ac5f2..ce93cb70d593 100644 --- a/lib/rubygems/name_tuple.rb +++ b/lib/rubygems/name_tuple.rb @@ -6,16 +6,18 @@ # 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 @@ -46,12 +48,15 @@ def self.null # of Gem::Specification#full_name. def full_name - case @platform - when nil, "", Gem::Platform::RUBY - "#{@name}-#{@version}" - else - "#{@name}-#{@version}-#{@platform}" - end + full_name = "#{@name}-#{@version}" + suffix = @content_address || platform_suffix + suffix ? "#{full_name}-#{suffix}" : full_name + end + + private def platform_suffix # :nodoc: + return if @platform.nil? || @platform.empty? || @platform == Gem::Platform::RUBY + + @platform end ## @@ -84,18 +89,27 @@ def to_a 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 @@ -109,7 +123,9 @@ def ==(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 @@ -120,6 +136,6 @@ def ==(other) alias_method :eql?, :== def hash - to_a.hash + [@name, @version, @platform, @content_address, @ruby_abi].hash end end diff --git a/lib/rubygems/package.rb b/lib/rubygems/package.rb index 8bcb52bc35e5..1bab92ce18ce 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/resolver.rb b/lib/rubygems/resolver.rb index 4964f5be217e..e18cf65659de 100644 --- a/lib/rubygems/resolver.rb +++ b/lib/rubygems/resolver.rb @@ -482,10 +482,11 @@ 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 the most specific platform, then a + # content-addressed candidate, then the earlier-supplied source. candidates.min_by do |s| [Gem::Platform.platform_specificity_match(s.platform, Gem::Platform.local), + Gem::ContentAddress.match?(s.content_address) ? 0 : 1, source_rank[s.source]] end end diff --git a/lib/rubygems/resolver/api_set.rb b/lib/rubygems/resolver/api_set.rb index c3dc4c0bcabc..9574f9885c1d 100644 --- a/lib/rubygems/resolver/api_set.rb +++ b/lib/rubygems/resolver/api_set.rb @@ -108,12 +108,12 @@ def versions(name) # :nodoc: [] end - infos.each do |_, number, platform, dependencies, requirements| - platform ||= "ruby" + infos.each do |_, number, suffix, dependencies, requirements| + suffix ||= "ruby" dependencies = dependencies.map {|dep_name, reqs| [dep_name, reqs.join(", ")] } requirements = requirements.map {|req_name, reqs| [req_name.to_sym, reqs] }.to_h - @data[name] << { name: name, number: number, platform: platform, dependencies: dependencies, requirements: requirements } + @data[name] << { name: name, number: number, suffix: suffix, dependencies: dependencies, requirements: requirements } end @data[name] diff --git a/lib/rubygems/resolver/api_specification.rb b/lib/rubygems/resolver/api_specification.rb index 7a0d98cb80c5..a37b7ef417d5 100644 --- a/lib/rubygems/resolver/api_specification.rb +++ b/lib/rubygems/resolver/api_specification.rb @@ -33,8 +33,7 @@ def initialize(set, api_data) @set = set @name = api_data[:name] @version = Gem::Version.new(api_data[:number]).freeze - @platform = Gem::Platform.new(api_data[:platform]).freeze - @original_platform = api_data[:platform].freeze + assign_platform(api_data) @dependencies = api_data[:dependencies].map do |name, ver| Gem::Dependency.new(name, ver.split(/\s*,\s*/)).freeze end.freeze @@ -48,15 +47,17 @@ def ==(other) # :nodoc: @set == other.set && @name == other.name && @version == other.version && - @platform == other.platform + @platform == other.platform && + @content_address == other.content_address end def hash - @set.hash ^ @name.hash ^ @version.hash ^ @platform.hash + @set.hash ^ @name.hash ^ @version.hash ^ @platform.hash ^ @content_address.hash end def fetch_development_dependencies # :nodoc: - spec = source.fetch_spec Gem::NameTuple.new @name, @version, @platform + suffix = @content_address || @platform + spec = source.fetch_spec Gem::NameTuple.new @name, @version, suffix @dependencies = spec.dependencies end @@ -101,6 +102,7 @@ def spec # :nodoc: s.original_platform = @original_platform s.required_ruby_version = @required_ruby_version s.required_rubygems_version = @required_rubygems_version + s.content_address = @content_address @dependencies.each do |dependency| s.add_runtime_dependency dependency.name, *dependency.requirement.as_list @@ -114,6 +116,30 @@ def source # :nodoc: private + def assign_platform(api_data) + suffix = api_data[:suffix] + required_platform = required_platform_from(api_data.dig(:requirements, :platform)) + + if Gem::ContentAddress.match?(suffix) && required_platform + @content_address = suffix.freeze + @platform = required_platform.freeze + @original_platform = required_platform.to_s.freeze + else + @content_address = nil + @platform = Gem::Platform.new(suffix).freeze + @original_platform = suffix.freeze + end + end + + def required_platform_from(requirement) + return unless requirement + + op, platform = requirement.last&.split(" ", 2) + return unless op == "=" && platform + + Gem::Platform.new(platform) + end + def parse_created_at(value) value = value.first if value.is_a?(Array) diff --git a/lib/rubygems/resolver/index_specification.rb b/lib/rubygems/resolver/index_specification.rb index 7b9560807148..20ef14e68291 100644 --- a/lib/rubygems/resolver/index_specification.rb +++ b/lib/rubygems/resolver/index_specification.rb @@ -15,7 +15,7 @@ class Gem::Resolver::IndexSpecification < Gem::Resolver::Specification # The +name+, +version+ and +platform+ are the name, version and platform of # the gem. - def initialize(set, name, version, source, platform) + def initialize(set, name, version, source, platform, content_address: nil) super() @set = set @@ -24,6 +24,7 @@ def initialize(set, name, version, source, platform) @source = source @platform = Gem::Platform.new(platform.to_s) @original_platform = platform.to_s + @content_address = content_address @spec = nil end @@ -60,11 +61,12 @@ def ==(other) self.class === other && @name == other.name && @version == other.version && - @platform == other.platform + @platform == other.platform && + @content_address == other.content_address end def hash - @name.hash ^ @version.hash ^ @platform.hash + [@name, @version, @platform, @content_address].hash end def inspect # :nodoc: @@ -93,7 +95,7 @@ def pretty_print(q) # :nodoc: def spec # :nodoc: @spec ||= begin - tuple = Gem::NameTuple.new @name, @version, @original_platform + tuple = Gem::NameTuple.new @name, @version, @original_platform, content_address: @content_address @source.fetch_spec tuple end diff --git a/lib/rubygems/resolver/installer_set.rb b/lib/rubygems/resolver/installer_set.rb index 42ce0890e2b6..e1113e595560 100644 --- a/lib/rubygems/resolver/installer_set.rb +++ b/lib/rubygems/resolver/installer_set.rb @@ -163,7 +163,7 @@ def find_all(req) @local_source.find_all_gems(name, dep.requirement).each do |local_spec| res << Gem::Resolver::IndexSpecification.new( self, local_spec.name, local_spec.version, - @local_source, local_spec.platform + @local_source, local_spec.platform, content_address: local_spec.content_address ) end rescue Gem::Package::FormatError diff --git a/lib/rubygems/resolver/spec_specification.rb b/lib/rubygems/resolver/spec_specification.rb index 00ef9fdba05b..f08d9773f8ea 100644 --- a/lib/rubygems/resolver/spec_specification.rb +++ b/lib/rubygems/resolver/spec_specification.rb @@ -23,6 +23,10 @@ def dependencies spec.dependencies end + def content_address # :nodoc: + spec.content_address + end + ## # The required_ruby_version constraint for this specification diff --git a/lib/rubygems/resolver/specification.rb b/lib/rubygems/resolver/specification.rb index 986fa7c9ae82..7b96d4ac4196 100644 --- a/lib/rubygems/resolver/specification.rb +++ b/lib/rubygems/resolver/specification.rb @@ -60,6 +60,11 @@ class Gem::Resolver::Specification attr_reader :created_at + ## + # The content address of this specification. + + attr_reader :content_address + ## # Sets default instance variables for the specification. @@ -73,6 +78,7 @@ def initialize @version = nil @required_ruby_version = Gem::Requirement.default @required_rubygems_version = Gem::Requirement.default + @content_address = nil end ## diff --git a/lib/rubygems/safe_marshal.rb b/lib/rubygems/safe_marshal.rb index 871f24727dcb..8bb8d95a692a 100644 --- a/lib/rubygems/safe_marshal.rb +++ b/lib/rubygems/safe_marshal.rb @@ -51,7 +51,7 @@ module SafeMarshal @name @requirement @prerelease @version_requirement @version_requirements @type @force_ruby_platform ], - "Gem::NameTuple" => %w[@name @version @platform], + "Gem::NameTuple" => %w[@name @version @platform @content_address @ruby_abi], "Gem::Platform" => %w[@os @cpu @version], "Psych::PrivateType" => %w[@value @type_id], "YAML::PrivateType" => %w[@value @type_id], diff --git a/lib/rubygems/source.rb b/lib/rubygems/source.rb index 286a036a5f4b..cf0a3fc4b48f 100644 --- a/lib/rubygems/source.rb +++ b/lib/rubygems/source.rb @@ -290,13 +290,36 @@ def load_compact_index_specs(type) tuples = [] versions.each_value do |rows| - gem_tuples = rows.filter_map do |name, version_string, platform| + info_rows = nil + + gem_tuples = rows.filter_map do |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" + platform = suffix + content_address = nil + ruby_abi = nil + + if Gem::ContentAddress.match?(suffix) + info_rows ||= compact_index_info_rows(name) + metadata = content_addressable_metadata(info_rows, version_string, suffix) + next unless metadata + + platform = metadata[:platform] + content_address = suffix + ruby_abi = metadata[:ruby_abi] + end + + Gem::NameTuple.new( + name, + version, + platform, + content_address: content_address, + ruby_abi: ruby_abi + ) end gem_tuples = max_versions_by_platform(gem_tuples) if type == :latest @@ -314,8 +337,71 @@ def compact_index_versions nil end + def compact_index_info_rows(name) + compact_index_client.info(name) + rescue Gem::RemoteFetcher::FetchError, Gem::CompactIndexClient::Error + [] + end + + def compact_index_info_row(info_rows, version, suffix) + info_rows.find do |row| + row_version = row[Gem::CompactIndexClient::INFO_VERSION] + row_suffix = row[Gem::CompactIndexClient::INFO_PLATFORM] + + row_version == version && row_suffix == suffix + end + end + + def content_addressable_metadata(info_rows, version, suffix) + info_row = compact_index_info_row(info_rows, version, suffix) + return unless info_row + + requirements = compact_index_requirements(info_row) + platform = required_platform_from(requirements[:platform]) + return unless platform + + { + platform: platform, + ruby_abi: ruby_abi_from(requirements[:ruby]), + } + end + + def compact_index_requirements(info_row) + info_row[Gem::CompactIndexClient::INFO_REQS].to_h do |key, requirements| + [key.to_sym, requirements] + end + end + + def required_platform_from(requirement) + platform_requirement = Array(requirement).last.to_s + operator, platform = platform_requirement.split(" ", 2) + return unless operator == "=" && platform + + platform + end + + def ruby_abi_from(requirement) + Array(requirement).each do |ruby_requirement| + match = ruby_requirement.to_s.match(/\A~>\s*(\d+)\.(\d+)\.0\z/) + return "#{match[1]}.#{match[2]}" if match + end + + nil + end + def max_versions_by_platform(tuples) - tuples.group_by(&:platform).map {|_, platform_tuples| platform_tuples.max_by(&:version) } + grouped_tuples = tuples.group_by {|tuple| latest_platform_key(tuple) } + grouped_tuples.map do |_, platform_tuples| + platform_tuples.max_by(&:version) + end + end + + def latest_platform_key(tuple) + if tuple.content_address + [tuple.platform, tuple.ruby_abi || tuple.content_address] + else + tuple.platform + end end def compact_index_uri diff --git a/lib/rubygems/source/local.rb b/lib/rubygems/source/local.rb index 4bef31a2655f..af2f79a05949 100644 --- a/lib/rubygems/source/local.rb +++ b/lib/rubygems/source/local.rb @@ -41,7 +41,8 @@ def load_specs(type) # :nodoc: Dir["*.gem"].each do |file| pkg = Gem::Package.new(file) spec = pkg.spec - rescue SystemCallError, Gem::Package::FormatError + spec.content_address = pkg.content_address + rescue SystemCallError, Gem::Package::FormatError, Gem::InstallError # ignore else tup = spec.name_tuple diff --git a/lib/rubygems/specification.rb b/lib/rubygems/specification.rb index 48fc50c8e40d..c11d4e689a5c 100644 --- a/lib/rubygems/specification.rb +++ b/lib/rubygems/specification.rb @@ -419,6 +419,8 @@ def licenses=(licenses) attr_accessor :metadata + attr_accessor :content_address # :nodoc: + ###################################################################### # :section: Optional gemspec attributes @@ -1371,7 +1373,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 +1945,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 +1981,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 +2302,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? @@ -2397,9 +2402,12 @@ 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::TARGET_PREFIX}platform=#{platform}" if content_addressed result << "#{Gem::StubSpecification::PREFIX}#{extensions.join "\0"}" unless extensions.empty? result << nil @@ -2410,6 +2418,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..5efc03d3db8e 100644 --- a/lib/rubygems/stub_specification.rb +++ b/lib/rubygems/stub_specification.rb @@ -9,12 +9,15 @@ 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 @@ -33,7 +36,7 @@ class StubLine # :nodoc: all "lib" => ["lib"].freeze, }.freeze - def initialize(data, extensions) + def initialize(data, extensions, target = nil) parts = data[PREFIX.length..-1].split(" ", 4) @name = -parts[0] @version = if Gem::Version.correct?(parts[1]) @@ -42,12 +45,17 @@ def initialize(data, extensions) Gem::Version.new(0) end - @platform = Gem::Platform.new parts[2] + suffix = parts[2] + target_platform = target && target["platform"] + @platform = Gem::Platform.new(target_platform || suffix) + @content_address = suffix if 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 +118,28 @@ def data file.readline # discard encoding line stubline = file.readline if stubline.start_with?(PREFIX) - extline = file.readline + line = file.readline + + if line.delete_prefix!(TARGET_PREFIX) + line.chomp! + target = {} + line.split(",").each do |pair| + key, value = pair.split("=", 2) + target[key] = value + end + line = file.readline + end extensions = - if extline.delete_prefix!(PREFIX) - extline.chomp! - extline.split "\0" + if line.delete_prefix!(PREFIX) + line.chomp! + line.split "\0" else StubLine::NO_EXTENSIONS 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 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_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..741eed1ea1d5 100644 --- a/test/rubygems/test_gem_installer.rb +++ b/test/rubygems/test_gem_installer.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative "installer_test_case" +require "digest" class TestGemInstaller < Gem::InstallerTestCase def setup @@ -1030,6 +1031,247 @@ def test_install_dir_takes_precedence_to_user_install assert_path_not_exist File.join(Gem.user_dir, "gems", @spec.full_name) end + def test_install_assigns_content_address_from_filename + _, a_gem = util_gem("a", 2) do |spec| + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + end + + digest = Digest::SHA256.file(a_gem).hexdigest + address = digest[0, 8] + dir = File.dirname(a_gem) + filename = File.join(dir, "a-2-#{address}.gem") + FileUtils.cp a_gem, filename + installer = Gem::Installer.at filename, install_dir: @gemhome, force: true + # deliberately memoize the directory before installation, to prove the installation recalculates + installer.gem_dir + spec = installer.install + + assert_equal address, spec.content_address + assert_equal "a-2-#{address}", spec.full_name + assert_path_exist File.join(@gemhome, "gems", "a-2-#{address}") + assert_path_exist File.join(@gemhome, "cache", "a-2-#{address}.gem") + assert_path_exist File.join(@gemhome, "specifications", "a-2-#{address}.gemspec") + end + + def test_install_raises_for_mismatched_content_address + _, a_gem = util_gem("a", 2) do |spec| + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + end + dir = File.dirname(a_gem) + filename = File.join(dir, "a-2-deadbeef.gem") + FileUtils.cp a_gem, filename + installer = Gem::Installer.at filename, install_dir: @gemhome, force: true + + e = assert_raise Gem::InstallError do + installer.install + end + + assert_match(/content address mismatch/, e.message) + end + + def test_non_content_addressed_gems_install_as_expected + _, a_gem = util_gem "a", 2 + installer = Gem::Installer.at a_gem, install_dir: @gemhome, force: true + spec = installer.install + + assert_nil spec.content_address + assert_equal "a-2", spec.full_name + assert_path_exist File.join(@gemhome, "gems", "a-2") + assert_path_exist File.join(@gemhome, "specifications", "a-2.gemspec") + end + + def test_numeric_version_not_treated_as_content_address + _, a_gem = util_gem "a", "20240101" + installer = Gem::Installer.at a_gem, install_dir: @gemhome, force: true + spec = installer.install + + assert_nil spec.content_address + assert_equal "a-20240101", spec.full_name + assert_path_exist File.join(@gemhome, "gems", "a-20240101") + assert_path_exist File.join(@gemhome, "specifications", "a-20240101.gemspec") + end + + def test_normal_gem_with_hex_suffix_is_not_content_addressed + _, a_gem = util_gem "a", 2 do |spec| + spec.platform = Gem::Platform.new("x86-linux-deadbeef") + end + installer = Gem::Installer.at a_gem, install_dir: @gemhome, force: true + spec = installer.install + + assert_nil spec.content_address + assert_equal "a-2-x86-linux-deadbeef", spec.full_name + end + + def test_content_address_not_set_without_required_ruby_version_and_platform + _, a_gem = util_gem "a", 2 + digest = Digest::SHA256.file(a_gem).hexdigest + address = digest[0, 8] + dir = File.dirname(a_gem) + filename = File.join(dir, "a-2-#{address}.gem") + FileUtils.cp a_gem, filename + installer = Gem::Installer.at filename, install_dir: @gemhome, force: true + spec = installer.install + + assert_nil spec.content_address + assert_equal "a-2", spec.full_name + end + + def test_hex_suffix_without_matching_spec_prefix_is_not_content_addressed + _, a_gem = util_gem "a", 2 + + digest = Digest::SHA256.file(a_gem).hexdigest + address = digest[0, 8] + dir = File.dirname(a_gem) + filename = File.join(dir, "wrong_name-2-#{address}.gem") + FileUtils.cp a_gem, filename + installer = Gem::Installer.at filename, install_dir: @gemhome, force: true + spec = installer.install + + assert_nil spec.content_address + assert_equal "a-2", spec.full_name + end + + def test_content_address_not_set_with_only_required_ruby_version + _, a_gem = util_gem("a", 2) do |spec| + spec.required_ruby_version = ">= 3.0" + end + digest = Digest::SHA256.file(a_gem).hexdigest + address = digest[0, 8] + dir = File.dirname(a_gem) + filename = File.join(dir, "a-2-#{address}.gem") + FileUtils.cp a_gem, filename + installer = Gem::Installer.at filename, install_dir: @gemhome, force: true + spec = installer.install + + assert_nil spec.content_address + end + + def test_content_address_not_set_with_only_platform + _, a_gem = util_gem("a", 2) do |spec| + spec.platform = "x86_64-linux" + end + digest = Digest::SHA256.file(a_gem).hexdigest + address = digest[0, 8] + dir = File.dirname(a_gem) + filename = File.join(dir, "a-2-#{address}.gem") + FileUtils.cp a_gem, filename + installer = Gem::Installer.at filename, install_dir: @gemhome, force: true + spec = installer.install + + assert_nil spec.content_address + end + + def test_require_works_after_content_addressed_install + source_spec, a_gem = util_gem("a", 2) do |spec| + spec.files = ["lib/ca_activation_test.rb"] + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + end + FileUtils.rm_rf source_spec.gem_dir + + digest = Digest::SHA256.file(a_gem).hexdigest + address = digest[0, 8] + dir = File.dirname(a_gem) + filename = File.join(dir, "a-2-#{address}.gem") + FileUtils.cp a_gem, filename + + installer = Gem::Installer.at filename, install_dir: @gemhome, force: true + installer.install + + Gem::Specification.reset + + assert require "ca_activation_test" + end + + def test_reinstalling_content_addressed_gem_is_idempotent + source_spec, a_gem = util_gem("a", 2) do |spec| + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + end + FileUtils.rm_rf source_spec.gem_dir + + digest = Digest::SHA256.file(a_gem).hexdigest + address = digest[0, 8] + dir = File.dirname(a_gem) + filename = File.join(dir, "a-2-#{address}.gem") + FileUtils.cp a_gem, filename + installer = Gem::Installer.at filename, install_dir: @gemhome, force: true + installer.install + + installer2 = Gem::Installer.at filename, install_dir: @gemhome, force: true + spec2 = installer2.install + + assert_equal "a-2-#{address}", spec2.full_name + assert_path_exist File.join(@gemhome, "gems", "a-2-#{address}") + assert_path_exist File.join(@gemhome, "specifications", "a-2-#{address}.gemspec") + assert_equal 1, Dir[File.join(@gemhome, "gems", "a-2*")].size + assert_equal 1, Dir[File.join(@gemhome, "specifications", "a-2*.gemspec")].size + end + + def test_install_assigns_content_address_from_filename_with_full_sha + _, a_gem = util_gem("a", 2) do |spec| + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + end + + digest = Digest::SHA256.file(a_gem).hexdigest + dir = File.dirname(a_gem) + filename = File.join(dir, "a-2-#{digest}.gem") + FileUtils.cp a_gem, filename + installer = Gem::Installer.at filename, install_dir: @gemhome, force: true + spec = installer.install + + assert_equal digest, spec.content_address + assert_equal "a-2-#{digest}", spec.full_name + assert_path_exist File.join(@gemhome, "gems", "a-2-#{digest}") + assert_path_exist File.join(@gemhome, "cache", "a-2-#{digest}.gem") + assert_path_exist File.join(@gemhome, "specifications", "a-2-#{digest}.gemspec") + end + + def test_two_content_addressed_gems_with_same_name_version_coexist + _, gem1 = util_gem("a", 2) do |spec| + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + spec.summary = "variant 1" + end + gem1_backup = File.join(@tempdir, "gem1_backup.gem") + FileUtils.cp gem1, gem1_backup + + _, gem2 = util_gem("a", 2) do |spec| + spec.required_ruby_version = ">= 3.0" + spec.platform = "x86_64-linux" + spec.summary = "variant 2" + end + + FileUtils.rm_rf File.join(@gemhome, "gems", "a-2-x86_64-linux") + FileUtils.rm_rf File.join(@gemhome, "specifications", "a-2-x86_64-linux.gemspec") + Gem::Specification.reset + + dir = File.dirname(gem1) + digest1 = Digest::SHA256.file(gem1_backup).hexdigest + digest2 = Digest::SHA256.file(gem2).hexdigest + refute_equal digest1, digest2 + + address1 = digest1[0, 8] + address2 = digest2[0, 8] + file1 = File.join(dir, "a-2-#{address1}.gem") + file2 = File.join(dir, "a-2-#{address2}.gem") + FileUtils.cp gem1_backup, file1 + FileUtils.cp gem2, file2 + + Gem::Installer.at(file1, install_dir: @gemhome, force: true).install + Gem::Installer.at(file2, install_dir: @gemhome, force: true).install + + assert_path_exist File.join(@gemhome, "gems", "a-2-#{address1}") + assert_path_exist File.join(@gemhome, "gems", "a-2-#{address2}") + assert_path_exist File.join(@gemhome, "specifications", "a-2-#{address1}.gemspec") + assert_path_exist File.join(@gemhome, "specifications", "a-2-#{address2}.gemspec") + assert_equal 2, Dir[File.join(@gemhome, "gems", "a-2-*")].size + assert_equal 2, Dir[File.join(@gemhome, "specifications", "a-2-*.gemspec")].size + end + def test_install installer = util_setup_installer diff --git a/test/rubygems/test_gem_name_tuple.rb b/test/rubygems/test_gem_name_tuple.rb index 4876737c83db..276a002dfb3f 100644 --- a/test/rubygems/test_gem_name_tuple.rb +++ b/test/rubygems/test_gem_name_tuple.rb @@ -46,6 +46,30 @@ 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_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 diff --git a/test/rubygems/test_gem_remote_fetcher.rb b/test/rubygems/test_gem_remote_fetcher.rb index c35da2fc5ae2..399ee9c3c5be 100644 --- a/test/rubygems/test_gem_remote_fetcher.rb +++ b/test/rubygems/test_gem_remote_fetcher.rb @@ -125,6 +125,29 @@ def test_download assert File.exist?(a1_cache_gem) end + def test_download_and_install_content_addressed_gem + require "digest" + + ca_spec, ca_gem = util_gem "a", "1" do |s| + s.required_ruby_version = ">= 3.0" + s.platform = "x86_64-linux" + end + + address = Digest::SHA256.file(ca_gem).hexdigest[0, 10] + ca_spec.content_address = address + gem_data = File.binread ca_gem + gem_url = "http://gems.example.com/gems/a-1-#{address}.gem" + fetcher = fake_fetcher(gem_url, gem_data) + + gem_path = fetcher.download(ca_spec, "http://gems.example.com") + installed_spec = Gem::Installer.at(gem_path, install_dir: @gemhome, force: true).install + + assert_equal gem_url, fetcher.paths.last + assert_equal address, installed_spec.content_address + assert_equal "a-1-#{address}", installed_spec.full_name + assert_path_exist installed_spec.full_gem_path + end + def test_download_with_auth a1_data = File.open @a1_gem, "rb", &:read a1_url = "http://user:password@gems.example.com/gems/a-1.gem" diff --git a/test/rubygems/test_gem_resolver.rb b/test/rubygems/test_gem_resolver.rb index 84ede36b6c85..c251899b488e 100644 --- a/test/rubygems/test_gem_resolver.rb +++ b/test/rubygems/test_gem_resolver.rb @@ -322,6 +322,102 @@ def test_picks_best_platform assert_resolves_to [a2_p1.spec], res end + def test_prefers_content_addressed_gem_for_same_platform + ca_spec = util_spec "a", "1" + spec = util_spec "a", "1" + + ca_spec.platform = Gem::Platform.local + ca_spec.content_address = "abc1234567" + spec.platform = Gem::Platform.local + + s = set(spec, ca_spec) + dependency = make_dep "a" + resolver = Gem::Resolver.new([dependency], s) + assert_resolves_to [ca_spec], resolver + end + + def test_falls_back_to_non_content_addressable_when_content_addressed_gem_requires_other_rubygems_version + ca_spec = util_spec "a", "1" + non_content_addressable_spec = util_spec "a", "1" + + ca_spec.platform = Gem::Platform.local + ca_spec.content_address = "abc1234567" + ca_spec.required_rubygems_version = ">= 999" + non_content_addressable_spec.platform = Gem::Platform.local + + s = set(non_content_addressable_spec, ca_spec) + dependency = make_dep "a" + resolver = Gem::Resolver.new([dependency], s) + assert_resolves_to [non_content_addressable_spec], resolver + end + + def test_falls_back_to_source_when_content_addressed_gem_requires_other_ruby + ca_spec = util_spec "a", "1" + source_spec = util_spec "a", "1" + + ca_spec.platform = Gem::Platform.local + ca_spec.content_address = "abc1234567" + ca_spec.required_ruby_version = ">= 999" + source_spec.platform = Gem::Platform::RUBY + + s = set(source_spec, ca_spec) + dependency = make_dep "a" + resolver = Gem::Resolver.new([dependency], s) + assert_resolves_to [source_spec], resolver + end + + def test_falls_back_to_non_content_addressable_before_source_when_content_addressed_gem_requires_other_ruby + ca_spec = util_spec "a", "1" + non_content_addressable_spec = util_spec "a", "1" + source_spec = util_spec "a", "1" + + ca_spec.platform = Gem::Platform.local + ca_spec.content_address = "abc1234567" + ca_spec.required_ruby_version = ">= 999" + non_content_addressable_spec.platform = Gem::Platform.local + source_spec.platform = Gem::Platform::RUBY + + s = set(source_spec, non_content_addressable_spec, ca_spec) + dependency = make_dep "a" + resolver = Gem::Resolver.new([dependency], s) + assert_resolves_to [non_content_addressable_spec], resolver + end + + def test_prefers_compatible_content_addressed_gem_when_multiple_abis_available + current_abi = "#{Gem.ruby_version.segments[0]}.#{Gem.ruby_version.segments[1]}" + + ca_compatible = util_spec "a", "1" + ca_incompatible = util_spec "a", "1" + + ca_compatible.platform = Gem::Platform.local + ca_compatible.content_address = "abc1234567" + ca_compatible.required_ruby_version = "~> #{current_abi}.0" + ca_incompatible.platform = Gem::Platform.local + ca_incompatible.content_address = "def1234567" + ca_incompatible.required_ruby_version = "~> 999.0.0" + + s = set(ca_incompatible, ca_compatible) + dependency = make_dep "a" + resolver = Gem::Resolver.new([dependency], s) + assert_resolves_to [ca_compatible], resolver + end + + def test_raises_when_only_content_addressed_gem_is_incompatible + ca_spec = util_spec "a", "1" + + ca_spec.platform = Gem::Platform.local + ca_spec.content_address = "abc1234567" + ca_spec.required_ruby_version = "~> 999.0.0" + + s = set(ca_spec) + dependency = make_dep "a" + resolver = Gem::Resolver.new([dependency], s) + + assert_raise Gem::DependencyResolutionError do + resolver.resolve + end + end + def test_does_not_pick_musl_variants_on_non_musl_linux util_set_arch "aarch64-linux" do is = Gem::Resolver::IndexSpecification diff --git a/test/rubygems/test_gem_resolver_api_set.rb b/test/rubygems/test_gem_resolver_api_set.rb index af855f1692ad..70b9ac3834f1 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,31 @@ def test_find_all assert_equal expected, set.find_all(a_dep) end + def test_find_all_content_addressed + spec_fetcher + + @fetcher.data["#{@dep_uri}a"] = util_compact_index_response("---\n1-ab12345678 |platform:= #{Gem::Platform.local}\n") + + 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 +104,7 @@ def test_find_all_cache data = [ { name: "a", number: "1", - platform: "ruby", + suffix: "ruby", dependencies: [] }, ] diff --git a/test/rubygems/test_gem_resolver_api_specification.rb b/test/rubygems/test_gem_resolver_api_specification.rb index 44d2ee254a37..d3fe3917ed0e 100644 --- a/test/rubygems/test_gem_resolver_api_specification.rb +++ b/test/rubygems/test_gem_resolver_api_specification.rb @@ -8,7 +8,7 @@ def test_initialize data = { name: "rails", number: "3.0.3", - platform: Gem::Platform.local.to_s, + suffix: Gem::Platform.local.to_s, dependencies: [ ["bundler", "~> 1.0"], ["railties", "= 3.0.3"], @@ -30,12 +30,64 @@ def test_initialize assert_nil spec.created_at end + def test_initialize_content_address + set = Gem::Resolver::APISet.new + data = { + name: "rails", + number: "3.0.3", + suffix: "abc1234567", + dependencies: [], + requirements: { platform: ["= #{Gem::Platform.local}"] }, + } + + spec = Gem::Resolver::APISpecification.new set, data + + assert_equal "abc1234567", spec.content_address + assert_equal Gem::Platform.local, spec.platform + assert Gem::ContentAddress.match?(spec.content_address) + assert_equal "abc1234567", spec.spec.content_address + end + + def test_initialize_does_not_treat_non_content_address_suffix_as_content_addressed + set = Gem::Resolver::APISet.new + data = { + name: "rails", + number: "3.0.3", + suffix: Gem::Platform.local.to_s, + dependencies: [], + requirements: { platform: ["= #{Gem::Platform.local}"] }, + } + + spec = Gem::Resolver::APISpecification.new set, data + + assert_nil spec.content_address + refute Gem::ContentAddress.match?(spec.content_address) + assert_equal Gem::Platform.local, spec.platform + end + + def test_content_addressed_specs_with_different_addresses_are_distinct + set = Gem::Resolver::APISet.new + data = { + name: "rails", + number: "3.0.3", + suffix: "abc1234567", + dependencies: [], + requirements: { platform: ["= #{Gem::Platform.local}"] }, + } + + first = Gem::Resolver::APISpecification.new set, data + second = Gem::Resolver::APISpecification.new set, data.merge(suffix: "def1234567") + + refute_equal first, second + refute_equal first.hash, second.hash + end + def test_initialize_created_at set = Gem::Resolver::APISet.new data = { name: "rails", number: "3.0.3", - platform: "ruby", + suffix: "ruby", dependencies: [], requirements: { created_at: ["2026-06-05T10:30:45Z"] }, } @@ -50,7 +102,7 @@ def test_initialize_created_at_invalid data = { name: "rails", number: "3.0.3", - platform: "ruby", + suffix: "ruby", dependencies: [], requirements: { created_at: ["not a timestamp"] }, } @@ -65,7 +117,7 @@ def test_initialize_created_at_non_iso8601 data = { name: "rails", number: "3.0.3", - platform: "ruby", + suffix: "ruby", dependencies: [], requirements: { created_at: ["2026"] }, } @@ -133,7 +185,7 @@ def test_fetch_development_dependencies data = { name: "rails", number: "3.0.3", - platform: "ruby", + suffix: "ruby", dependencies: [ ["bundler", "~> 1.0"], ["railties", "= 3.0.3"], @@ -155,12 +207,42 @@ def test_fetch_development_dependencies assert_equal expected, spec.dependencies end + def test_fetch_development_dependencies_for_content_addressed_spec + fetched_tuple = nil + fetched_spec = util_spec "rails", "3.0.3" do |s| + s.add_development_dependency "a", "= 1" + end + + source = Object.new + source.define_singleton_method(:fetch_spec) do |tuple| + fetched_tuple = tuple + fetched_spec + end + + set = Gem::Resolver::APISet.new + set.instance_variable_set :@source, source + data = { + name: "rails", + number: "3.0.3", + suffix: "abc1234567", + dependencies: [], + requirements: { platform: ["= #{Gem::Platform.local}"] }, + } + + spec = Gem::Resolver::APISpecification.new set, data + + spec.fetch_development_dependencies + + assert_equal "rails-3.0.3-abc1234567.gemspec", fetched_tuple.spec_name + assert_equal [Gem::Dependency.new("a", "= 1", :development)], spec.dependencies + end + def test_installable_platform_eh set = Gem::Resolver::APISet.new data = { name: "a", number: "1", - platform: "ruby", + suffix: "ruby", dependencies: [], } @@ -171,7 +253,7 @@ def test_installable_platform_eh data = { name: "b", number: "1", - platform: "cpu-other_platform-1", + suffix: "cpu-other_platform-1", dependencies: [], } @@ -182,7 +264,7 @@ def test_installable_platform_eh data = { name: "c", number: "1", - platform: Gem::Platform.local.to_s, + suffix: Gem::Platform.local.to_s, dependencies: [], } @@ -196,7 +278,7 @@ def test_source data = { name: "a", number: "1", - platform: "ruby", + suffix: "ruby", dependencies: [], } @@ -215,7 +297,7 @@ def test_spec data = { name: "a", number: "1", - platform: "ruby", + suffix: "ruby", dependencies: [], } @@ -239,7 +321,7 @@ def test_spec_jruby_platform data = { name: "j", number: "1", - platform: "jruby", + suffix: "jruby", dependencies: [], } diff --git a/test/rubygems/test_gem_resolver_index_specification.rb b/test/rubygems/test_gem_resolver_index_specification.rb index ed9475f0cf81..3f09844f8f92 100644 --- a/test/rubygems/test_gem_resolver_index_specification.rb +++ b/test/rubygems/test_gem_resolver_index_specification.rb @@ -32,6 +32,21 @@ def test_initialize_platform assert_equal Gem::Platform.local, spec.platform end + def test_content_addressed_specs_with_different_addresses_are_distinct + set = Gem::Resolver::IndexSet.new + source = Gem::Source::Local.new + version = Gem::Version.new "3.0.3" + first = Gem::Resolver::IndexSpecification.new( + set, "rails", version, source, Gem::Platform.local, content_address: "abc1234567" + ) + second = Gem::Resolver::IndexSpecification.new( + set, "rails", version, source, Gem::Platform.local, content_address: "def1234567" + ) + + refute_equal first, second + refute_equal first.hash, second.hash + end + def test_install spec_fetcher do |fetcher| fetcher.gem "a", 2 diff --git a/test/rubygems/test_gem_safe_marshal.rb b/test/rubygems/test_gem_safe_marshal.rb index 1937c02be0cb..80f94647092a 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_unmarshall_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_unmarshall_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..ec6960ecad05 100644 --- a/test/rubygems/test_gem_source.rb +++ b/test/rubygems/test_gem_source.rb @@ -168,6 +168,72 @@ 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 + 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,platform:= x86_64-linux\n") + + spec = @source.load_specs(:released).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") + + assert_empty @source.load_specs(:released) + 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") + + assert_empty @source.load_specs(:released) + end + + def test_load_specs_compact_index_does_not_infer_ruby_abi_from_broad_ruby_requirement + 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,platform:= x86_64-linux\n") + + spec = @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 + versions_body = +"created_at: 2026-01-01T00:00:00Z\n---\na 1-abcdef12,1-fedcba98 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(<<~INFO) + --- + 1-abcdef12 |checksum:123,ruby:~> 3.3.0,platform:= x86_64-linux + 1-fedcba98 |checksum:456,ruby:~> 3.4.0,platform:= x86_64-linux + INFO + + specs = @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_load_specs_compact_index_latest_per_platform a1 = util_spec "a", "1" a2_java = util_spec "a", "2" do |s| 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..d0c5f1eb2466 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-target: platform=x86_64-linux + # stub: ext/a/extconf.rb + 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..1a350d588322 100644 --- a/test/rubygems/test_gem_stub_specification.rb +++ b/test/rubygems/test_gem_stub_specification.rb @@ -23,6 +23,26 @@ 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_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 +311,31 @@ 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-target: platform=x86_64-linux + # stub: ext/stub_with_target/extconf.rb + + 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_with_extension spec = File.join @gemhome, "specifications", "stub_e-2.gemspec" File.open spec, "w" do |io| From c04d2669e644863ebd0202184e9728ced7a3c82b Mon Sep 17 00:00:00 2001 From: Gira Chawda Date: Fri, 28 Aug 2026 00:27:43 -0400 Subject: [PATCH 3/9] Support content addressable gems in gem push Co-authored-by: Jenny Shen --- lib/rubygems/commands/push_command.rb | 86 +++- test/rubygems/helper.rb | 23 +- .../test_gem_commands_push_command.rb | 381 ++++++++++++++++++ 3 files changed, 479 insertions(+), 11 deletions(-) diff --git a/lib/rubygems/commands/push_command.rb b/lib/rubygems/commands/push_command.rb index 78fb844eb963..42005315168b 100644 --- a/lib/rubygems/commands/push_command.rb +++ b/lib/rubygems/commands/push_command.rb @@ -45,6 +45,18 @@ 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_option("--ruby-abi RUBY_ABI", + "Push a gem for a specific Ruby ABI", + " (e.g. 3.4)") do |value, options| + options[:ruby_abi] = value + end + add_option("--attestation FILE", "Push with sigstore attestations") do |value, options| options[:attestations] << value @@ -54,7 +66,14 @@ def initialize end def execute - gem_name = get_one_gem_name + validate_ruby_abi_option if options[:ruby_abi] + + gem_name = if gem_name_selectors? + resolve_gem_name(get_all_gem_names) + else + get_one_gem_name + end + default_gem_server, push_host = get_hosts_for(gem_name) @host = if @user_defined_host @@ -91,6 +110,71 @@ def send_gem(name) private + def gem_name_selectors? + options[:platform] || options[:ruby_abi] + end + + def resolve_gem_name(names) + candidates = names.filter_map do |name| + [name, Gem::Package.new(name).spec] + rescue Gem::Package::FormatError => e + alert_warning "Skipping #{name}: #{e.message}" + nil + end + + matches = candidates.select do |_, spec| + platform_matches?(spec) && ruby_matches?(spec) + end + + raise Gem::CommandLineError, "No gem matched #{gem_name_selector_description}" if matches.empty? + raise Gem::CommandLineError, multiple_matches_message(matches) if matches.length > 1 + + matches.first.first + end + + def validate_ruby_abi_option + return if /\A\d+\.\d+\z/.match?(options[:ruby_abi]) + + raise Gem::CommandLineError, "Ruby ABI must be in X.Y format" + end + + def multiple_matches_message(matches) + message = "Multiple gems matched #{gem_name_selector_description}: #{matches.map(&:first).join(", ")}" + suggestion = multiple_matches_suggestion(matches) + message += "\n#{suggestion}" if suggestion + message + end + + def multiple_matches_suggestion(matches) + if options[:platform] && !options[:ruby_abi] + ruby_abis = matches.filter_map {|_, spec| spec.ruby_abi }.uniq.sort + suggestions = [] + suggestions << "Specify --ruby-abi with one of: #{ruby_abis.join(", ")}" unless ruby_abis.empty? + suggestions << "To push a gem without a Ruby ABI, pass the exact filename." if matches.any? {|_, spec| spec.ruby_abi.nil? } + suggestions.join("\n") unless suggestions.empty? + elsif options[:ruby_abi] && !options[:platform] + platforms = matches.map {|_, spec| spec.platform.to_s }.uniq.sort + "Specify --platform with one of: #{platforms.join(", ")}" unless platforms.empty? + end + end + + def gem_name_selector_description + selectors = [] + selectors << "platform #{options[:platform]}" if options[:platform] + selectors << "Ruby ABI #{options[:ruby_abi]}" if options[:ruby_abi] + selectors.join(" and ") + end + + def platform_matches?(spec) + !options[:platform] || spec.platform == Gem::Platform.new(options[:platform]) + end + + def ruby_matches?(spec) + return true unless options[:ruby_abi] + + Gem::ContentAddress.applicable?(spec) && spec.ruby_abi == options[:ruby_abi] + end + def send_push_request(name, args) # Always honor explicit --attestation option # Auto-attestation is only supported on rubygems.org with GitHub Actions (not JRuby) diff --git a/test/rubygems/helper.rb b/test/rubygems/helper.rb index 73360ced5c84..c05ba4a3b57d 100644 --- a/test/rubygems/helper.rb +++ b/test/rubygems/helper.rb @@ -852,7 +852,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 @@ -866,12 +866,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 @@ -989,11 +991,12 @@ def util_spec(name, version = 2, deps = nil, *files) # :yields: specification ## # 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| @@ -1004,11 +1007,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 f8bb09d60062..48cff376c8b1 100644 --- a/test/rubygems/test_gem_commands_push_command.rb +++ b/test/rubygems/test_gem_commands_push_command.rb @@ -102,6 +102,387 @@ 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_ruby_abi_selector_rejects_invalid_ruby_abi + @cmd.options[:args] = [@path] + @cmd.options[:ruby_abi] = "3.4.5" + + error = assert_raise Gem::CommandLineError do + @cmd.execute + end + + assert_equal "Ruby ABI must be in X.Y format", error.message + 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 28c21d67043e75712c49a43b3aa52c271051dae8 Mon Sep 17 00:00:00 2001 From: Gira Chawda Date: Fri, 28 Aug 2026 00:27:43 -0400 Subject: [PATCH 4/9] Support content addressable gems in gem yank Co-authored-by: Jenny Shen --- lib/rubygems/commands/yank_command.rb | 28 ++++-- .../test_gem_commands_yank_command.rb | 85 ++++++++++++++++++- 2 files changed, 104 insertions(+), 9 deletions(-) diff --git a/lib/rubygems/commands/yank_command.rb b/lib/rubygems/commands/yank_command.rb index fbdc262549d1..4bccac53758a 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 @@ -35,6 +35,12 @@ def initialize add_platform_option("remove") add_otp_option + add_option("--ruby-abi RUBY_ABI", + "Yank a content-addressable gem for a specific Ruby ABI") do |value, options| + validate_ruby_abi(value) + options[:ruby_abi] = value + end + add_option("--host HOST", "Yank from another gemcutter-compatible host", " (e.g. https://rubygems.org)") do |value, options| @@ -50,20 +56,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 +78,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,12 +88,19 @@ 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 response end + def validate_ruby_abi(ruby_abi) + return if /\A\d+\.\d+\z/.match?(ruby_abi) + + raise Gem::OptionParser::InvalidArgument, "Ruby ABI must be in X.Y format" + end + def get_version_from_requirements(requirements) requirements.requirements.first[1].version rescue StandardError diff --git a/test/rubygems/test_gem_commands_yank_command.rb b/test/rubygems/test_gem_commands_yank_command.rb index 457a0e65c8a9..5fbfdeeaaa3d 100644 --- a/test/rubygems/test_gem_commands_yank_command.rb +++ b/test/rubygems/test_gem_commands_yank_command.rb @@ -27,23 +27,32 @@ 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 end end + def test_handle_options_invalid_ruby_abi + e = assert_raise Gem::OptionParser::InvalidArgument do + @cmd.handle_options %w[a --version 1.0 --ruby-abi 3] + end + + assert_match(/Ruby ABI must be in X.Y format/, e.message) + end + def test_execute yank_uri = "http://example/api/v1/gems/yank" @fetcher.data[yank_uri] = HTTPResponseFactory.create(body: "Successfully yanked", code: 200, msg: "OK") @@ -68,6 +77,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 404c72521db22686b4edd13b3643aca28927ed05 Mon Sep 17 00:00:00 2001 From: Harriet Oughton Date: Fri, 28 Aug 2026 00:27:44 -0400 Subject: [PATCH 5/9] Support content addressable gems in remote queries and compact-index metadata Co-authored-by: Gira Chawda Co-authored-by: Jenny Shen --- lib/rubygems/name_tuple.rb | 18 +- lib/rubygems/query_utils.rb | 117 ++++++-- lib/rubygems/source.rb | 124 ++++++--- test/rubygems/helper.rb | 29 +- .../test_gem_commands_info_command.rb | 249 ++++++++++++++++++ .../test_gem_commands_list_command.rb | 173 ++++++++++++ .../test_gem_commands_search_command.rb | 197 ++++++++++++++ test/rubygems/test_gem_resolver_api_set.rb | 3 +- test/rubygems/test_gem_safe_marshal.rb | 4 +- test/rubygems/test_gem_source.rb | 83 ++++-- 10 files changed, 897 insertions(+), 100 deletions(-) diff --git a/lib/rubygems/name_tuple.rb b/lib/rubygems/name_tuple.rb index ce93cb70d593..a89119f05858 100644 --- a/lib/rubygems/name_tuple.rb +++ b/lib/rubygems/name_tuple.rb @@ -48,15 +48,13 @@ def self.null # of Gem::Specification#full_name. def full_name - full_name = "#{@name}-#{@version}" - suffix = @content_address || platform_suffix - suffix ? "#{full_name}-#{suffix}" : full_name - end - - private def platform_suffix # :nodoc: - return if @platform.nil? || @platform.empty? || @platform == Gem::Platform::RUBY - - @platform + if @content_address + "#{@name}-#{@version}-#{@content_address}" + elsif @platform.nil? || @platform.empty? || @platform == Gem::Platform::RUBY + "#{@name}-#{@version}" + else + "#{@name}-#{@version}-#{@platform}" + end end ## @@ -136,6 +134,6 @@ def ==(other) alias_method :eql?, :== def hash - [@name, @version, @platform, @content_address, @ruby_abi].hash + to_a.hash end end 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/source.rb b/lib/rubygems/source.rb index cf0a3fc4b48f..f0bda9c9dc32 100644 --- a/lib/rubygems/source.rb +++ b/lib/rubygems/source.rb @@ -185,6 +185,37 @@ def compact_index_client # :nodoc: end end + def decode_content_addressable_tuples(tuples, latest: false) # :nodoc: + 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,36 +321,16 @@ def load_compact_index_specs(type) tuples = [] versions.each_value do |rows| - info_rows = nil - - gem_tuples = rows.filter_map do |name, version_string, suffix| + 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) suffix ||= "ruby" - platform = suffix - content_address = nil - ruby_abi = nil - - if Gem::ContentAddress.match?(suffix) - info_rows ||= compact_index_info_rows(name) - metadata = content_addressable_metadata(info_rows, version_string, suffix) - next unless metadata - - platform = metadata[:platform] - content_address = suffix - ruby_abi = metadata[:ruby_abi] - end + content_address = suffix if Gem::ContentAddress.match?(suffix) - Gem::NameTuple.new( - name, - version, - platform, - content_address: content_address, - ruby_abi: ruby_abi - ) + Gem::NameTuple.new(row_name, version, suffix, content_address: content_address) end gem_tuples = max_versions_by_platform(gem_tuples) if type == :latest @@ -343,27 +354,64 @@ def compact_index_info_rows(name) [] end - def compact_index_info_row(info_rows, version, suffix) - info_rows.find do |row| - row_version = row[Gem::CompactIndexClient::INFO_VERSION] - row_suffix = row[Gem::CompactIndexClient::INFO_PLATFORM] + 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 - row_version == version && row_suffix == suffix + def eql?(other) + other.is_a?(ContentAddressableInfo) && + version == other.version && + suffix == other.suffix end end - def content_addressable_metadata(info_rows, version, suffix) - info_row = compact_index_info_row(info_rows, version, suffix) - return unless info_row + def content_addressable_tuples(name, rows) + metadata = content_addressable_metadata(name, rows) - requirements = compact_index_requirements(info_row) - platform = required_platform_from(requirements[:platform]) - return unless platform + 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 - { - platform: platform, - ruby_abi: ruby_abi_from(requirements[:ruby]), - } + available_rows & wanted_rows end def compact_index_requirements(info_row) diff --git a/test/rubygems/helper.rb b/test/rubygems/helper.rb index c05ba4a3b57d..ec93b96b2927 100644 --- a/test/rubygems/helper.rb +++ b/test/rubygems/helper.rb @@ -989,6 +989,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. When +ruby_abi+ is set, @@ -1208,7 +1226,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" @@ -1230,7 +1248,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)}" @@ -1243,6 +1265,9 @@ def util_compact_index_info_line(spec, created_at = nil) unless spec.required_rubygems_version.nil? || spec.required_rubygems_version.none? metadata << ",rubygems:#{util_compact_index_requirement(spec.required_rubygems_version)}" end + if spec.content_address + metadata << ",platform:= #{spec.platform}" + end metadata << ",created_at:#{created_at}" if created_at "#{version} #{dependencies}|#{metadata}" diff --git a/test/rubygems/test_gem_commands_info_command.rb b/test/rubygems/test_gem_commands_info_command.rb index dab7cfb836b7..3bd6d802f5eb 100644 --- a/test/rubygems/test_gem_commands_info_command.rb +++ b/test/rubygems/test_gem_commands_info_command.rb @@ -42,6 +42,255 @@ def test_execute assert_match "", @ui.error end + def test_execute_remote_unscoped_content_addressable_gems_do_not_fetch_metadata + spec_fetcher {} + + spec_a = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") do |s| + s.summary = "summary a" + s.homepage = "http://example.com" + s.authors = ["A User"] + end + spec_b = util_ca_spec("b", "1", "fedcba98", ruby_abi: "3.4", platform: "arm64-darwin") do |s| + s.summary = "summary b" + s.homepage = "http://example.com" + s.authors = ["B User"] + end + util_setup_compact_index(spec_a, spec_b) + + path = "#{@gem_repo}quick/Marshal.#{Gem.marshal_version}/a-1-abcdef12.gemspec.rz" + @fetcher.data[path] = Zlib::Deflate.deflate(Marshal.dump(spec_a)) + path = "#{@gem_repo}quick/Marshal.#{Gem.marshal_version}/b-1-fedcba98.gemspec.rz" + @fetcher.data[path] = Zlib::Deflate.deflate(Marshal.dump(spec_b)) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[--remote] + + use_ui @ui do + @cmd.execute + end + + assert_include @ui.output, "a (1)" + assert_include @ui.output, "b (1)" + refute_match "Ruby ABI", @ui.output + refute @fetcher.requests.any? {|req| req.path.start_with?("/info/") } + end + + def test_execute_remote_content_addressable_gem_displays_real_platform_and_ruby_abi + spec_fetcher {} + + spec = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") do |s| + s.summary = "this is a summary" + s.homepage = "http://example.com" + s.authors = ["A User"] + end + util_setup_compact_index(spec) + + path = "#{@gem_repo}quick/Marshal.#{Gem.marshal_version}/a-1-abcdef12.gemspec.rz" + @fetcher.data[path] = Zlib::Deflate.deflate(Marshal.dump(spec)) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[a --remote] + + use_ui @ui do + @cmd.execute + end + + assert_include @ui.output, "Platforms:\n" + assert_include @ui.output, " x86_64-linux Ruby ABI: 3.3\n" + refute_match "abcdef12", @ui.output + end + + def test_execute_remote_content_addressable_gem_displays_ruby_abis_next_to_their_platforms + spec_fetcher {} + + spec = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") do |s| + s.summary = "this is a summary" + s.homepage = "http://example.com" + s.authors = ["A User"] + end + spec_musl = util_ca_spec("a", "1", "fedcba98", ruby_abi: "3.4", platform: "x86_64-linux-musl") do |s| + s.summary = "this is a summary" + s.homepage = "http://example.com" + s.authors = ["A User"] + end + util_setup_compact_index(spec, spec_musl) + + path = "#{@gem_repo}quick/Marshal.#{Gem.marshal_version}/a-1-abcdef12.gemspec.rz" + @fetcher.data[path] = Zlib::Deflate.deflate(Marshal.dump(spec)) + path = "#{@gem_repo}quick/Marshal.#{Gem.marshal_version}/a-1-fedcba98.gemspec.rz" + @fetcher.data[path] = Zlib::Deflate.deflate(Marshal.dump(spec_musl)) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[a --remote] + + use_ui @ui do + @cmd.execute + end + + assert_include @ui.output, "Platforms:\n" + assert_include @ui.output, " x86_64-linux Ruby ABI: 3.3\n" + assert_include @ui.output, " x86_64-linux-musl Ruby ABI: 3.4\n" + refute_match "Ruby ABIs: 3.3, 3.4", @ui.output + refute_match "abcdef12", @ui.output + refute_match "fedcba98", @ui.output + end + + def test_execute_remote_content_addressable_and_platform_gems_display_together + spec_fetcher {} + + spec_v1 = util_spec "a", "1" do |s| + s.platform = "x86_64-linux" + s.summary = "this is a summary" + s.homepage = "http://example.com" + s.authors = ["A User"] + end + spec_v2 = util_ca_spec("a", "2", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") do |s| + s.summary = "this is a summary" + s.homepage = "http://example.com" + s.authors = ["A User"] + end + spec_v3 = util_ca_spec("a", "3", "fedcba98", ruby_abi: "3.4", platform: "arm64-darwin") do |s| + s.summary = "this is a summary" + s.homepage = "http://example.com" + s.authors = ["A User"] + end + util_setup_compact_index(spec_v1, spec_v2, spec_v3) + + path = "#{@gem_repo}quick/Marshal.#{Gem.marshal_version}/a-3-fedcba98.gemspec.rz" + @fetcher.data[path] = Zlib::Deflate.deflate(Marshal.dump(spec_v3)) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[a --remote --all] + + use_ui @ui do + @cmd.execute + end + + assert_include @ui.output, "a (3, 2, 1)" + assert_include @ui.output, "Platforms:\n" + assert_include @ui.output, " 1: x86_64-linux\n" + assert_include @ui.output, " 2: x86_64-linux Ruby ABI: 3.3\n" + assert_include @ui.output, " 3: arm64-darwin Ruby ABI: 3.4\n" + refute_match "abcdef12", @ui.output + refute_match "fedcba98", @ui.output + end + + def test_execute_remote_content_addressable_gem_displays_multiple_ruby_abis_on_same_platform + spec_fetcher {} + + spec = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") do |s| + s.summary = "this is a summary" + s.homepage = "http://example.com" + s.authors = ["A User"] + end + spec_other = util_ca_spec("a", "1", "fedcba98", ruby_abi: "3.4", platform: "x86_64-linux") do |s| + s.summary = "this is a summary" + s.homepage = "http://example.com" + s.authors = ["A User"] + end + util_setup_compact_index(spec, spec_other) + + path = "#{@gem_repo}quick/Marshal.#{Gem.marshal_version}/a-1-abcdef12.gemspec.rz" + @fetcher.data[path] = Zlib::Deflate.deflate(Marshal.dump(spec)) + path = "#{@gem_repo}quick/Marshal.#{Gem.marshal_version}/a-1-fedcba98.gemspec.rz" + @fetcher.data[path] = Zlib::Deflate.deflate(Marshal.dump(spec_other)) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[a --remote] + + use_ui @ui do + @cmd.execute + end + + assert_include @ui.output, "Platforms:\n" + assert_include @ui.output, "x86_64-linux Ruby ABI: 3.3, 3.4\n" + refute_match "abcdef12", @ui.output + refute_match "fedcba98", @ui.output + end + + def test_execute_remote_content_addressable_platform_and_source_gems_display_together + spec_fetcher {} + + spec_v1 = util_spec "a", "1" do |s| + s.platform = "x86_64-linux" + s.summary = "this is a summary" + s.homepage = "http://example.com" + s.authors = ["A User"] + end + spec_v2 = util_ca_spec("a", "2", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") do |s| + s.summary = "this is a summary" + s.homepage = "http://example.com" + s.authors = ["A User"] + end + spec_v3 = util_ca_spec("a", "3", "fedcba98", ruby_abi: "3.4", platform: "arm64-darwin") do |s| + s.summary = "this is a summary" + s.homepage = "http://example.com" + s.authors = ["A User"] + end + spec_v4 = util_spec "a", "4" do |s| + s.summary = "this is a summary" + s.homepage = "http://example.com" + s.authors = ["A User"] + end + util_setup_compact_index(spec_v1, spec_v2, spec_v3, spec_v4) + + path = "#{@gem_repo}quick/Marshal.#{Gem.marshal_version}/a-3-fedcba98.gemspec.rz" + @fetcher.data[path] = Zlib::Deflate.deflate(Marshal.dump(spec_v3)) + path = "#{@gem_repo}quick/Marshal.#{Gem.marshal_version}/a-4.gemspec.rz" + @fetcher.data[path] = Zlib::Deflate.deflate(Marshal.dump(spec_v4)) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[a --remote --all] + + use_ui @ui do + @cmd.execute + end + + assert_include @ui.output, "a (4, 3, 2, 1)" + assert_include @ui.output, "Platforms:\n" + assert_include @ui.output, " 1: x86_64-linux\n" + assert_include @ui.output, " 2: x86_64-linux Ruby ABI: 3.3\n" + assert_include @ui.output, " 3: arm64-darwin Ruby ABI: 3.4\n" + refute_match " 4:", @ui.output + refute_match "abcdef12", @ui.output + refute_match "fedcba98", @ui.output + end + + def test_execute_remote_platform_gem_displays_version_once_for_multiple_platforms + spec_fetcher {} + + spec_e1 = util_spec "e", "1" do |s| + s.platform = "x86_64-linux" + s.summary = "summary e" + s.homepage = "http://example.com" + s.authors = ["E User"] + end + + spec_e2 = util_spec "e", "1" do |s| + s.platform = "arm64-darwin" + s.summary = "summary e" + s.homepage = "http://example.com" + s.authors = ["E User"] + end + + util_setup_compact_index(spec_e1, spec_e2) + + path = "#{@gem_repo}quick/Marshal.#{Gem.marshal_version}/e-1-x86_64-linux.gemspec.rz" + @fetcher.data[path] = Zlib::Deflate.deflate(Marshal.dump(spec_e1)) + path = "#{@gem_repo}quick/Marshal.#{Gem.marshal_version}/e-1-arm64-darwin.gemspec.rz" + @fetcher.data[path] = Zlib::Deflate.deflate(Marshal.dump(spec_e2)) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[e --remote] + + use_ui @ui do + @cmd.execute + end + + assert_include @ui.output, "e (1)" + assert_include @ui.output, "Platforms: arm64-darwin, x86_64-linux\n" + end + def test_execute_with_version_flag spec_fetcher do |fetcher| fetcher.spec "coolgem", "1.0" diff --git a/test/rubygems/test_gem_commands_list_command.rb b/test/rubygems/test_gem_commands_list_command.rb index 0b52b54e7748..ec22dfb0700e 100644 --- a/test/rubygems/test_gem_commands_list_command.rb +++ b/test/rubygems/test_gem_commands_list_command.rb @@ -31,6 +31,179 @@ def test_execute_installed assert_equal "", @ui.error end + def test_execute_remote_unscoped_content_addressable_gems_do_not_fetch_metadata + spec_fetcher {} + + a = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") + b = util_ca_spec("b", "1", "fedcba98", ruby_abi: "3.4", platform: "arm64-darwin") + util_setup_compact_index(a, b) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[--remote] + + use_ui @ui do + @cmd.execute + end + + assert_include @ui.output, "a (1 abcdef12)" + assert_include @ui.output, "b (1 fedcba98)" + refute_match "Ruby ABI", @ui.output + refute @fetcher.requests.any? {|req| req.path.start_with?("/info/") } + end + + def test_execute_remote_unscoped_all_content_addressable_gems_do_not_fetch_metadata + spec_fetcher {} + + a1 = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") + a2 = util_ca_spec("a", "2", "fedcba98", ruby_abi: "3.4", platform: "arm64-darwin") + util_setup_compact_index(a1, a2) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[--remote --all] + + use_ui @ui do + @cmd.execute + end + + assert_include @ui.output, "a (2 fedcba98, 1 abcdef12)" + refute_match "Ruby ABI", @ui.output + refute @fetcher.requests.any? {|req| req.path.start_with?("/info/") } + end + + def test_execute_remote_content_addressable_gem_displays_real_platform_and_ruby_abi + spec_fetcher {} + + a = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") + b = util_ca_spec("b", "1", "fedcba98", ruby_abi: "3.4", platform: "x86_64-linux") + util_setup_compact_index(a, b) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[a --remote] + + use_ui @ui do + @cmd.execute + end + + assert_include @ui.output, "a (1 Platform: x86_64-linux, Ruby ABI: 3.3)" + refute_match "abcdef12", @ui.output + refute @fetcher.requests.any? {|req| req.path.end_with?("/info/b") } + end + + def test_execute_remote_content_addressable_gems_displays_ruby_abis_next_to_their_platforms + spec_fetcher {} + + a1 = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") + a2 = util_ca_spec("a", "1", "fedcba98", ruby_abi: "3.4", platform: "arm64-darwin") + util_setup_compact_index(a1, a2) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[a --remote] + + use_ui @ui do + @cmd.execute + end + + expected = <<~OUTPUT.chomp + a (1 Platform: arm64-darwin, Ruby ABI: 3.4 + 1 Platform: x86_64-linux, Ruby ABI: 3.3) + OUTPUT + + assert_include @ui.output, expected + refute_match "abcdef12", @ui.output + refute_match "fedcba98", @ui.output + end + + def test_execute_remote_content_addressable_gems_displays_multiple_ruby_abis_on_the_same_line + spec_fetcher {} + + a1 = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") + a2 = util_ca_spec("a", "1", "fedcba98", ruby_abi: "3.4", platform: "x86_64-linux") + util_setup_compact_index(a1, a2) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[a --remote] + + use_ui @ui do + @cmd.execute + end + + assert_include @ui.output, "a (1 Platform: x86_64-linux, Ruby ABI: 3.3, 3.4)" + refute_match "abcdef12", @ui.output + refute_match "fedcba98", @ui.output + end + + def test_execute_remote_content_addressable_gems_displays_multiple_versions_on_separate_lines + spec_fetcher {} + + a1 = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") + a2 = util_ca_spec("a", "2", "fedcba98", ruby_abi: "3.4", platform: "x86_64-linux") + a3 = util_ca_spec("a", "3", "12345678", ruby_abi: "3.4", platform: "arm64-darwin") + util_setup_compact_index(a1, a2, a3) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[a --remote] + + use_ui @ui do + @cmd.execute + end + + expected = <<~OUTPUT.chomp + a (3 Platform: arm64-darwin, Ruby ABI: 3.4 + 2 Platform: x86_64-linux, Ruby ABI: 3.4 + 1 Platform: x86_64-linux, Ruby ABI: 3.3) + OUTPUT + + assert_include @ui.output, expected + refute_match "abcdef12", @ui.output + refute_match "fedcba98", @ui.output + refute_match "12345678", @ui.output + end + + def test_execute_remote_content_addressable_and_platform_gems_display_together + spec_fetcher {} + + a1 = util_spec("a", 1) {|s| s.platform = Gem::Platform.new("x86_64-linux") } + a2 = util_ca_spec("a", "2", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") + a3 = util_ca_spec("a", "3", "fedcba98", ruby_abi: "3.4", platform: "arm64-darwin") + a4 = util_spec("a", 4) + util_setup_compact_index(a1, a2, a3, a4) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[a --remote --all] + + use_ui @ui do + @cmd.execute + end + + expected = <<~OUTPUT.chomp + a (4 + 3 Platform: arm64-darwin, Ruby ABI: 3.4 + 2 Platform: x86_64-linux, Ruby ABI: 3.3 + 1 Platform: x86_64-linux) + OUTPUT + + assert_include @ui.output, expected + refute_match "abcdef12", @ui.output + refute_match "fedcba98", @ui.output + end + + def test_execute_remote_platform_gem_displays_version_once_for_multiple_platforms + spec_fetcher {} + + e1 = util_spec("e", 1) {|s| s.platform = Gem::Platform.new("x86_64-linux") } + e2 = util_spec("e", 1) {|s| s.platform = Gem::Platform.new("arm64-darwin") } + util_setup_compact_index(e1, e2) + Gem::SpecFetcher.fetcher = nil + + @cmd.handle_options %w[e --remote] + + use_ui @ui do + @cmd.execute + end + + assert_include @ui.output, "e (1 arm64-darwin x86_64-linux)" + end + def test_execute_normal_gem_shadowing_default_gem c1_default = new_default_spec "c", 1 install_default_gems c1_default diff --git a/test/rubygems/test_gem_commands_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_resolver_api_set.rb b/test/rubygems/test_gem_resolver_api_set.rb index 70b9ac3834f1..5df6eed82ea6 100644 --- a/test/rubygems/test_gem_resolver_api_set.rb +++ b/test/rubygems/test_gem_resolver_api_set.rb @@ -58,7 +58,8 @@ def test_find_all def test_find_all_content_addressed spec_fetcher - @fetcher.data["#{@dep_uri}a"] = util_compact_index_response("---\n1-ab12345678 |platform:= #{Gem::Platform.local}\n") + 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 diff --git a/test/rubygems/test_gem_safe_marshal.rb b/test/rubygems/test_gem_safe_marshal.rb index 80f94647092a..c34d8570c6ec 100644 --- a/test/rubygems/test_gem_safe_marshal.rb +++ b/test/rubygems/test_gem_safe_marshal.rb @@ -344,7 +344,7 @@ def test_rational end end - def test_name_tuple_unmarshall_content_addressable_metadata + def test_name_tuple_unmarshal_content_addressable_metadata tuple = Gem::NameTuple.new( "a", Gem::Version.new("1"), @@ -363,7 +363,7 @@ def test_name_tuple_unmarshall_content_addressable_metadata assert_equal "a-1-abcdef12", unmarshalled_tuple.full_name end - def test_name_tuple_unmarshall_legacy_payload_without_content_addressable_metadata + 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") diff --git a/test/rubygems/test_gem_source.rb b/test/rubygems/test_gem_source.rb index ec6960ecad05..97731be4e9bd 100644 --- a/test/rubygems/test_gem_source.rb +++ b/test/rubygems/test_gem_source.rb @@ -169,13 +169,13 @@ def test_load_specs_compact_index end def test_load_specs_compact_index_content_addressable_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("---\n1-abcdef12 |checksum:123,ruby:~> 3.3.0,platform:= x86_64-linux\n") + spec = util_ca_spec("a", "1", "abcdef12", ruby_abi: "3.3", platform: "x86_64-linux") + util_setup_compact_index(spec) - spec = @source.load_specs(:released).first + 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 @@ -190,7 +190,9 @@ def test_load_specs_compact_index_skips_content_addressable_rows_without_metadat @fetcher.data["#{@gem_repo}versions"] = versions_response @fetcher.data["#{@gem_repo}info/a"] = util_compact_index_response("---\n") - assert_empty @source.load_specs(:released) + 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 @@ -200,17 +202,16 @@ def test_load_specs_compact_index_skips_content_addressable_rows_without_require @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") - assert_empty @source.load_specs(:released) + 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 - 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,platform:= x86_64-linux\n") + spec = util_ca_spec("a", "1", "abcdef12", required_ruby_version: ">= 3.3", platform: "x86_64-linux") + util_setup_compact_index(spec) - spec = @source.load_specs(:released).first + spec = @source.decode_content_addressable_tuples(@source.load_specs(:released)).first assert_equal "x86_64-linux", spec.platform assert_equal "abcdef12", spec.content_address @@ -218,20 +219,56 @@ def test_load_specs_compact_index_does_not_infer_ruby_abi_from_broad_ruby_requir end def test_load_specs_compact_index_latest_keeps_content_addressable_ruby_abi_variants - versions_body = +"created_at: 2026-01-01T00:00:00Z\n---\na 1-abcdef12,1-fedcba98 0000\n" + 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(<<~INFO) - --- - 1-abcdef12 |checksum:123,ruby:~> 3.3.0,platform:= x86_64-linux - 1-fedcba98 |checksum:456,ruby:~> 3.4.0,platform:= x86_64-linux - INFO + @fetcher.data["#{@gem_repo}info/a"] = util_compact_index_response("---\n1-abcdef12 |checksum:123,platform:= x86_64-linux\n") - specs = @source.load_specs(:latest) + specs = @source.decode_content_addressable_tuples(@source.load_specs(:released)) - 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 + assert_empty specs end def test_load_specs_compact_index_latest_per_platform From 4dd1d3bc727100210ee64aa2798af6385b1f436e Mon Sep 17 00:00:00 2001 From: Harriet Oughton Date: Fri, 28 Aug 2026 00:27:47 -0400 Subject: [PATCH 6/9] Support content addressable gems in bundle install, lockfile, and local cache Co-authored-by: Jenny Shen --- lib/bundler/endpoint_specification.rb | 26 +- lib/bundler/fetcher.rb | 6 +- lib/bundler/lazy_specification.rb | 19 +- lib/bundler/lockfile_parser.rb | 16 +- lib/bundler/match_platform.rb | 18 +- 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/specification.rb | 2 +- spec/bundler/endpoint_specification_spec.rb | 77 +++- 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 | 337 ++++++++++++++++++ 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 | 2 +- .../helpers/compact_index_cooldown.rb | 13 - .../artifice/helpers/compact_index_v2.rb | 83 +++++ spec/support/builders.rb | 21 +- .../rubygems/test_gem_dependency_installer.rb | 3 + 27 files changed, 777 insertions(+), 123 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 5d0485ee558e..5613667bf816 100644 --- a/lib/bundler/endpoint_specification.rb +++ b/lib/bundler/endpoint_specification.rb @@ -5,24 +5,32 @@ 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 + else + @platform = Gem::Platform.new(suffix) + end end def insecurely_materialized? @@ -147,7 +155,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 +192,8 @@ def parse_metadata(data) @required_ruby_version = Gem::Requirement.new(v) when "created_at" @created_at = parse_created_at(v.is_a?(Array) ? v.last : v)&.freeze + when "platform" + @required_platform = required_platform_from(Array(v).last) end end rescue StandardError => e @@ -210,5 +221,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 13d7588cd011..d006f0bef6e8 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}" @@ -77,7 +80,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) @@ -114,7 +117,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 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..9ed3ce9b31d9 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,24 @@ 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.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_ruby?) + compatible.any? ? compatible : non_addressable + end + def self.select_best_local_platform_match(specs, force_ruby: false, locked_platforms: nil) local = Bundler.local_platform - matching = select_all_platform_match(specs, local, force_ruby: force_ruby).filter_map {|spec| spec.materialized_for_installation(locked_platforms) } + matching = select_all_platform_match(specs, local, force_ruby: force_ruby) + matching = prefer_content_addressable(matching) + matching = matching.filter_map {|spec| spec.materialized_for_installation(locked_platforms) } Gem::Platform.sort_best_platform_match(matching, local) end @@ -31,7 +46,6 @@ def self.select_all_platform_match(specs, platform, force_ruby: false, prefer_lo locked_originally = matching.select {|spec| spec.is_a?(::Bundler::LazySpecification) } return locked_originally if locked_originally.any? end - matching end diff --git a/lib/bundler/remote_specification.rb b/lib/bundler/remote_specification.rb index dcaaf6af2e61..bf899693f536 100644 --- a/lib/bundler/remote_specification.rb +++ b/lib/bundler/remote_specification.rb @@ -10,11 +10,11 @@ class RemoteSpecification include MatchPlatform include Comparable - attr_reader :name, :version, :platform + attr_reader :name, :version, :platform, :content_address attr_writer :dependencies attr_accessor :source, :remote, :locked_platform, :created_at - def initialize(name, version, platform, spec_fetcher) + def initialize(name, version, platform, spec_fetcher, content_address: nil) @name = name @version = Gem::Version.create version @original_platform = platform || Gem::Platform::RUBY @@ -22,6 +22,7 @@ def initialize(name, version, platform, spec_fetcher) @spec_fetcher = spec_fetcher @dependencies = nil @locked_platform = nil + @content_address = content_address end def insecurely_materialized? @@ -35,7 +36,9 @@ def fetch_platform end def full_name - @full_name ||= if @platform == Gem::Platform::RUBY + @full_name ||= if Gem::ContentAddress.match?(@content_address) && @platform != Gem::Platform::RUBY + "#{@name}-#{@version}-#{@content_address}" + elsif @platform == Gem::Platform::RUBY "#{@name}-#{@version}" else "#{@name}-#{@version}-#{@platform}" diff --git a/lib/bundler/resolver.rb b/lib/bundler/resolver.rb index a164a4193541..72c6a32afb34 100644 --- a/lib/bundler/resolver.rb +++ b/lib/bundler/resolver.rb @@ -275,7 +275,7 @@ def incompatibilities_for(package, version) def all_versions_for(package) name = package.name - results = (@base[name] + filter_specs(@all_specs[name], package)).uniq {|spec| [spec.version.hash, spec.platform] } + results = (@base[name] + filter_specs(@all_specs[name], package)).uniq {|spec| [spec.version.hash, spec.platform, spec.content_address] } if name == "bundler" && !bundler_pinned_to_current_version? bundler_spec = Gem.loaded_specs["bundler"] diff --git a/lib/bundler/rubygems_ext.rb b/lib/bundler/rubygems_ext.rb index 4ad2bdf46f04..623de1836e77 100644 --- a/lib/bundler/rubygems_ext.rb +++ b/lib/bundler/rubygems_ext.rb @@ -13,7 +13,34 @@ # `Gem::Source` from the redefined `Gem::Specification#source`. require "rubygems/source" +# Can be removed once RubyGems 4.0.0 support is dropped +unless Gem::BasicSpecification.method_defined?(:content_address) + Gem::BasicSpecification.attr_accessor :content_address +end + +# Can be removed once RubyGems 4.0.0 support is dropped +unless Gem::NameTuple.method_defined?(:content_address) + Gem::NameTuple.attr_reader :content_address +end + module Gem + # Can be removed once RubyGems 4.0.0 support is dropped + unless defined?(Gem::ContentAddress) + module ContentAddress + def self.match?(token) + false + end + + def self.applicable?(spec) + false + end + + def self.content_addressed?(spec) + false + end + end + end + # Can be removed once RubyGems 3.5.11 support is dropped unless Gem.respond_to?(:freebsd_platform?) def self.freebsd_platform? @@ -417,7 +444,8 @@ class NameTuple unless Gem::NameTuple.new("a", Gem::Version.new("1"), Gem::Platform.new("x86_64-linux")).platform.is_a?(String) alias_method :initialize_with_platform, :initialize - def initialize(name, version, platform = Gem::Platform::RUBY) + def initialize(name, version, platform = Gem::Platform::RUBY, content_address = nil) + @content_address = content_address if Gem::Platform === platform initialize_with_platform(name, version, platform.to_s) else @@ -426,7 +454,18 @@ def initialize(name, version, platform = Gem::Platform::RUBY) end end + unless instance_method(:initialize).parameters.any? {|kind, name| kind == :key && name == :content_address } + alias_method :initialize_without_content_address, :initialize + + def initialize(name, version, platform = Gem::Platform::RUBY, content_address: nil) + initialize_without_content_address(name, version, platform) + @content_address = content_address + end + end + def lock_name + return "#{name} (#{version}-#{content_address})" if Gem::ContentAddress.match?(content_address) + if platform == Gem::Platform::RUBY "#{name} (#{version})" else diff --git a/lib/bundler/rubygems_gem_installer.rb b/lib/bundler/rubygems_gem_installer.rb index d8c50556c531..e33f135c59e4 100644 --- a/lib/bundler/rubygems_gem_installer.rb +++ b/lib/bundler/rubygems_gem_installer.rb @@ -4,6 +4,11 @@ module Bundler class RubyGemsGemInstaller < Gem::Installer + # Can be removed once RubyGems 4.0.0 support is dropped + unless private_method_defined?(:assign_content_address) + private def assign_content_address; end + end + # Cap how many jobserver slots a single gem's `make` may grab so that one # gem with many recipes doesn't starve the others sharing the pool. Beyond # a handful of jobs the extra parallelism rarely pays off in practice. @@ -14,6 +19,8 @@ def check_executable_overwrite(filename) end def install + assign_content_address + pre_install_checks run_pre_install_hooks diff --git a/lib/bundler/rubygems_integration.rb b/lib/bundler/rubygems_integration.rb index e04ef232592a..06d17d0cacdc 100644 --- a/lib/bundler/rubygems_integration.rb +++ b/lib/bundler/rubygems_integration.rb @@ -144,7 +144,12 @@ def ext_lock def spec_from_gem(path) require "rubygems/package" - Gem::Package.new(path).spec + package = Gem::Package.new(path) + spec = package.spec + if package.respond_to?(:content_address) + spec.content_address = package.content_address + end + spec end def build_gem(gem_dir, spec) diff --git a/lib/bundler/source/rubygems.rb b/lib/bundler/source/rubygems.rb index 22b9ca821cab..ad5ba0b9cc8a 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/specification.rb b/lib/rubygems/specification.rb index c11d4e689a5c..374ef5556533 100644 --- a/lib/rubygems/specification.rb +++ b/lib/rubygems/specification.rb @@ -2135,7 +2135,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 229ea34dda66..3f38b41ac861 100644 --- a/spec/bundler/endpoint_specification_spec.rb +++ b/spec/bundler/endpoint_specification_spec.rb @@ -3,12 +3,12 @@ RSpec.describe Bundler::EndpointSpecification do let(:name) { "foo" } let(:version) { "1.0.0" } - let(:platform) { Gem::Platform::RUBY } + let(:suffix) { Gem::Platform::RUBY } let(:dependencies) { [] } let(:spec_fetcher) { double(:spec_fetcher) } let(:metadata) { nil } - subject(:spec) { described_class.new(name, version, platform, spec_fetcher, dependencies, metadata) } + subject(:spec) { described_class.new(name, version, suffix, spec_fetcher, dependencies, metadata) } def with_tz(tz) orig_tz = ENV["TZ"] @@ -44,6 +44,37 @@ def with_tz(tz) end describe "#parse_metadata" do + context "when a content-addressed suffix has platform metadata" do + let(:suffix) { "abc1234567" } + let(:metadata) { { "platform" => ["= arm64-darwin"], "ruby" => [">= 3.0.0"] } } + + it "uses the platform from the metadata" do + expect(spec.platform).to eq(Gem::Platform.new("arm64-darwin")) + expect(spec.content_address).to eq("abc1234567") + end + + it "includes the content address in full_name" do + expect(spec.full_name).to eq("foo-1.0.0-abc1234567") + end + end + + context "when the suffix is an ordinary platform" do + let(:suffix) { "x86_64-linux" } + + it "uses the suffix as the platform without a content address" do + expect(spec.platform).to eq(Gem::Platform.new("x86_64-linux")) + expect(spec.content_address).to be_nil + end + end + + context "when a content-addressed suffix has no platform metadata" do + let(:suffix) { "abc1234567" } + + it "treats the suffix as a platform without a content address" do + expect(spec.content_address).to be_nil + end + end + context "when the metadata has malformed requirements" do let(:metadata) { { "rubygems" => ">\n" } } it "raises a helpful error message" do @@ -143,34 +174,48 @@ def with_tz(tz) end describe "#required_ruby_version" do - context "required_ruby_version is already set on endpoint specification" do - existing_value = "already set value" - let(:required_ruby_version) { existing_value } + context "when the specification is content-addressed" do + let(:suffix) { "abc1234567" } + let(:metadata) { { "platform" => ["= arm64-darwin"] } } - it "should return the current value when already set on endpoint specification" do - expect(spec.required_ruby_version). eql?(existing_value) + it "fetches the remote specification using the content address" do + remote_spec = double(:remote_spec, required_ruby_version: nil) + + expect(spec_fetcher).to receive(:fetch_spec). + with([name, Gem::Version.new(version), suffix]). + and_return(remote_spec) + + spec.send(:_remote_specification) end end - it "should return the remote spec value when not set on endpoint specification and remote spec has one" do - remote_value = "remote_value" - remote_spec = double(:remote_spec, required_ruby_version: remote_value, required_rubygems_version: nil) - allow(spec_fetcher).to receive(:fetch_spec).and_return(remote_spec) + context "required_ruby_version is already set on endpoint specification" do + let(:metadata) { { "ruby" => [">= 3.0"] } } - expect(spec.required_ruby_version). eql?(remote_value) + it "returns the value from metadata without fetching the remote spec" do + expect(spec_fetcher).not_to receive(:fetch_spec) + expect(spec.required_ruby_version).to eq(Gem::Requirement.new(">= 3.0")) + end end - it "should use the default Gem Requirement value when not set on endpoint specification and not set on remote spec" do - remote_spec = double(:remote_spec, required_ruby_version: nil, required_rubygems_version: nil) + it "returns nil when not set on endpoint specification and metadata is nil" do + expect(spec_fetcher).not_to receive(:fetch_spec) + expect(spec.required_ruby_version).to be_nil + end + + it "loads required_ruby_version from the remote spec via matches_current_ruby?" do + remote_spec = double(:remote_spec, required_ruby_version: Gem::Requirement.new(">= 3.0"), required_rubygems_version: nil) allow(spec_fetcher).to receive(:fetch_spec).and_return(remote_spec) - expect(spec.required_ruby_version). eql?(Gem::Requirement.default) + + spec.matches_current_ruby? + expect(spec.required_ruby_version).to eq(Gem::Requirement.new(">= 3.0")) end end it "supports equality comparison" do remote_spec = double(:remote_spec, required_ruby_version: nil, required_rubygems_version: nil) allow(spec_fetcher).to receive(:fetch_spec).and_return(remote_spec) - other_spec = described_class.new("bar", version, platform, spec_fetcher, dependencies, metadata) + other_spec = described_class.new("bar", version, suffix, spec_fetcher, dependencies, metadata) expect(spec).to eql(spec) expect(spec).to_not eql(other_spec) end diff --git a/spec/bundler/lockfile_parser_spec.rb b/spec/bundler/lockfile_parser_spec.rb index c92d8909d29e..c54ae5d1fa92 100644 --- a/spec/bundler/lockfile_parser_spec.rb +++ b/spec/bundler/lockfile_parser_spec.rb @@ -145,6 +145,65 @@ include_examples "parsing" + context "when a spec has a content address" do + let(:lockfile_contents) do + <<~L + GEM + remote: https://rubygems.org/ + specs: + mygem (1.0-abcdef1234) x86_64-linux + + PLATFORMS + x86_64-linux + + DEPENDENCIES + mygem + + CHECKSUMS + mygem (1.0-abcdef1234) sha256=814828c34f1315d7e7b7e8295184577cc4e969bad6156ac069d02d63f58d82e8 + + BUNDLED WITH + 1.12.0.rc.2 + L + end + + it "parses the platform and content address" do + spec = subject.specs.find {|s| s.name == "mygem" } + + expect(spec.platform).to eq(Gem::Platform.new("x86_64-linux")) + expect(spec.content_address).to eq("abcdef1234") + + checksums = subject.sources.first.checksum_store.to_lock(spec) + expect(checksums).to eq("#{spec.lock_name} sha256=814828c34f1315d7e7b7e8295184577cc4e969bad6156ac069d02d63f58d82e8") + end + end + + context "when a Ruby-platform suffix resembles a content address but no platform is present" do + let(:lockfile_contents) do + <<~L + GEM + remote: https://rubygems.org/ + specs: + mygem (1.0-abcdef1234) + + PLATFORMS + ruby + + DEPENDENCIES + mygem + + BUNDLED WITH + 1.12.0.rc.2 + L + end + + it "does not parse the suffix as a content address" do + spec = subject.specs.find {|s| s.name == "mygem" } + + expect(spec.content_address).to be_nil + end + end + context "when an extra section is at the end" do let(:lockfile_contents) { super() + "\n\nFOO BAR\n baz\n baa\n qux\n" } include_examples "parsing" diff --git a/spec/bundler/override_spec.rb b/spec/bundler/override_spec.rb index ad8be75520b5..347509d27d3d 100644 --- a/spec/bundler/override_spec.rb +++ b/spec/bundler/override_spec.rb @@ -56,6 +56,7 @@ def initialize(name, ruby_req, rubygems_req) src.define_singleton_method(:runtime_dependencies) { [] } src.define_singleton_method(:required_ruby_version) { Gem::Requirement.default } src.define_singleton_method(:required_rubygems_version) { Gem::Requirement.default } + src.define_singleton_method(:content_address) { nil } src.define_singleton_method(:respond_to?) {|*| raise "from_spec must not call respond_to?" } expect { Bundler::LazySpecification.from_spec(src) }.not_to raise_error end diff --git a/spec/bundler/remote_specification_spec.rb b/spec/bundler/remote_specification_spec.rb index f35b231d5869..e68d68d1115b 100644 --- a/spec/bundler/remote_specification_spec.rb +++ b/spec/bundler/remote_specification_spec.rb @@ -3,10 +3,10 @@ RSpec.describe Bundler::RemoteSpecification do let(:name) { "foo" } let(:version) { Gem::Version.new("1.0.0") } - let(:platform) { Gem::Platform::RUBY } + let(:suffix) { Gem::Platform::RUBY } let(:spec_fetcher) { double(:spec_fetcher) } - subject { described_class.new(name, version, platform, spec_fetcher) } + subject { described_class.new(name, version, suffix, spec_fetcher) } it "is Comparable" do expect(described_class.ancestors).to include(Comparable) @@ -34,7 +34,7 @@ end context "when platform is nil" do - let(:platform) { nil } + let(:suffix) { nil } it "should return the spec name and version" do expect(subject.full_name).to eq("foo-1.0.0") @@ -42,7 +42,7 @@ end context "when platform is a non-ruby platform" do - let(:platform) { "jruby" } + let(:suffix) { "jruby" } it "should return the spec name, version, and platform" do expect(subject.full_name).to eq("foo-1.0.0-java") @@ -53,7 +53,7 @@ describe "#<=>" do let(:other_name) { name } let(:other_version) { version } - let(:other_platform) { platform } + let(:other_platform) { suffix } let(:other_spec_fetcher) { spec_fetcher } shared_examples_for "a comparison" do @@ -147,7 +147,7 @@ end context "when platform is not ruby" do - let(:platform) { "jruby" } + let(:suffix) { "jruby" } it "should return a sorting delegate array with name, version, and 1" do expect(subject.sort_obj).to match_array(["foo", version, 1]) @@ -184,4 +184,21 @@ end end end + + describe "#content_address" do + it "is nil for a hex suffix (CA gems are not handled on the legacy index path)" do + spec = Bundler::RemoteSpecification.new(name, version, "abc1234567", spec_fetcher) + expect(spec.content_address).to be_nil + end + + it "is nil for an ordinary platform" do + spec = Bundler::RemoteSpecification.new(name, version, "x86_64-linux", spec_fetcher) + expect(spec.content_address).to be_nil + end + + it "is nil for a RUBY platform" do + spec = Bundler::RemoteSpecification.new(name, version, Gem::Platform::RUBY, spec_fetcher) + expect(spec.content_address).to be_nil + end + end end diff --git a/spec/install/cooldown_spec.rb b/spec/install/cooldown_spec.rb index b45d50b1f2e1..f887a4b84031 100644 --- a/spec/install/cooldown_spec.rb +++ b/spec/install/cooldown_spec.rb @@ -173,7 +173,7 @@ #{Bundler::VERSION} L - bundle "lock --update ripe_gem", artifice: "compact_index_cooldown" + bundle "lock --update ripe_gem", artifice: "compact_index_v2" expect(lockfile).to include("fresh_gem (0.3.2)") end @@ -184,7 +184,7 @@ gem "ripe_gem" G - bundle "install --cooldown 7", artifice: "compact_index_cooldown" + bundle "install --cooldown 7", artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 1.0.0") end @@ -195,7 +195,7 @@ gem "ripe_gem" G - bundle "install --cooldown 0", artifice: "compact_index_cooldown" + bundle "install --cooldown 0", artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 2.0.0") end @@ -206,7 +206,7 @@ gem "ripe_gem" G - bundle "install --cooldown 7", artifice: "compact_index_cooldown" + bundle "install --cooldown 7", artifice: "compact_index_v2" expect(out).to include("The following gem versions were skipped by the cooldown setting:") expect(out).to include("* ripe_gem 2.0.0 (available in 6 days), resolved 1.0.0 instead") @@ -235,7 +235,7 @@ #{Bundler::VERSION} L - bundle "update ripe_gem", artifice: "compact_index_cooldown" + bundle "update ripe_gem", artifice: "compact_index_v2" expect(out).to include("The following gem versions were skipped by the cooldown setting:") expect(out).to include("* ripe_gem 2.0.0 (available in 6 days), resolved 1.0.0 instead") @@ -248,7 +248,7 @@ gem "ripe_gem" G - bundle "install --cooldown 0", artifice: "compact_index_cooldown" + bundle "install --cooldown 0", artifice: "compact_index_v2" expect(out).not_to include("skipped by the cooldown setting") end @@ -259,7 +259,7 @@ gem "ripe_gem", "~> 1.0" G - bundle "install --cooldown 7", artifice: "compact_index_cooldown" + bundle "install --cooldown 7", artifice: "compact_index_v2" expect(out).not_to include("skipped by the cooldown setting") expect(the_bundle).to include_gems("ripe_gem 1.0.0") @@ -271,10 +271,10 @@ gem "ripe_gem" G - bundle "install", artifice: "compact_index_cooldown" + bundle "install", artifice: "compact_index_v2" expect(out).to include("skipped by the cooldown setting") - bundle "install", artifice: "compact_index_cooldown" + bundle "install", artifice: "compact_index_v2" expect(out).not_to include("skipped by the cooldown setting") end @@ -284,7 +284,7 @@ gem "ripe_gem" G - bundle "install", artifice: "compact_index_cooldown" + bundle "install", artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 1.0.0") end @@ -314,7 +314,7 @@ #{Bundler::VERSION} L - bundle "update ripe_gem", artifice: "compact_index_cooldown" + bundle "update ripe_gem", artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 1.0.0") end @@ -342,7 +342,7 @@ #{Bundler::VERSION} L - bundle "install", artifice: "compact_index_cooldown" + bundle "install", artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 1.0.0", "child 1.0.0") end @@ -351,7 +351,7 @@ # https://github.com/rubygems/rubygems/issues/9723: a second declaration # of the same URL is deduped into the first one, so its cooldown cannot # act as a per-gem exemption. - install_gemfile <<-G, artifice: "compact_index_cooldown" + install_gemfile <<-G, artifice: "compact_index_v2" source "https://gem.repo3", cooldown: 7 source "https://gem.repo3", cooldown: 0 do gem "ripe_gem" @@ -363,7 +363,7 @@ end it "does not warn when the same source is declared again without a cooldown" do - install_gemfile <<-G, artifice: "compact_index_cooldown" + install_gemfile <<-G, artifice: "compact_index_v2" source "https://gem.repo3", cooldown: 7 source "https://gem.repo3" do gem "ripe_gem" @@ -375,7 +375,7 @@ end it "does not warn when the same source is declared again with the same cooldown" do - install_gemfile <<-G, artifice: "compact_index_cooldown" + install_gemfile <<-G, artifice: "compact_index_v2" source "https://gem.repo3", cooldown: 7 source "https://gem.repo3", cooldown: 7 do gem "ripe_gem" @@ -392,7 +392,7 @@ gem "ripe_gem" G - bundle "install --cooldown 7", artifice: "compact_index_cooldown" + bundle "install --cooldown 7", artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 1.0.0") end @@ -419,7 +419,7 @@ #{Bundler::VERSION} L - bundle "install --cooldown 7", artifice: "compact_index_cooldown" + bundle "install --cooldown 7", artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 2.0.0") end @@ -446,7 +446,7 @@ #{Bundler::VERSION} L - bundle "outdated --cooldown 7", artifice: "compact_index_cooldown", raise_on_error: false + bundle "outdated --cooldown 7", artifice: "compact_index_v2", raise_on_error: false expect(out).to match(/ripe_gem.*\(cooldown \d+d\)/) end @@ -473,7 +473,7 @@ #{Bundler::VERSION} L - bundle "outdated --cooldown 7 --parseable", artifice: "compact_index_cooldown", raise_on_error: false + bundle "outdated --cooldown 7 --parseable", artifice: "compact_index_v2", raise_on_error: false expect(out).to match(/ripe_gem.*in cooldown for \d+ more day/) end @@ -500,11 +500,11 @@ #{Bundler::VERSION} L - bundle "outdated --cooldown 7 --parseable", artifice: "compact_index_cooldown", raise_on_error: false + bundle "outdated --cooldown 7 --parseable", artifice: "compact_index_v2", raise_on_error: false expect(out).to match(/mid_gem \(newest 2\.0\.0, installed 1\.0\.0.*in cooldown for \d+ more days, newest out of cooldown 1\.5\.0\)/) - bundle "outdated --cooldown 7", artifice: "compact_index_cooldown", raise_on_error: false + bundle "outdated --cooldown 7", artifice: "compact_index_v2", raise_on_error: false expect(out).to match(/mid_gem.*2\.0\.0 \(cooldown \d+d, 1\.5\.0 out of cooldown\)/) end @@ -531,7 +531,7 @@ #{Bundler::VERSION} L - bundle "outdated --strict --cooldown 7 --parseable", artifice: "compact_index_cooldown", raise_on_error: false + bundle "outdated --strict --cooldown 7 --parseable", artifice: "compact_index_v2", raise_on_error: false # in strict mode "newest" is the resolved (cooldown-filtered) version # itself, so the annotations have nothing to add @@ -561,7 +561,7 @@ #{Bundler::VERSION} L - bundle "outdated --cooldown 7 --parseable", artifice: "compact_index_cooldown", raise_on_error: false + bundle "outdated --cooldown 7 --parseable", artifice: "compact_index_v2", raise_on_error: false expect(out).to match(/fresh_gem.*in cooldown for \d+ more day/) expect(out).not_to include("out of cooldown") @@ -590,7 +590,7 @@ L # mid_gem 2.0.0 is one day old, so a two-day window leaves one day - bundle "outdated --cooldown 2 --parseable", artifice: "compact_index_cooldown", raise_on_error: false + bundle "outdated --cooldown 2 --parseable", artifice: "compact_index_v2", raise_on_error: false expect(out).to match(/mid_gem \(newest 2\.0\.0, installed 1\.0\.0.*in cooldown for 1 more day, newest out of cooldown 1\.5\.0\)/) end @@ -617,7 +617,7 @@ #{Bundler::VERSION} L - bundle "outdated --parseable", artifice: "compact_index_cooldown", raise_on_error: false + bundle "outdated --parseable", artifice: "compact_index_v2", raise_on_error: false expect(out).to match(/mid_gem \(newest 2\.0\.0, installed 1\.0\.0/) expect(out).not_to include("cooldown") @@ -631,7 +631,7 @@ gem "ripe_gem" G - bundle "install --cooldown 7", artifice: "compact_index_cooldown" + bundle "install --cooldown 7", artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 1.0.0") end @@ -644,7 +644,7 @@ gem "ripe_gem" G - bundle "install --cooldown 0", artifice: "compact_index_cooldown" + bundle "install --cooldown 0", artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 2.0.0") end @@ -671,7 +671,7 @@ #{Bundler::VERSION} L - bundle "update ripe_gem --cooldown 99999", artifice: "compact_index_cooldown", raise_on_error: false + bundle "update ripe_gem --cooldown 99999", artifice: "compact_index_v2", raise_on_error: false expect(err).to match(/excluded by the cooldown setting/) expect(err).to match(/--cooldown 0/) @@ -702,7 +702,7 @@ #{Bundler::VERSION} L - bundle "update --all --cooldown 7", artifice: "compact_index_cooldown" + bundle "update --all --cooldown 7", artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 2.0.0") end @@ -729,7 +729,7 @@ #{Bundler::VERSION} L - bundle "outdated --cooldown 7", artifice: "compact_index_cooldown", raise_on_error: false + bundle "outdated --cooldown 7", artifice: "compact_index_v2", raise_on_error: false # exit 0 means no outdated gems and, crucially, no resolution failure (exit 7) expect(exitstatus).to eq(0) @@ -757,7 +757,7 @@ #{Bundler::VERSION} L - bundle "update ripe_gem --cooldown 7", artifice: "compact_index_cooldown" + bundle "update ripe_gem --cooldown 7", artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 1.0.0") end @@ -786,7 +786,7 @@ #{Bundler::VERSION} L - bundle "update --all --cooldown 7", artifice: "compact_index_cooldown" + bundle "update --all --cooldown 7", artifice: "compact_index_v2" expect(the_bundle).to include_gems("parent 1.0.0", "child 2.0.0") end @@ -813,7 +813,7 @@ #{Bundler::VERSION} L - bundle "update --all --cooldown 7", artifice: "compact_index_cooldown" + bundle "update --all --cooldown 7", artifice: "compact_index_v2" expect(the_bundle).to include_gems("upgradable 3.0.0") end @@ -859,7 +859,7 @@ #{Bundler::VERSION} L - bundle "update ripe_gem", artifice: "compact_index_cooldown" + bundle "update ripe_gem", artifice: "compact_index_v2" # A partial update converges the still-locked sources, the path that used # to drop cooldown. repo3's cooldown must survive that even with a second @@ -908,7 +908,7 @@ #{Bundler::VERSION} L - bundle "update solo_gem", artifice: "compact_index_cooldown" + bundle "update solo_gem", artifice: "compact_index_v2" # The cooldown lives on the gem-block source, which is also converged from # the lockfile. A partial update of solo_gem must keep that cooldown, so @@ -938,7 +938,7 @@ #{Bundler::VERSION} L - bundle "add child", artifice: "compact_index_cooldown" + bundle "add child", artifice: "compact_index_v2" expect(the_bundle).to include_gems("child 1.0.0") end @@ -965,7 +965,7 @@ #{Bundler::VERSION} L - bundle "lock --update ripe_gem", artifice: "compact_index_cooldown" + bundle "lock --update ripe_gem", artifice: "compact_index_v2" expect(lockfile).to include("ripe_gem (1.0.0)") expect(lockfile).not_to include("ripe_gem (2.0.0)") @@ -993,7 +993,7 @@ #{Bundler::VERSION} L - bundle "lock --update ripe_gem", artifice: "compact_index_cooldown" + bundle "lock --update ripe_gem", artifice: "compact_index_v2" expect(lockfile).to include("ripe_gem (1.0.0)") expect(lockfile).not_to include("ripe_gem (2.0.0)") @@ -1008,7 +1008,7 @@ gem "late_platform" G - bundle "install --cooldown 7", artifice: "compact_index_cooldown" + bundle "install --cooldown 7", artifice: "compact_index_v2" expect(the_bundle).to include_gems("late_platform 1.0.0") end @@ -1019,7 +1019,7 @@ gem "late_platform" G - bundle "install --cooldown 0", artifice: "compact_index_cooldown" + bundle "install --cooldown 0", artifice: "compact_index_v2" # On x86_64-linux hosts this resolves to the platform-specific build, so # assert on the lockfile instead of the installed platform. @@ -1049,7 +1049,7 @@ #{Bundler::VERSION} L - bundle "lock --update --cooldown 7", artifice: "compact_index_cooldown" + bundle "lock --update --cooldown 7", artifice: "compact_index_v2" expect(lockfile).to include("ripe_gem (1.0.0)") expect(lockfile).not_to include("ripe_gem (2.0.0)") @@ -1061,7 +1061,7 @@ gem "ripe_gem" G - bundle "lock --cooldown=-7", artifice: "compact_index_cooldown", raise_on_error: false + bundle "lock --cooldown=-7", artifice: "compact_index_v2", raise_on_error: false expect(err).to match(/non-negative integer/) end @@ -1072,7 +1072,7 @@ gem "ripe_gem" G - bundle "cache --cooldown 7", artifice: "compact_index_cooldown" + bundle "cache --cooldown 7", artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 1.0.0") expect(bundled_app("vendor/cache/ripe_gem-1.0.0.gem")).to exist @@ -1103,7 +1103,7 @@ L bundle "config set frozen true" - bundle "install", artifice: "compact_index_cooldown" + bundle "install", artifice: "compact_index_v2" expect(the_bundle).to include_gems("ripe_gem 2.0.0") end @@ -1119,7 +1119,7 @@ gem "ripe_gem" G - bundle "install", artifice: "compact_index_cooldown", + bundle "install", artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo3.to_s } expect(the_bundle).to include_gems("ripe_gem 1.0.0") diff --git a/spec/install/gemfile/content_addressable_spec.rb b/spec/install/gemfile/content_addressable_spec.rb new file mode 100644 index 000000000000..c8fe3ee168fd --- /dev/null +++ b/spec/install/gemfile/content_addressable_spec.rb @@ -0,0 +1,337 @@ +# 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$/) + end + end + + it "resolves a content-addressed binary from the local cache after a lockfile round-trip" do + simulate_platform "x86_64-linux" do + build_repo2 do + build_gem "mygem", "1.0" do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.write "lib/mygem.rb", "MYGEM = '1.0 not_content_addressed'" + end + end + + build_gem "mygem", "1.0", ruby_abi: current_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{current_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed'" + end + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s } + source "https://gem.repo2" + + gem "mygem" + G + + cached_file = Dir[default_bundle_path("cache", "mygem-1.0-*.gem").to_s].first + FileUtils.mkdir_p(bundled_app("vendor/cache")) + FileUtils.cp(cached_file, bundled_app("vendor/cache")) + + gem_dir = Dir[default_bundle_path("gems", "mygem-1.0-*").to_s].first + pristine_system_gems + bundle "install --local" + + expect(the_bundle).to include_gems "mygem 1.0 content_addressed" + expect(Dir[default_bundle_path("gems", "mygem-1.0-*").to_s].first).to eq(gem_dir) + end + end + + it "falls back to the non-content-addressed gem when the content-addressed gem requires a different Ruby ABI" do + simulate_platform "x86_64-linux" do + build_repo2 do + build_gem "mygem", "1.0" do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.write "lib/mygem.rb", "MYGEM = '1.0 not_content_addressed'" + end + end + + build_gem "mygem", "1.0", ruby_abi: mismatched_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{mismatched_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed'" + end + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s } + source "https://gem.repo2" + + gem "mygem" + G + + expect(the_bundle).to include_gems "mygem 1.0 not_content_addressed" + end + end + + it "does not treat a content-addressed suffix as content-addressable when platform metadata is missing" do + simulate_platform "x86_64-linux" do + build_repo2 do + build_gem "mygem", "1.0" do |s| + s.platform = "abcdef12" + s.write "lib/mygem.rb", "MYGEM = '1.0 hex_platform'" + end + end + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s }, raise_on_error: false + source "https://gem.repo2" + + gem "mygem" + G + + expect(err).to include("Could not find gem 'mygem'") + end + end + + it "falls back to the non-content-addressed gem when the content-addressed gem is for a different platform" do + simulate_platform "x86_64-linux" do + build_repo2 do + build_gem "mygem", "1.0" do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.write "lib/mygem.rb", "MYGEM = '1.0 not_content_addressed'" + end + end + + build_gem "mygem", "1.0", ruby_abi: current_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("arm64-darwin") + s.required_ruby_version = "~> #{current_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed'" + end + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s } + source "https://gem.repo2" + + gem "mygem" + G + + expect(the_bundle).to include_gems "mygem 1.0 not_content_addressed" + end + end + + it "installs the content-addressed gem matching the current platform when multiple platforms are available" do + simulate_platform "x86_64-linux" do + build_repo2 + + build_gem "mygem", "1.0", ruby_abi: current_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{current_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed_linux'" + end + + build_gem "mygem", "1.0", ruby_abi: current_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("arm64-darwin") + s.required_ruby_version = "~> #{current_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed_darwin'" + end + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s } + source "https://gem.repo2" + + gem "mygem" + G + + expect(the_bundle).to include_gems "mygem 1.0 content_addressed_linux" + end + end + + it "falls back to the pure-ruby gem when the content-addressed gem requires a different Ruby ABI" do + simulate_platform "x86_64-linux" do + build_repo2 do + build_gem "mygem", "1.0" do |s| + s.write "lib/mygem.rb", "MYGEM = '1.0 pure_ruby'" + end + end + + build_gem "mygem", "1.0", ruby_abi: mismatched_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{mismatched_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed'" + end + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s } + source "https://gem.repo2" + + gem "mygem" + G + + expect(the_bundle).to include_gems "mygem 1.0 pure_ruby" + end + end + + it "installs the ABI-compatible content-addressed gem when multiple content-addressed gems are available for the same platform" do + simulate_platform "x86_64-linux" do + build_repo2 + + build_gem "mygem", "1.0", ruby_abi: current_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{current_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed_matching_abi'" + end + + build_gem "mygem", "1.0", ruby_abi: mismatched_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{mismatched_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed_mismatched_abi'" + end + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s } + source "https://gem.repo2" + + gem "mygem" + G + + expect(the_bundle).to include_gems "mygem 1.0 content_addressed_matching_abi" + end + end + + it "installs the higher non-content-addressed version over a lower content-addressed version" do + simulate_platform "x86_64-linux" do + build_repo2 do + build_gem "mygem", "2.0" do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.write "lib/mygem.rb", "MYGEM = '2.0 not_content_addressed'" + end + end + + build_gem "mygem", "1.0", ruby_abi: current_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{current_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed'" + end + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s } + source "https://gem.repo2" + + gem "mygem" + G + + expect(the_bundle).to include_gems "mygem 2.0 not_content_addressed" + end + end + + it "falls back to the non-content-addressed gem when all content-addressed gems require a different Ruby ABI" do + simulate_platform "x86_64-linux" do + build_repo2 do + build_gem "mygem", "1.0" do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.write "lib/mygem.rb", "MYGEM = '1.0 not_content_addressed'" + end + end + + build_gem "mygem", "1.0", ruby_abi: mismatched_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{mismatched_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed_mismatched_abi_1'" + end + + second_mismatched_abi = "#{Gem.ruby_version.segments[0] + 2}.0" + build_gem "mygem", "1.0", ruby_abi: second_mismatched_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{second_mismatched_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed_mismatched_abi_2'" + end + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s } + source "https://gem.repo2" + + gem "mygem" + G + + expect(the_bundle).to include_gems "mygem 1.0 not_content_addressed" + end + end + + it "installs a locked content-addressed gem in frozen mode" do + simulate_platform "x86_64-linux" do + build_repo2 do + build_gem "mygem", "1.0" do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.write "lib/mygem.rb", "MYGEM = '1.0 not_content_addressed'" + end + end + + build_gem "mygem", "1.0", ruby_abi: current_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{current_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed'" + end + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s } + source "https://gem.repo2" + + gem "mygem" + G + + expect(the_bundle).to include_gems "mygem 1.0 content_addressed" + + pristine_system_gems + bundle_config "frozen true" + bundle "install", artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s } + + expect(the_bundle).to include_gems "mygem 1.0 content_addressed" + end + end + + it "fails when the downloaded content-addressed gem hash does not match the filename" do + simulate_platform "x86_64-linux" do + build_repo2 do + build_gem "mygem", "1.0" do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.write "lib/mygem.rb", "MYGEM = '1.0 not_content_addressed'" + end + end + + build_gem "mygem", "1.0", ruby_abi: current_abi, path: gem_repo2("gems") do |s| + s.platform = Gem::Platform.new("x86_64-linux") + s.required_ruby_version = "~> #{current_abi}.0" + s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed'" + end + + ca_gem = Dir[gem_repo2("gems", "mygem-1.0-[0-9a-f]*.gem")].first + non_ca_gem = gem_repo2("gems", "mygem-1.0-x86_64-linux.gem") + FileUtils.cp non_ca_gem, ca_gem + + install_gemfile <<~G, artifice: "compact_index_v2", env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo2.to_s }, raise_on_error: false + source "https://gem.repo2" + + gem "mygem" + G + + expect(err).to include("content address mismatch") + end + end +end diff --git a/spec/other/ext_spec.rb b/spec/other/ext_spec.rb index a883eefe0667..056371f5eb80 100644 --- a/spec/other/ext_spec.rb +++ b/spec/other/ext_spec.rb @@ -46,5 +46,20 @@ expect(Gem::NameTuple.new("a", v("1.0.0"), "ruby").lock_name).to eq("a (1.0.0)") expect(Gem::NameTuple.new("a", v("1.0.0")).lock_name).to eq("a (1.0.0)") end + + it "uses content_address in the lock name when set" do + expect(Gem::NameTuple.new("a", v("1.0.0"), "x86_64-linux", content_address: "abcdef12").lock_name).to eq("a (1.0.0-abcdef12)") + expect(Gem::NameTuple.new("a", v("1.0.0"), "ruby", content_address: "abcdef12").lock_name).to eq("a (1.0.0-abcdef12)") + end + end +end + +RSpec.describe Bundler::LazySpecification do + describe "#to_lock" do + it "appends the content address after the platform lock name when set" do + spec = Bundler::LazySpecification.new("mygem", v("1.0"), "x86_64-linux", nil, content_address: "abcdef1234") + + expect(spec.to_lock).to eq(" mygem (1.0-abcdef1234) x86_64-linux\n") + end end end diff --git a/spec/support/artifice/compact_index_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..c2aa47ab10d9 100644 --- a/spec/support/artifice/helpers/compact_index.rb +++ b/spec/support/artifice/helpers/compact_index.rb @@ -85,7 +85,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 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..870483b4d408 --- /dev/null +++ b/spec/support/artifice/helpers/compact_index_v2.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require_relative "compact_index" + +# The vendored compact_index is pinned (in spec/support/rubygems_ext.rb) to a ref +# of rubygems/rubygems.org#lib/compact_index/ that predates content-addressable +# gem support. The pin lives in an external app, so it can't be bumped from here. +# Prepend a module that adds CA support by delegating to +super+ and only +# injecting CA-specific behavior: the version token carries the content address, +# and /info appends the real platform as +platform:=+ metadata for CA gems. +# When Gem::ContentAddress is not available (system RubyGems), the no-op +# stub from rubygems_ext.rb makes match? return false, so CA paths are inert. +# +# TODO: Remove this patch once rubygems/rubygems.org PR #6674 merges and the +# pinned ref is updated to include native CA support. +if defined?(CompactIndex::GemVersionMethods) + module CAGemVersionMethods + def number_and_platform + return "#{number}-#{content_address}" if content_address + + super + end + + def to_line + line = super + line << ",platform:= #{platform}" if content_address + line + end + end + CompactIndex::GemVersionMethods.prepend(CAGemVersionMethods) + + CompactIndex::GemVersionV2.attr_accessor :content_address +end + +class CompactIndexV2API < CompactIndexAPI + helpers do + def build_gem_version(spec, deps, checksum) + created_at = spec.date&.utc&.iso8601 + version = 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) + version.content_address = spec.content_address + version + 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/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 4dcc02386e8788e7990d525a8bc30d70de18ff06 Mon Sep 17 00:00:00 2001 From: Gira Chawda Date: Thu, 27 Aug 2026 16:13:31 -0400 Subject: [PATCH 7/9] Decode CA tuples in SpecFetcher Assisted-By: devx/54c45e18-0bd1-4563-826a-f8cbc21a3b90 --- lib/rubygems/commands/dependency_command.rb | 2 +- lib/rubygems/query_utils.rb | 11 +-- lib/rubygems/spec_fetcher.rb | 50 +++++++++--- test/rubygems/helper.rb | 25 ++++++ .../test_gem_commands_dependency_command.rb | 47 +++++++++++ .../test_gem_commands_fetch_command.rb | 38 +++++++++ .../test_gem_commands_update_command.rb | 61 ++++++++++++++ test/rubygems/test_gem_spec_fetcher.rb | 81 +++++++++++++++++++ 8 files changed, 293 insertions(+), 22 deletions(-) diff --git a/lib/rubygems/commands/dependency_command.rb b/lib/rubygems/commands/dependency_command.rb index 9aaefae999d4..009bea9a569f 100644 --- a/lib/rubygems/commands/dependency_command.rb +++ b/lib/rubygems/commands/dependency_command.rb @@ -66,7 +66,7 @@ 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 {|tuple, source| source.fetch_spec(tuple) } end def fetch_specs(name_pattern, requirement, prerelease) # :nodoc: 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/spec_fetcher.rb b/lib/rubygems/spec_fetcher.rb index 6f06b554d2c1..a76594ac3884 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,13 @@ def spec_for_dependency(dependency, matching_platform = true) [specs, errors] end + def decode_content_addressable_tuples(spec_tuples, latest: false) # :nodoc: + 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 +302,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/test/rubygems/helper.rb b/test/rubygems/helper.rb index ec93b96b2927..229dea2f98e5 100644 --- a/test/rubygems/helper.rb +++ b/test/rubygems/helper.rb @@ -1037,6 +1037,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..6afd5650367c 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" + ) 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-x86_64-linux\n dep_tophat (~> 1.0)\n\n", @stub_ui.output + refute_match content_address, @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_update_command.rb b/test/rubygems/test_gem_commands_update_command.rb index 9d15406bd13f..fcf146bfc991 100644 --- a/test/rubygems/test_gem_commands_update_command.rb +++ b/test/rubygems/test_gem_commands_update_command.rb @@ -42,6 +42,67 @@ def test_execute assert_empty out end + def test_execute_content_addressable_compact_index_gem + util_set_arch "x86_64-linux" + + spec_fetcher do |fetcher| + fetcher.gem "ca_update", "0.9.0" do |s| + s.platform = "x86_64-linux" + end + end + + _spec, _gem_path, content_address = util_setup_content_addressable_compact_index_gem( + "ca_update", + "1.0.0", + platform: "x86_64-linux" + ) + + @cmd.options[:args] = %w[ca_update] + + use_ui @ui do + @cmd.execute + end + + out = @ui.output.split "\n" + assert_equal "Updating installed gems", out.shift + assert_equal "Updating ca_update", out.shift + assert_equal "Gems updated: ca_update", out.shift + assert_empty out + + assert_path_exist File.join(@gemhome, "specifications", "ca_update-1.0.0-#{content_address}.gemspec") + end + + def test_execute_platform_compact_index_gem + util_set_arch "x86_64-linux" + + spec_fetcher do |fetcher| + fetcher.gem "platform_update", "0.9.0" do |s| + s.platform = "x86_64-linux" + end + end + + spec, gem_path = util_gem "platform_update", "1.0.0" do |s| + s.platform = "x86_64-linux" + end + util_setup_compact_index spec + add_to_fetcher spec, gem_path + Gem::SpecFetcher.fetcher = nil + + @cmd.options[:args] = %w[platform_update] + + use_ui @ui do + @cmd.execute + end + + out = @ui.output.split "\n" + assert_equal "Updating installed gems", out.shift + assert_equal "Updating platform_update", out.shift + assert_equal "Gems updated: platform_update", out.shift + assert_empty out + + assert_path_exist File.join(@gemhome, "specifications", "platform_update-1.0.0-x86_64-linux.gemspec") + end + def test_execute_compact_index spec_fetcher do |fetcher| fetcher.gem "b", 1 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 cb03f5fa3fe0ec2c313c5090f492df59fc4f3140 Mon Sep 17 00:00:00 2001 From: Gira Chawda Date: Wed, 26 Aug 2026 12:34:54 -0400 Subject: [PATCH 8/9] Use CA suffix for cooldown created_at lookup Assisted-By: devx/54c45e18-0bd1-4563-826a-f8cbc21a3b90 --- lib/rubygems/commands/outdated_command.rb | 2 +- lib/rubygems/commands/update_command.rb | 2 +- lib/rubygems/compact_index_client.rb | 2 +- lib/rubygems/source.rb | 18 +++++++--- .../test_gem_commands_outdated_command.rb | 27 +++++++++++++++ .../test_gem_commands_update_command.rb | 33 +++++++++++++++++++ .../rubygems/test_gem_compact_index_client.rb | 4 +-- test/rubygems/test_gem_source.rb | 10 ++++++ 8 files changed, 88 insertions(+), 10 deletions(-) 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..f1ebe8bb03b9 100644 --- a/lib/rubygems/compact_index_client.rb +++ b/lib/rubygems/compact_index_client.rb @@ -20,7 +20,7 @@ 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 diff --git a/lib/rubygems/source.rb b/lib/rubygems/source.rb index f0bda9c9dc32..48d757e4e5e9 100644 --- a/lib/rubygems/source.rb +++ b/lib/rubygems/source.rb @@ -217,12 +217,12 @@ def decode_content_addressable_tuples(tuples, latest: false) # :nodoc: 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 ||= {} @@ -232,12 +232,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 @@ -246,6 +246,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. @@ -401,7 +409,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/test/rubygems/test_gem_commands_outdated_command.rb b/test/rubygems/test_gem_commands_outdated_command.rb index 4f88aef0f03b..b95f1c05dcb8 100644 --- a/test/rubygems/test_gem_commands_outdated_command.rb +++ b/test/rubygems/test_gem_commands_outdated_command.rb @@ -81,6 +81,33 @@ def test_execute_cooldown_annotates_newer_version_within_period assert_equal "", @ui.error end + def test_execute_cooldown_embargoes_content_addressable_tuple + util_set_arch "x86_64-linux" + + spec_fetcher do |fetcher| + fetcher.gem "ca_cooldown", "1.0.0" do |s| + s.platform = "x86_64-linux" + end + end + + ca_spec = util_ca_spec "ca_cooldown", "2.0.0", "abcdef12", + ruby_abi: Gem.ruby_version.segments.first(2).join("."), + platform: "x86_64-linux" + util_setup_compact_index ca_spec, created_at: { + ca_spec.original_name => util_cooldown_time(1), + } + Gem::SpecFetcher.fetcher = nil + + @cmd.options[:cooldown] = 7 + + use_ui @ui do + @cmd.execute + end + + assert_equal "ca_cooldown (1.0.0 < 2.0.0 (cooldown 7d))\n", @ui.output + assert_equal "", @ui.error + end + def test_execute_cooldown_only_version_within_period util_setup_cooldown_repo "foo-0.3" => util_cooldown_time(1) diff --git a/test/rubygems/test_gem_commands_update_command.rb b/test/rubygems/test_gem_commands_update_command.rb index fcf146bfc991..38126e78d1c3 100644 --- a/test/rubygems/test_gem_commands_update_command.rb +++ b/test/rubygems/test_gem_commands_update_command.rb @@ -177,6 +177,39 @@ def test_execute_cooldown_falls_back_to_older_version assert_path_not_exist File.join(@gemhome, "specifications", "b-3.gemspec") end + def test_execute_cooldown_skips_content_addressable_tuple + util_set_arch "x86_64-linux" + + spec_fetcher do |fetcher| + fetcher.gem "ca_cooldown", "1.0.0" do |s| + s.platform = "x86_64-linux" + end + end + + ca_spec = util_ca_spec "ca_cooldown", "2.0.0", "abcdef12", + ruby_abi: Gem.ruby_version.segments.first(2).join("."), + platform: "x86_64-linux" + util_setup_compact_index ca_spec, created_at: { + ca_spec.original_name => util_cooldown_time(1), + } + Gem::SpecFetcher.fetcher = nil + + @cmd.options[:cooldown] = 7 + @cmd.options[:args] = [] + + use_ui @ui do + @cmd.execute + end + + out = @ui.output.split "\n" + assert_equal "Updating installed gems", out.shift + assert_equal "Nothing to update", out.shift + assert_equal "The following gem versions were skipped by the cooldown setting:", out.shift + assert_match(/\A \* ca_cooldown 2\.0\.0 \(available in \d+ days\), resolved 1\.0\.0 instead\z/, out.shift) + assert_empty out + assert_path_not_exist File.join(@gemhome, "specifications", "ca_cooldown-2.0.0-abcdef12.gemspec") + end + def test_execute_cooldown_all_new_versions_within_period util_setup_cooldown_repo b2_created_at: util_cooldown_time(1), b3_created_at: util_cooldown_time(1) diff --git a/test/rubygems/test_gem_compact_index_client.rb b/test/rubygems/test_gem_compact_index_client.rb index d0d6998d1606..93dc89a4229a 100644 --- a/test/rubygems/test_gem_compact_index_client.rb +++ b/test/rubygems/test_gem_compact_index_client.rb @@ -60,7 +60,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 +69,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" From f9999affad8c60e486a8a8055645e8d0d182beee Mon Sep 17 00:00:00 2001 From: Jenny Shen Date: Wed, 26 Aug 2026 10:46:04 -0400 Subject: [PATCH 9/9] Include CA metadata in Gem::NameTuple#to_a so #hash distinguishes variants Assisted-By: devx/25d3c4be-88ab-425e-a76b-08a00d8e9d71 --- lib/rubygems/name_tuple.rb | 36 +++++++-- test/rubygems/test_gem_name_tuple.rb | 107 +++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 7 deletions(-) diff --git a/lib/rubygems/name_tuple.rb b/lib/rubygems/name_tuple.rb index a89119f05858..b7f0a8cb3db0 100644 --- a/lib/rubygems/name_tuple.rb +++ b/lib/rubygems/name_tuple.rb @@ -20,11 +20,27 @@ def initialize(name, version, platform = Gem::Platform::RUBY, content_address: n 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 ## @@ -32,7 +48,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 ## @@ -78,10 +94,16 @@ 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 @@ -114,7 +136,7 @@ def sort_key # :nodoc: ## # 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 diff --git a/test/rubygems/test_gem_name_tuple.rb b/test/rubygems/test_gem_name_tuple.rb index 276a002dfb3f..7d6b4fd32438 100644 --- a/test/rubygems/test_gem_name_tuple.rb +++ b/test/rubygems/test_gem_name_tuple.rb @@ -54,6 +54,73 @@ def test_content_addressable_metadata 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" @@ -75,6 +142,12 @@ def test_spec_name 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 @@ -85,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 @@ -118,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