diff --git a/lib/rubygems/commands/push_command.rb b/lib/rubygems/commands/push_command.rb index d2ce86703ba3..62dbd14e0bb2 100644 --- a/lib/rubygems/commands/push_command.rb +++ b/lib/rubygems/commands/push_command.rb @@ -92,21 +92,70 @@ def send_gem(name) private def send_push_request(name, args) + # Attestation is only supported on rubygems.org with GitHub Actions (not JRuby) + if RUBY_ENGINE != "jruby" && attestation_supported_host? && ENV["GITHUB_ACTIONS"] + send_push_request_with_attestation(name, args) + else + send_push_request_without_attestation(name, args) + end + end + + def send_push_request_without_attestation(name, args) scope = get_push_scope rubygems_api_request(*args, scope: scope) do |request| body = Gem.read_binary name - if options[:attestations].any? - request.set_form([ - ["gem", body, { filename: name, content_type: "application/octet-stream" }], - get_attestations_part, - ], "multipart/form-data") - else - request.body = body - request.add_field "Content-Type", "application/octet-stream" - request.add_field "Content-Length", request.body.size + request.body = body + request.add_field "Content-Type", "application/octet-stream" + request.add_field "Content-Length", request.body.size + request.add_field "Authorization", api_key + end + end + + def send_push_request_with_attestation(name, args) + attestations = if options[:attestations].any? + options[:attestations].map do |attestation| + Gem.read_binary(attestation) + end + else + bundle_path = attest!(name) + begin + [Gem.read_binary(bundle_path)] + ensure + File.unlink(bundle_path) if bundle_path && File.exist?(bundle_path) end + end + bundles = "[" + attestations.join(",") + "]" + + rubygems_api_request(*args, scope: get_push_scope) do |request| + request.set_form([ + ["gem", Gem.read_binary(name), { filename: name, content_type: "application/octet-stream" }], + ["attestations", bundles, { content_type: "application/json" }], + ], "multipart/form-data") request.add_field "Authorization", api_key end + rescue StandardError => e + alert_warning "Failed to push with attestation, retrying without attestation.\n#{e.full_message}" + send_push_request_without_attestation(name, args) + end + + def attest!(name) + require "open3" + require "tempfile" + + tempfile = Tempfile.new([File.basename(name, ".*"), ".sigstore.json"]) + bundle = tempfile.path + tempfile.close(false) + + env = defined?(Bundler.unbundled_env) ? Bundler.unbundled_env : ENV.to_h + out, st = Open3.capture2e( + env, + Gem.ruby, "-S", "gem", "exec", + "sigstore-cli:0.2.2", "sign", name, "--bundle", bundle, + unsetenv_others: true + ) + raise Gem::Exception, "Failed to sign gem:\n\n#{out}" unless st.success? + + bundle end def get_hosts_for(name) @@ -122,14 +171,7 @@ def get_push_scope :push_rubygem end - def get_attestations_part - bundles = "[" + options[:attestations].map do |attestation| - Gem.read_binary(attestation) - end.join(",") + "]" - [ - "attestations", - bundles, - { content_type: "application/json" }, - ] + def attestation_supported_host? + (@host || Gem.host) == "https://rubygems.org" end end diff --git a/test/rubygems/test_gem_commands_push_command.rb b/test/rubygems/test_gem_commands_push_command.rb index 1477a7494714..ba70eef54b19 100644 --- a/test/rubygems/test_gem_commands_push_command.rb +++ b/test/rubygems/test_gem_commands_push_command.rb @@ -103,44 +103,127 @@ def test_execute_host 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") + omit if RUBY_ENGINE == "jruby" + + ENV["GITHUB_ACTIONS"] = "true" + begin + @response = "Successfully registered gem: freewill (1.0.0)" + @fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK") + + File.write("#{@path}.sigstore.json", "attestation") + @cmd.options[:args] = [@path] + @cmd.options[:attestations] = ["#{@path}.sigstore.json"] + + @cmd.execute + + assert_equal Gem::Net::HTTP::Post, @fetcher.last_request.class + content_length = @fetcher.last_request["Content-Length"].to_i + assert_equal content_length, @fetcher.last_request.body.length + assert_attestation_multipart Gem.read_binary("#{@path}.sigstore.json") + ensure + ENV.delete("GITHUB_ACTIONS") + end + end + + def test_execute_attestation_auto + omit if RUBY_ENGINE == "jruby" + + ENV["GITHUB_ACTIONS"] = "true" + begin + @response = "Successfully registered gem: freewill (1.0.0)" + @fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK") + + attestation_path = "#{@path}.sigstore.json" + attestation_content = "auto-attestation" + File.write(attestation_path, attestation_content) + @cmd.options[:args] = [@path] + + @cmd.stub(:attest!, attestation_path) do + @cmd.execute + end + + assert_equal Gem::Net::HTTP::Post, @fetcher.last_request.class + content_length = @fetcher.last_request["Content-Length"].to_i + assert_equal content_length, @fetcher.last_request.body.length + assert_attestation_multipart attestation_content + ensure + ENV.delete("GITHUB_ACTIONS") + end + end + + def test_execute_attestation_fallback + omit if RUBY_ENGINE == "jruby" + + ENV["GITHUB_ACTIONS"] = "true" + begin + @response = "Successfully registered gem: freewill (1.0.0)" + @fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK") + + @cmd.options[:args] = [@path] + + @cmd.stub(:attest!, proc { raise Gem::Exception, "boom" }) do + use_ui @ui do + @cmd.execute + end + end + + assert_match "Failed to push with attestation, retrying without attestation.", @ui.error + assert_equal Gem::Net::HTTP::Post, @fetcher.last_request.class + assert_equal Gem.read_binary(@path), @fetcher.last_request.body + assert_equal "application/octet-stream", + @fetcher.last_request["Content-Type"] + ensure + ENV.delete("GITHUB_ACTIONS") + end + end + + def test_execute_attestation_skipped_on_non_rubygems_host + @spec, @path = util_gem "freebird", "1.0.1" do |spec| + spec.metadata["allowed_push_host"] = "https://privategemserver.example" + end + + @response = "Successfully registered gem: freebird (1.0.1)" + @fetcher.data["#{@spec.metadata["allowed_push_host"]}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK") - File.write("#{@path}.sigstore.json", "attestation") @cmd.options[:args] = [@path] - @cmd.options[:attestations] = ["#{@path}.sigstore.json"] - @cmd.execute + attest_called = false + @cmd.stub(:attest!, proc { attest_called = true }) do + @cmd.execute + end + refute attest_called, "attest! should not be called for non-rubygems.org hosts" assert_equal Gem::Net::HTTP::Post, @fetcher.last_request.class - content_length = @fetcher.last_request["Content-Length"].to_i - assert_equal content_length, @fetcher.last_request.body.length - assert_equal "multipart", @fetcher.last_request.main_type, @fetcher.last_request.content_type - assert_equal "form-data", @fetcher.last_request.sub_type - assert_include @fetcher.last_request.type_params, "boundary" - boundary = @fetcher.last_request.type_params["boundary"] + assert_equal Gem.read_binary(@path), @fetcher.last_request.body + assert_equal "application/octet-stream", + @fetcher.last_request["Content-Type"] + end - parts = @fetcher.last_request.body.split(/(?:\r\n|\A)--#{Regexp.quote(boundary)}(?:\r\n|--)/m) - refute_empty parts - assert_empty parts[0] - parts.shift # remove the first empty part + def test_execute_attestation_skipped_on_jruby + @response = "Successfully registered gem: freewill (1.0.0)" + @fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK") - p1 = parts.shift - p2 = parts.shift - assert_equal "\r\n", parts.shift - assert_empty parts + @cmd.options[:args] = [@path] - assert_equal [ - "Content-Disposition: form-data; name=\"gem\"; filename=\"#{@path}\"", - "Content-Type: application/octet-stream", - nil, - Gem.read_binary(@path), - ].join("\r\n").b, p1 - assert_equal [ - "Content-Disposition: form-data; name=\"attestations\"", - nil, - "[#{Gem.read_binary("#{@path}.sigstore.json")}]", - ].join("\r\n").b, p2 + attest_called = false + engine = RUBY_ENGINE + Object.send :remove_const, :RUBY_ENGINE + Object.const_set :RUBY_ENGINE, "jruby" + + begin + @cmd.stub(:attest!, proc { attest_called = true }) do + @cmd.execute + end + + refute attest_called, "attest! should not be called on JRuby" + assert_equal Gem::Net::HTTP::Post, @fetcher.last_request.class + assert_equal Gem.read_binary(@path), @fetcher.last_request.body + assert_equal "application/octet-stream", + @fetcher.last_request["Content-Type"] + ensure + Object.send :remove_const, :RUBY_ENGINE + Object.const_set :RUBY_ENGINE, engine + end end def test_execute_allowed_push_host @@ -642,6 +725,35 @@ def test_sending_gem_with_no_local_creds private + def assert_attestation_multipart(attestation_payload) + assert_equal "multipart", @fetcher.last_request.main_type, @fetcher.last_request.content_type + assert_equal "form-data", @fetcher.last_request.sub_type + assert_include @fetcher.last_request.type_params, "boundary" + boundary = @fetcher.last_request.type_params["boundary"] + + parts = @fetcher.last_request.body.split(/(?:\r\n|\A)--#{Regexp.quote(boundary)}(?:\r\n|--)/m) + refute_empty parts + assert_empty parts[0] + parts.shift # remove the first empty part + + p1 = parts.shift + p2 = parts.shift + assert_equal "\r\n", parts.shift + assert_empty parts + + assert_equal [ + "Content-Disposition: form-data; name=\"gem\"; filename=\"#{@path}\"", + "Content-Type: application/octet-stream", + nil, + Gem.read_binary(@path), + ].join("\r\n").b, p1 + assert_equal [ + "Content-Disposition: form-data; name=\"attestations\"", + nil, + "[#{attestation_payload}]", + ].join("\r\n").b, p2 + end + def singleton_gem_class class << Gem; self; end end