diff --git a/lib/rubygems/commands/push_command.rb b/lib/rubygems/commands/push_command.rb index 78fb844eb963..b3be7fdf268f 100644 --- a/lib/rubygems/commands/push_command.rb +++ b/lib/rubygems/commands/push_command.rb @@ -46,7 +46,8 @@ def initialize end add_option("--attestation FILE", - "Push with sigstore attestations") do |value, options| + "Push with sigstore attestations", + " (FILE must be a JSON sigstore bundle)") do |value, options| options[:attestations] << value end @@ -94,7 +95,7 @@ def send_gem(name) def send_push_request(name, args) # Always honor explicit --attestation option # Auto-attestation is only supported on rubygems.org with GitHub Actions (not JRuby) - if options[:attestations].any? || (RUBY_ENGINE != "jruby" && attestation_supported_host? && ENV["GITHUB_ACTIONS"]) + if options[:attestations].any? || (RUBY_ENGINE != "jruby" && attestation_supported_host? && ENV["GITHUB_ACTIONS"] == "true") send_push_request_with_attestation(name, args) else send_push_request_without_attestation(name, args) @@ -115,14 +116,23 @@ def send_push_request_without_attestation(name, args) def send_push_request_with_attestation(name, args) attestations = if options[:attestations].any? options[:attestations].map do |attestation| - Gem.read_binary(attestation) + load_attestation(attestation) end else - bundle_path = attest!(name) + # Only the opportunistic signing step falls back. The request below stays + # outside this rescue because once the server may have seen the attested + # push, a network error must not trigger an unattested retry. begin - [Gem.read_binary(bundle_path)] - ensure - File.unlink(bundle_path) if bundle_path && File.exist?(bundle_path) + [attest!(name)] + rescue StandardError => e + message = "Failed to create an attestation, pushing without one.\n" + message += if Gem.configuration.really_verbose + e.full_message + else + e.message + end + alert_warning message + return send_push_request_without_attestation(name, args) end end bundles = "[" + attestations.join(",") + "]" @@ -134,15 +144,27 @@ def send_push_request_with_attestation(name, args) ], "multipart/form-data") request.add_field "Authorization", api_key end - rescue StandardError => e - message = "Failed to push with attestation, retrying without attestation.\n" - message += if Gem.configuration.really_verbose - e.full_message - else - e.message + end + + def load_attestation(file) + data = begin + Gem.read_binary(file) + rescue SystemCallError, IOError, ArgumentError => e + raise Gem::Exception, "Failed to read attestation #{file}: #{e.message}" + end + validate_attestation_json(data, file) + end + + def validate_attestation_json(data, source) + require "json" + + parsed = begin + JSON.parse(data) + rescue JSON::ParserError => e + raise Gem::Exception, "Attestation #{source} is not valid JSON: #{e.message}" end - alert_warning message - send_push_request_without_attestation(name, args) + raise Gem::Exception, "Attestation #{source} is not a JSON object" unless parsed.is_a?(Hash) + data end def attest!(name) @@ -150,22 +172,24 @@ def attest!(name) require "shellwords" 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 - # Gem.ruby is quoted if it contains whitespace, so split it into argv - # elements to keep the quotes out of the spawned command. - out, st = Open3.capture2e( - env, - *Shellwords.split(Gem.ruby), "-S", "gem", "exec", "--conservative", - "sigstore-cli", "sign", name, "--bundle", bundle, - unsetenv_others: true - ) - raise Gem::Exception, "Failed to sign gem:\n\n#{out}" unless st.success? - - bundle + + Tempfile.create([File.basename(name, ".*"), ".sigstore.json"]) do |tempfile| + tempfile.close + bundle = tempfile.path + + # Gem.ruby is quoted if it contains whitespace, so split it into argv + # elements to keep the quotes out of the spawned command. + out, st = Open3.capture2e( + env, + *Shellwords.split(Gem.ruby), "-S", "gem", "exec", "--conservative", + "sigstore-cli", "sign", name, "--bundle", bundle, + unsetenv_others: true + ) + raise Gem::Exception, "Failed to sign gem:\n\n#{out}" unless st.success? + + validate_attestation_json(Gem.read_binary(bundle), "generated by sigstore-cli") + end end def get_hosts_for(name) diff --git a/test/rubygems/helper.rb b/test/rubygems/helper.rb index dbb53600ba65..ab9c2f421608 100644 --- a/test/rubygems/helper.rb +++ b/test/rubygems/helper.rb @@ -388,6 +388,7 @@ def setup ENV["XDG_STATE_HOME"] = nil ENV["MAKEFLAGS"] = nil ENV["SOURCE_DATE_EPOCH"] = nil + ENV["GITHUB_ACTIONS"] = nil ENV["BUNDLER_VERSION"] = nil ENV["BUNDLE_CONFIG"] = nil ENV["BUNDLE_USER_CONFIG"] = nil diff --git a/test/rubygems/test_gem_commands_push_command.rb b/test/rubygems/test_gem_commands_push_command.rb index f8bb09d60062..3a89c08fdaaf 100644 --- a/test/rubygems/test_gem_commands_push_command.rb +++ b/test/rubygems/test_gem_commands_push_command.rb @@ -106,7 +106,7 @@ 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") + File.write("#{@path}.sigstore.json", '{"attestation":true}') @cmd.options[:args] = [@path] @cmd.options[:attestations] = ["#{@path}.sigstore.json"] @@ -118,59 +118,164 @@ def test_execute_attestation assert_attestation_multipart Gem.read_binary("#{@path}.sigstore.json") end + def test_execute_attestation_multiple + @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}.a.sigstore.json", '{"attestation":"a"}') + File.write("#{@path}.b.sigstore.json", '{"attestation":"b"}') + @cmd.options[:args] = [@path] + @cmd.options[:attestations] = ["#{@path}.a.sigstore.json", "#{@path}.b.sigstore.json"] + + @cmd.execute + + assert_attestation_multipart '{"attestation":"a"},{"attestation":"b"}' + 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] + @response = "Successfully registered gem: freewill (1.0.0)" + @fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK") + + attestation_content = '{"auto":"attestation"}' + @cmd.options[:args] = [@path] + + @cmd.stub(:attest!, attestation_content) 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 + end + + def test_execute_attestation_fallback + omit if RUBY_ENGINE == "jruby" + + ENV["GITHUB_ACTIONS"] = "true" - @cmd.stub(:attest!, attestation_path) do + @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_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") + assert_match "Failed to create an attestation, pushing without one.", @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"] + end + + def test_execute_attestation_explicit_missing_file + @fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: "", code: 200, msg: "OK") + + @cmd.options[:args] = [@path] + @cmd.options[:attestations] = ["#{@path}.sigstore.json"] + + e = assert_raise Gem::Exception do + use_ui @ui do + @cmd.execute + end end + + assert_match "Failed to read attestation", e.message + refute_match "pushing without one", @ui.error + assert_nil @fetcher.last_request end - def test_execute_attestation_fallback + def test_execute_attestation_explicit_invalid_json + @fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: "", code: 200, msg: "OK") + + File.write("#{@path}.sigstore.json", "not json") + @cmd.options[:args] = [@path] + @cmd.options[:attestations] = ["#{@path}.sigstore.json"] + + e = assert_raise Gem::Exception do + use_ui @ui do + @cmd.execute + end + end + + assert_match "is not valid JSON", e.message + refute_match "pushing without one", @ui.error + assert_nil @fetcher.last_request + end + + def test_execute_attestation_explicit_json_scalar + @fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: "", code: 200, msg: "OK") + + File.write("#{@path}.sigstore.json", "null") + @cmd.options[:args] = [@path] + @cmd.options[:attestations] = ["#{@path}.sigstore.json"] + + e = assert_raise Gem::Exception do + use_ui @ui do + @cmd.execute + end + end + + assert_match "is not a JSON object", e.message + assert_nil @fetcher.last_request + end + + def test_execute_attestation_network_error_not_retried_without_attestation 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] + requests = 0 + @fetcher.data["#{Gem.host}/api/v1/gems"] = proc do + requests += 1 + raise Gem::RemoteFetcher::FetchError.new("timed out", "#{Gem.host}/api/v1/gems") + end + + @cmd.options[:args] = [@path] - @cmd.stub(:attest!, proc { raise Gem::Exception, "boom" }) do + assert_raise Gem::RemoteFetcher::FetchError do + @cmd.stub(:attest!, '{"auto":"attestation"}') do use_ui @ui do @cmd.execute end 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") + assert_equal 1, requests + refute_match "pushing without one", @ui.error + end + + def test_execute_attestation_auto_skipped_unless_github_actions_true + ENV["GITHUB_ACTIONS"] = "false" + + @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] + + attest_called = false + @cmd.stub(:attest!, proc { attest_called = true }) do + @cmd.execute end + + refute attest_called, "attest! should not be called when GITHUB_ACTIONS is not \"true\"" + assert_equal "application/octet-stream", + @fetcher.last_request["Content-Type"] end def test_execute_attestation_skipped_on_non_rubygems_host + omit if RUBY_ENGINE == "jruby" + + ENV["GITHUB_ACTIONS"] = "true" + @spec, @path = util_gem "freebird", "1.0.1" do |spec| spec.metadata["allowed_push_host"] = "https://privategemserver.example" end @@ -193,6 +298,8 @@ def test_execute_attestation_skipped_on_non_rubygems_host end def test_execute_attestation_skipped_on_jruby + ENV["GITHUB_ACTIONS"] = "true" + @response = "Successfully registered gem: freewill (1.0.0)" @fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK") @@ -234,6 +341,7 @@ def fake_status.success? captured = nil capture_stub = lambda do |*args, **_kwargs| captured = args + File.write(args[args.index("--bundle") + 1], "{}") ["", fake_status] end Gem.stub(:ruby, '"/path with space/bin/ruby"') do @@ -249,6 +357,77 @@ def fake_status.success? assert_equal "-S", captured[2] end + def test_attest_aborts_when_signing_fails + require "open3" + + fake_status = Object.new + def fake_status.success? + false + end + + bundle_path = nil + capture_stub = lambda do |*args, **_kwargs| + bundle_path = args[args.index("--bundle") + 1] + ["sigstore-cli: no identity token available", fake_status] + end + + e = assert_raise Gem::Exception do + Open3.stub(:capture2e, capture_stub) do + @cmd.send(:attest!, @path) + end + end + + assert_match "Failed to sign gem", e.message + assert_match "no identity token available", e.message + refute_nil bundle_path, "signing command should have been spawned" + refute File.exist?(bundle_path), "bundle tempfile should be removed" + end + + def test_attest_rejects_a_bundle_that_is_not_json + require "open3" + + fake_status = Object.new + def fake_status.success? + true + end + + capture_stub = lambda do |*args, **_kwargs| + File.write(args[args.index("--bundle") + 1], "not json") + ["", fake_status] + end + + e = assert_raise Gem::Exception do + Open3.stub(:capture2e, capture_stub) do + @cmd.send(:attest!, @path) + end + end + + assert_match "is not valid JSON", e.message + end + + def test_attest_returns_bundle_content_and_removes_tempfile + require "open3" + + fake_status = Object.new + def fake_status.success? + true + end + + bundle_path = nil + capture_stub = lambda do |*args, **_kwargs| + bundle_path = args[args.index("--bundle") + 1] + File.write(bundle_path, '{"signed":true}') + ["", fake_status] + end + + content = Open3.stub(:capture2e, capture_stub) do + @cmd.send(:attest!, @path) + end + + assert_equal '{"signed":true}', content + refute File.exist?(bundle_path), "bundle tempfile should be removed" + 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"