Skip to content

Commit 66d21cd

Browse files
authored
Merge pull request #9325 from ruby/push-with-attestation
Push with auto-attestation
2 parents 2bafb57 + 6ac00f3 commit 66d21cd

2 files changed

Lines changed: 196 additions & 41 deletions

File tree

lib/rubygems/commands/push_command.rb

Lines changed: 68 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -92,21 +92,77 @@ def send_gem(name)
9292
private
9393

9494
def send_push_request(name, args)
95+
# Always honor explicit --attestation option
96+
# Auto-attestation is only supported on rubygems.org with GitHub Actions (not JRuby)
97+
if options[:attestations].any? || (RUBY_ENGINE != "jruby" && attestation_supported_host? && ENV["GITHUB_ACTIONS"])
98+
send_push_request_with_attestation(name, args)
99+
else
100+
send_push_request_without_attestation(name, args)
101+
end
102+
end
103+
104+
def send_push_request_without_attestation(name, args)
95105
scope = get_push_scope
96106
rubygems_api_request(*args, scope: scope) do |request|
97107
body = Gem.read_binary name
98-
if options[:attestations].any?
99-
request.set_form([
100-
["gem", body, { filename: name, content_type: "application/octet-stream" }],
101-
get_attestations_part,
102-
], "multipart/form-data")
103-
else
104-
request.body = body
105-
request.add_field "Content-Type", "application/octet-stream"
106-
request.add_field "Content-Length", request.body.size
108+
request.body = body
109+
request.add_field "Content-Type", "application/octet-stream"
110+
request.add_field "Content-Length", request.body.size
111+
request.add_field "Authorization", api_key
112+
end
113+
end
114+
115+
def send_push_request_with_attestation(name, args)
116+
attestations = if options[:attestations].any?
117+
options[:attestations].map do |attestation|
118+
Gem.read_binary(attestation)
107119
end
120+
else
121+
bundle_path = attest!(name)
122+
begin
123+
[Gem.read_binary(bundle_path)]
124+
ensure
125+
File.unlink(bundle_path) if bundle_path && File.exist?(bundle_path)
126+
end
127+
end
128+
bundles = "[" + attestations.join(",") + "]"
129+
130+
rubygems_api_request(*args, scope: get_push_scope) do |request|
131+
request.set_form([
132+
["gem", Gem.read_binary(name), { filename: name, content_type: "application/octet-stream" }],
133+
["attestations", bundles, { content_type: "application/json" }],
134+
], "multipart/form-data")
108135
request.add_field "Authorization", api_key
109136
end
137+
rescue StandardError => e
138+
message = "Failed to push with attestation, retrying without attestation.\n"
139+
message += if Gem.configuration.really_verbose
140+
e.full_message
141+
else
142+
e.message
143+
end
144+
alert_warning message
145+
send_push_request_without_attestation(name, args)
146+
end
147+
148+
def attest!(name)
149+
require "open3"
150+
require "tempfile"
151+
152+
tempfile = Tempfile.new([File.basename(name, ".*"), ".sigstore.json"])
153+
bundle = tempfile.path
154+
tempfile.close(false)
155+
156+
env = defined?(Bundler.unbundled_env) ? Bundler.unbundled_env : ENV.to_h
157+
out, st = Open3.capture2e(
158+
env,
159+
Gem.ruby, "-S", "gem", "exec", "--conservative",
160+
"sigstore-cli", "sign", name, "--bundle", bundle,
161+
unsetenv_others: true
162+
)
163+
raise Gem::Exception, "Failed to sign gem:\n\n#{out}" unless st.success?
164+
165+
bundle
110166
end
111167

112168
def get_hosts_for(name)
@@ -122,14 +178,8 @@ def get_push_scope
122178
:push_rubygem
123179
end
124180

125-
def get_attestations_part
126-
bundles = "[" + options[:attestations].map do |attestation|
127-
Gem.read_binary(attestation)
128-
end.join(",") + "]"
129-
[
130-
"attestations",
131-
bundles,
132-
{ content_type: "application/json" },
133-
]
181+
def attestation_supported_host?
182+
host = (@host || Gem.host).to_s.chomp("/")
183+
host == Gem::DEFAULT_HOST
134184
end
135185
end

test/rubygems/test_gem_commands_push_command.rb

Lines changed: 128 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -115,32 +115,108 @@ def test_execute_attestation
115115
assert_equal Gem::Net::HTTP::Post, @fetcher.last_request.class
116116
content_length = @fetcher.last_request["Content-Length"].to_i
117117
assert_equal content_length, @fetcher.last_request.body.length
118-
assert_equal "multipart", @fetcher.last_request.main_type, @fetcher.last_request.content_type
119-
assert_equal "form-data", @fetcher.last_request.sub_type
120-
assert_include @fetcher.last_request.type_params, "boundary"
121-
boundary = @fetcher.last_request.type_params["boundary"]
118+
assert_attestation_multipart Gem.read_binary("#{@path}.sigstore.json")
119+
end
122120

