diff --git a/lib/rubygems/commands/push_command.rb b/lib/rubygems/commands/push_command.rb index 591ddc3a80..726191377a 100644 --- a/lib/rubygems/commands/push_command.rb +++ b/lib/rubygems/commands/push_command.rb @@ -30,7 +30,7 @@ The push command will use ~/.gem/credentials to authenticate to a server, but yo end def initialize - super "push", "Push a gem up to the gem server", host: host + super "push", "Push a gem up to the gem server", host: host, attestations: [] @user_defined_host = false @@ -45,6 +45,11 @@ The push command will use ~/.gem/credentials to authenticate to a server, but yo @user_defined_host = true end + add_option("--attestation FILE", + "Push with sigstore attestations") do |value, options| + options[:attestations] << value + end + @host = nil end @@ -88,10 +93,18 @@ The push command will use ~/.gem/credentials to authenticate to a server, but yo def send_push_request(name, args) rubygems_api_request(*args, scope: get_push_scope) do |request| - request.body = Gem.read_binary name - request.add_field "Content-Length", request.body.size - request.add_field "Content-Type", "application/octet-stream" - request.add_field "Authorization", api_key + 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 + end + request.add_field "Authorization", api_key end end @@ -107,4 +120,15 @@ The push command will use ~/.gem/credentials to authenticate to a server, but yo 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" }, + ] + end end diff --git a/test/rubygems/test_gem_commands_push_command.rb b/test/rubygems/test_gem_commands_push_command.rb index a7a18ff4ab..2d0190b49f 100644 --- a/test/rubygems/test_gem_commands_push_command.rb +++ b/test/rubygems/test_gem_commands_push_command.rb @@ -102,6 +102,47 @@ class TestGemCommandsPushCommand < Gem::TestCase @fetcher.last_request["Content-Type"] 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") + + 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_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, + "[#{Gem.read_binary("#{@path}.sigstore.json")}]", + ].join("\r\n").b, p2 + end + def test_execute_allowed_push_host @spec, @path = util_gem "freebird", "1.0.1" do |spec| spec.metadata["allowed_push_host"] = "https://privategemserver.example" diff --git a/test/rubygems/utilities.rb b/test/rubygems/utilities.rb index fd0fdd6111..000cbe038d 100644 --- a/test/rubygems/utilities.rb +++ b/test/rubygems/utilities.rb @@ -103,10 +103,21 @@ class Gem::FakeFetcher @requests.last end + class FakeSocket < StringIO + def continue_timeout + 1 + end + end + def request(uri, request_class, last_modified = nil) @requests << request_class.new(uri.request_uri) yield last_request if block_given? + # Ensure multipart request bodies are generated + socket = FakeSocket.new + last_request.exec socket.binmode, "1.1", last_request.path + _, last_request.body = socket.string.split("\r\n\r\n", 2) + create_response(uri) end