Skip to content

Commit 6e64935

Browse files
duckinatordeivid-rodriguez
authored andcommitted
Merge pull request #4913 from duckinator/gem-rebuild
Add "gem rebuild" command. (cherry picked from commit e887efb)
1 parent db571b7 commit 6e64935

File tree

6 files changed

+433
-11
lines changed

6 files changed

+433
-11
lines changed

Manifest.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,7 @@ lib/rubygems/commands/pristine_command.rb
384384
lib/rubygems/commands/push_command.rb
385385
lib/rubygems/commands/query_command.rb
386386
lib/rubygems/commands/rdoc_command.rb
387+
lib/rubygems/commands/rebuild_command.rb
387388
lib/rubygems/commands/search_command.rb
388389
lib/rubygems/commands/server_command.rb
389390
lib/rubygems/commands/setup_command.rb
@@ -425,6 +426,7 @@ lib/rubygems/gemcutter_utilities.rb
425426
lib/rubygems/gemcutter_utilities/webauthn_listener.rb
426427
lib/rubygems/gemcutter_utilities/webauthn_listener/response.rb
427428
lib/rubygems/gemcutter_utilities/webauthn_poller.rb
429+
lib/rubygems/gemspec_helpers.rb
428430
lib/rubygems/install_default_message.rb
429431
lib/rubygems/install_message.rb
430432
lib/rubygems/install_update_options.rb

lib/rubygems/command_manager.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ class Gem::CommandManager
6060
:push,
6161
:query,
6262
:rdoc,
63+
:rebuild,
6364
:search,
6465
:server,
6566
:signin,

lib/rubygems/commands/build_command.rb

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
# frozen_string_literal: true
22

33
require_relative "../command"
4+
require_relative "../gemspec_helpers"
45
require_relative "../package"
56
require_relative "../version_option"
67

78
class Gem::Commands::BuildCommand < Gem::Command
89
include Gem::VersionOption
10+
include Gem::GemspecHelpers
911

1012
def initialize
1113
super "build", "Build a gem from a gemspec"
@@ -75,17 +77,6 @@ def execute
7577

7678
private
7779