123-
parts = @fetcher.last_request.body.split(/(?:\r\n|\A)--#{Regexp.quote(boundary)}(?:\r\n|--)/m)
124-
refute_empty parts
125-
assert_empty parts[0]
126-
parts.shift # remove the first empty part
121+
def test_execute_attestation_auto
122+
omit if RUBY_ENGINE == "jruby"
127123

128-
p1 = parts.shift
129-
p2 = parts.shift
130-
assert_equal "\r\n", parts.shift
131-
assert_empty parts
124+
ENV["GITHUB_ACTIONS"] = "true"
125+
begin
126+
@response = "Successfully registered gem: freewill (1.0.0)"
127+
@fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK")
132128

133-
assert_equal [
134-
"Content-Disposition: form-data; name=\"gem\"; filename=\"#{@path}\"",
135-
"Content-Type: application/octet-stream",
136-
nil,
137-
Gem.read_binary(@path),
138-
].join("\r\n").b, p1
139-
assert_equal [
140-
"Content-Disposition: form-data; name=\"attestations\"",
141-
nil,
142-
"[#{Gem.read_binary("#{@path}.sigstore.json")}]",
143-
].join("\r\n").b, p2
129+
attestation_path = "#{@path}.sigstore.json"
130+
attestation_content = "auto-attestation"
131+
File.write(attestation_path, attestation_content)
132+
@cmd.options[:args] = [@path]
133+
134+
@cmd.stub(:attest!, attestation_path) do
135+
@cmd.execute
136+
end
137+
138+
assert_equal Gem::Net::HTTP::Post, @fetcher.last_request.class
139+
content_length = @fetcher.last_request["Content-Length"].to_i
140+
assert_equal content_length, @fetcher.last_request.body.length
141+
assert_attestation_multipart attestation_content
142+
ensure
143+
ENV.delete("GITHUB_ACTIONS")
144+
end
145+
end
146+
147+
def test_execute_attestation_fallback
148+
omit if RUBY_ENGINE == "jruby"
149+
150+
ENV["GITHUB_ACTIONS"] = "true"
151+
begin
152+
@response = "Successfully registered gem: freewill (1.0.0)"
153+
@fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK")
154+
155+
@cmd.options[:args] = [@path]
156+
157+
@cmd.stub(:attest!, proc { raise Gem::Exception, "boom" }) do
158+
use_ui @ui do
159+
@cmd.execute
160+
end
161+
end
162+
163+
assert_match "Failed to push with attestation, retrying without attestation.", @ui.error
164+
assert_equal Gem::Net::HTTP::Post, @fetcher.last_request.class
165+
assert_equal Gem.read_binary(@path), @fetcher.last_request.body
166+
assert_equal "application/octet-stream",
167+
@fetcher.last_request["Content-Type"]
168+
ensure
169+
ENV.delete("GITHUB_ACTIONS")
170+
end
171+
end
172+
173+
def test_execute_attestation_skipped_on_non_rubygems_host
174+
@spec, @path = util_gem "freebird", "1.0.1" do |spec|
175+
spec.metadata["allowed_push_host"] = "https://privategemserver.example"
176+
end
177+
178+
@response = "Successfully registered gem: freebird (1.0.1)"
179+
@fetcher.data["#{@spec.metadata["allowed_push_host"]}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK")
180+
181+
@cmd.options[:args] = [@path]
182+
183+
attest_called = false
184+
@cmd.stub(:attest!, proc { attest_called = true }) do
185+
@cmd.execute
186+
end
187+
188+
refute attest_called, "attest! should not be called for non-rubygems.org hosts"
189+
assert_equal Gem::Net::HTTP::Post, @fetcher.last_request.class
190+
assert_equal Gem.read_binary(@path), @fetcher.last_request.body
191+
assert_equal "application/octet-stream",
192+
@fetcher.last_request["Content-Type"]
193+
end
194+
195+
def test_execute_attestation_skipped_on_jruby
196+
@response = "Successfully registered gem: freewill (1.0.0)"
197+
@fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK")
198+
199+
@cmd.options[:args] = [@path]
200+
201+
attest_called = false
202+
engine = RUBY_ENGINE
203+
Object.send :remove_const, :RUBY_ENGINE
204+
Object.const_set :RUBY_ENGINE, "jruby"
205+
206+
begin
207+
@cmd.stub(:attest!, proc { attest_called = true }) do
208+
@cmd.execute
209+
end
210+
211+
refute attest_called, "attest! should not be called on JRuby"
212+
assert_equal Gem::Net::HTTP::Post, @fetcher.last_request.class
213+
assert_equal Gem.read_binary(@path), @fetcher.last_request.body
214+
assert_equal "application/octet-stream",
215+
@fetcher.last_request["Content-Type"]
216+
ensure
217+
Object.send :remove_const, :RUBY_ENGINE
218+
Object.const_set :RUBY_ENGINE, engine
219+
end
144220
end
145221

146222
def test_execute_allowed_push_host
@@ -643,6 +719,35 @@ def test_sending_gem_with_no_local_creds
643719

644720
private
645721

722+
def assert_attestation_multipart(attestation_payload)
723+
assert_equal "multipart", @fetcher.last_request.main_type, @fetcher.last_request.content_type
724+
assert_equal "form-data", @fetcher.last_request.sub_type
725+
assert_include @fetcher.last_request.type_params, "boundary"
726+
boundary = @fetcher.last_request.type_params["boundary"]
727+
728+
parts = @fetcher.last_request.body.split(/(?:\r\n|\A)--#{Regexp.quote(boundary)}(?:\r\n|--)/m)
729+
refute_empty parts
730+
assert_empty parts[0]
731+
parts.shift # remove the first empty part
732+
733+
p1 = parts.shift
734+
p2 = parts.shift
735+
assert_equal "\r\n", parts.shift
736+
assert_empty parts
737+
738+
assert_equal [
739+
"Content-Disposition: form-data; name=\"gem\"; filename=\"#{@path}\"",
740+
"Content-Type: application/octet-stream",
741+
nil,
742+
Gem.read_binary(@path),
743+
].join("\r\n").b, p1
744+
assert_equal [
745+
"Content-Disposition: form-data; name=\"attestations\"",
746+
nil,
747+
"[#{attestation_payload}]",
748+
].join("\r\n").b, p2
749+
end
750+
646751
def singleton_gem_class
647752
class << Gem; self; end
648753
end

0 commit comments

Comments
 (0)