78-
def find_gemspec(glob = "*.gemspec")
79-
gemspecs = Dir.glob(glob).sort
80-
81-
if gemspecs.size > 1
82-
alert_error "Multiple gemspecs found: #{gemspecs}, please specify one"
83-
terminate_interaction(1)
84-
end
85-
86-
gemspecs.first
87-
end
88-
8980
def build_gem
9081
gemspec = resolve_gem_name
9182

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
# frozen_string_literal: true
2+
3+
require "date"
4+
require "digest"
5+
require "fileutils"
6+
require "tmpdir"
7+
require_relative "../gemspec_helpers"
8+
require_relative "../package"
9+
10+
class Gem::Commands::RebuildCommand < Gem::Command
11+
include Gem::GemspecHelpers
12+
13+
DATE_FORMAT = "%Y-%m-%d %H:%M:%S.%N Z"
14+
15+
def initialize
16+
super "rebuild", "Attempt to reproduce a build of a gem."
17+
18+
add_option "--diff", "If the files don't match, compare them using diffoscope." do |_value, options|
19+
options[:diff] = true
20+
end
21+
22+
add_option "--force", "Skip validation of the spec." do |_value, options|
23+
options[:force] = true
24+
end
25+
26+
add_option "--strict", "Consider warnings as errors when validating the spec." do |_value, options|
27+
options[:strict] = true
28+
end
29+
30+
add_option "--source GEM_SOURCE", "Specify the source to download the gem from." do |value, options|
31+
options[:source] = value
32+
end
33+
34+
add_option "--original GEM_FILE", "Specify a local file to compare against (instead of downloading it)." do |value, options|
35+
options[:original_gem_file] = value
36+
end
37+
38+
add_option "--gemspec GEMSPEC_FILE", "Specify the name of the gemspec file." do |value, options|
39+
options[:gemspec_file] = value
40+
end
41+
42+
add_option "-C PATH", "Run as if gem build was started in <PATH> instead of the current working directory." do |value, options|
43+
options[:build_path] = value
44+
end
45+
end
46+
47+
def arguments # :nodoc:
48+
"GEM_NAME gem name on gem server\n" \
49+
"GEM_VERSION gem version you are attempting to rebuild"
50+
end
51+
52+
def description # :nodoc:
53+
<<-EOF
54+
The rebuild command allows you to (attempt to) reproduce a build of a gem
55+
from a ruby gemspec.
56+
57+
This command assumes the gemspec can be built with the `gem build` command.
58+
If you use any of `gem build`, `rake build`, or`rake release` in the
59+
build/release process for a gem, it is a potential candidate.
60+
61+
You will need to match the RubyGems version used, since this is included in
62+
the Gem metadata.
63+
64+
If the gem includes lockfiles (e.g. Gemfile.lock) and similar, it will
65+
require more effort to reproduce a build. For example, it might require
66+
more precisely matched versions of Ruby and/or Bundler to be used.
67+
EOF
68+
end
69+
70+
def usage # :nodoc:
71+
"#{program_name} GEM_NAME GEM_VERSION"
72+
end
73+
74+
def execute
75+
gem_name, gem_version = get_gem_name_and_version
76+
77+
old_dir, new_dir = prep_dirs
78+
79+
gem_filename = "#{gem_name}-#{gem_version}.gem"
80+
old_file = File.join(old_dir, gem_filename)
81+
new_file = File.join(new_dir, gem_filename)
82+
83+
if options[:original_gem_file]
84+
FileUtils.copy_file(options[:original_gem_file], old_file)
85+
else
86+
download_gem(gem_name, gem_version, old_file)
87+
end
88+
89+
rg_version = rubygems_version(old_file)
90+
unless rg_version == Gem::VERSION
91+
alert_error <<-EOF
92+
You need to use the same RubyGems version #{gem_name} v#{gem_version} was built with.
93+
94+
#{gem_name} v#{gem_version} was built using RubyGems v#{rg_version}.
95+
Gem files include the version of RubyGems used to build them.
96+
This means in order to reproduce #{gem_filename}, you must also use RubyGems v#{rg_version}.
97+
98+
You're using RubyGems v#{Gem::VERSION}.
99+
100+
Please install RubyGems v#{rg_version} and try again.
101+
EOF
102+
terminate_interaction 1
103+
end
104+
105+
source_date_epoch = get_timestamp(old_file).to_s
106+
107+
if build_path = options[:build_path]
108+
Dir.chdir(build_path) { build_gem(gem_name, source_date_epoch, new_file) }
109+
else
110+
build_gem(gem_name, source_date_epoch, new_file)
111+
end
112+
113+
compare(source_date_epoch, old_file, new_file)
114+
end
115+
116+
private
117+
118+
def sha256(file)
119+
Digest::SHA256.hexdigest(Gem.read_binary(file))
120+
end
121+
122+
def get_timestamp(file)
123+
mtime = nil
124+
File.open(file, Gem.binary_mode) do |f|
125+
Gem::Package::TarReader.new(f) do |tar|
126+
mtime = tar.seek("metadata.gz") {|tf| tf.header.mtime }
127+
end
128+
end
129+
130+
mtime
131+
end
132+
133+
def compare(source_date_epoch, old_file, new_file)
134+
date = Time.at(source_date_epoch.to_i).strftime("%F %T %Z")
135+
136+
old_hash = sha256(old_file)
137+
new_hash = sha256(new_file)
138+
139+
say
140+
say "Built at: #{date} (#{source_date_epoch})"
141+
say "Original build saved to: #{old_file}"
142+
say "Reproduced build saved to: #{new_file}"
143+
say "Working directory: #{options[:build_path] || Dir.pwd}"
144+
say
145+
say "Hash comparison:"
146+
say " #{old_hash}\t#{old_file}"
147+
say " #{new_hash}\t#{new_file}"
148+
say
149+
150+
if old_hash == new_hash
151+
say "SUCCESS - original and rebuild hashes matched"
152+
else
153+
say "FAILURE - original and rebuild hashes did not match"
154+
say
155+
156+
if options[:diff]
157+
if system("diffoscope", old_file, new_file).nil?
158+
alert_error "error: could not find `diffoscope` executable"
159+
end
160+
else
161+
say "Pass --diff for more details (requires diffoscope to be installed)."
162+
end
163+
164+
terminate_interaction 1
165+
end
166+
end
167+
168+
def prep_dirs
169+
rebuild_dir = Dir.mktmpdir("gem_rebuild")
170+
old_dir = File.join(rebuild_dir, "old")
171+
new_dir = File.join(rebuild_dir, "new")
172+
173+
FileUtils.mkdir_p(old_dir)
174+
FileUtils.mkdir_p(new_dir)
175+
176+
[old_dir, new_dir]
177+
end
178+
179+
def get_gem_name_and_version
180+
args = options[:args] || []
181+
if args.length == 2
182+
gem_name, gem_version = args
183+
elsif args.length > 2
184+
raise Gem::CommandLineError, "Too many arguments"
185+
else
186+
raise Gem::CommandLineError, "Expected GEM_NAME and GEM_VERSION arguments (gem rebuild GEM_NAME GEM_VERSION)"
187+
end
188+
189+
[gem_name, gem_version]
190+
end
191+
192+
def build_gem(gem_name, source_date_epoch, output_file)
193+
gemspec = options[:gemspec_file] || find_gemspec("#{gem_name}.gemspec")
194+
195+
if gemspec
196+
build_package(gemspec, source_date_epoch, output_file)
197+
else
198+
alert_error error_message(gem_name)
199+
terminate_interaction(1)
200+
end
201+
end
202+
203+
def build_package(gemspec, source_date_epoch, output_file)
204+
with_source_date_epoch(source_date_epoch) do
205+
spec = Gem::Specification.load(gemspec)
206+
if spec
207+
Gem::Package.build(
208+
spec,
209+
options[:force],
210+
options[:strict],
211+
output_file
212+
)
213+
else
214+
alert_error "Error loading gemspec. Aborting."
215+
terminate_interaction 1
216+
end
217+
end
218+
end
219+
220+
def with_source_date_epoch(source_date_epoch)
221+
old_sde = ENV["SOURCE_DATE_EPOCH"]
222+
ENV["SOURCE_DATE_EPOCH"] = source_date_epoch.to_s
223+
224+
yield
225+
ensure
226+
ENV["SOURCE_DATE_EPOCH"] = old_sde
227+
end
228+
229+
def error_message(gem_name)
230+
if gem_name
231+
"Couldn't find a gemspec file matching '#{gem_name}' in #{Dir.pwd}"
232+
else
233+
"Couldn't find a gemspec file in #{Dir.pwd}"
234+
end
235+
end
236+
237+
def download_gem(gem_name, gem_version, old_file)
238+
# This code was based loosely off the `gem fetch` command.
239+
version = "= #{gem_version}"
240+
dep = Gem::Dependency.new gem_name, version
241+
242+
specs_and_sources, errors =
243+
Gem::SpecFetcher.fetcher.spec_for_dependency dep
244+
245+
# There should never be more than one item in specs_and_sources,
246+
# since we search for an exact version.
247+
spec, source = specs_and_sources[0]
248+
249+
if spec.nil?
250+
show_lookup_failure gem_name, version, errors, options[:domain]
251+
terminate_interaction 1
252+
end
253+
254+
download_path = source.download spec
255+
256+
FileUtils.move(download_path, old_file)
257+
258+
say "Downloaded #{gem_name} version #{gem_version} as #{old_file}."
259+
end
260+
261+
def rubygems_version(gem_file)
262+
Gem::Package.new(gem_file).spec.rubygems_version
263+
end
264+
end

lib/rubygems/gemspec_helpers.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "../rubygems"
4+
5+
##
6+
# Mixin methods for commands that work with gemspecs.
7+
8+
module Gem::GemspecHelpers
9+
def find_gemspec(glob = "*.gemspec")
10+
gemspecs = Dir.glob(glob).sort
11+
12+
if gemspecs.size > 1
13+
alert_error "Multiple gemspecs found: #{gemspecs}, please specify one"
14+
terminate_interaction(1)
15+
end
16+
17+
gemspecs.first
18+
end
19+
end

0 commit comments

Comments
 (0)