1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
|
# frozen_string_literal: true
#--
# Copyright (C) 2004 Mauricio Julio Fernández Pradier
# See LICENSE.txt for additional licensing information.
#++
require_relative "../rubygems"
require_relative "security"
require_relative "user_interaction"
##
# Example using a Gem::Package
#
# Builds a .gem file given a Gem::Specification. A .gem file is a tarball
# which contains a data.tar.gz, metadata.gz, checksums.yaml.gz and possibly
# signatures.
#
# require 'rubygems'
# require 'rubygems/package'
#
# spec = Gem::Specification.new do |s|
# s.summary = "Ruby based make-like utility."
# s.name = 'rake'
# s.version = PKG_VERSION
# s.requirements << 'none'
# s.files = PKG_FILES
# s.description = <<-EOF
# Rake is a Make-like program implemented in Ruby. Tasks
# and dependencies are specified in standard Ruby syntax.
# EOF
# end
#
# Gem::Package.build spec
#
# Reads a .gem file.
#
# require 'rubygems'
# require 'rubygems/package'
#
# the_gem = Gem::Package.new(path_to_dot_gem)
# the_gem.contents # get the files in the gem
# the_gem.extract_files destination_directory # extract the gem into a directory
# the_gem.spec # get the spec out of the gem
# the_gem.verify # check the gem is OK (contains valid gem specification, contains a not corrupt contents archive)
#
# #files are the files in the .gem tar file, not the Ruby files in the gem
# #extract_files and #contents automatically call #verify
class Gem::Package
include Gem::UserInteraction
class Error < Gem::Exception; end
class FormatError < Error
attr_reader :path
def initialize(message, source = nil)
if source
@path = source.path
message = message + " in #{path}" if path
end
super message
end
end
class PathError < Error
def initialize(destination, destination_dir)
super "installing into parent path %s of %s is not allowed" %
[destination, destination_dir]
end
end
class SymlinkError < Error
def initialize(name, destination, destination_dir)
super "installing symlink '%s' pointing to parent path %s of %s is not allowed" %
[name, destination, destination_dir]
end
end
class NonSeekableIO < Error; end
class TooLongFileName < Error; end
##
# Raised when a tar file is corrupt
class TarInvalidError < Error; end
attr_accessor :build_time # :nodoc:
##
# Checksums for the contents of the package
attr_reader :checksums
##
# The files in this package. This is not the contents of the gem, just the
# files in the top-level container.
attr_reader :files
##
# Reference to the gem being packaged.
attr_reader :gem
##
# The security policy used for verifying the contents of this package.
attr_accessor :security_policy
##
# Sets the Gem::Specification to use to build this package.
attr_writer :spec
##
# Permission for directories
attr_accessor :dir_mode
##
# Permission for program files
attr_accessor :prog_mode
##
# Permission for other files
attr_accessor :data_mode
def self.build(spec, skip_validation = false, strict_validation = false, file_name = nil)
gem_file = file_name || spec.file_name
package = new gem_file
package.spec = spec
package.build skip_validation, strict_validation
gem_file
end
##
# Creates a new Gem::Package for the file at +gem+. +gem+ can also be
# provided as an IO object.
#
# If +gem+ is an existing file in the old format a Gem::Package::Old will be
# returned.
def self.new(gem, security_policy = nil)
gem = if gem.is_a?(Gem::Package::Source)
gem
elsif gem.respond_to? :read
Gem::Package::IOSource.new gem
else
Gem::Package::FileSource.new gem
end
return super unless Gem::Package == self
return super unless gem.present?
return super unless gem.start
return super unless gem.start.include? "MD5SUM ="
Gem::Package::Old.new gem
end
##
# Extracts the Gem::Specification and raw metadata from the .gem file at
# +path+.
#--
def self.raw_spec(path, security_policy = nil)
format = new(path, security_policy)
spec = format.spec
metadata = nil
File.open path, Gem.binary_mode do |io|
tar = Gem::Package::TarReader.new io
tar.each_entry do |entry|
case entry.full_name
when "metadata" then
metadata = entry.read
when "metadata.gz" then
metadata = Gem::Util.gunzip entry.read
end
end
end
return spec, metadata
end
##
# Creates a new package that will read or write to the file +gem+.
def initialize(gem, security_policy) # :notnew:
require "zlib"
@gem = gem
@build_time = Gem.source_date_epoch
@checksums = {}
@contents = nil
@digests = Hash.new {|h, algorithm| h[algorithm] = {} }
@files = nil
@security_policy = security_policy
@signatures = {}
@signer = nil
@spec = nil
end
##
# Copies this package to +path+ (if possible)
def copy_to(path)
FileUtils.cp @gem.path, path unless File.exist? path
end
##
# Adds a checksum for each entry in the gem to checksums.yaml.gz.
def add_checksums(tar)
Gem.load_yaml
checksums_by_algorithm = Hash.new {|h, algorithm| h[algorithm] = {} }
@checksums.each do |name, digests|
digests.each do |algorithm, digest|
checksums_by_algorithm[algorithm][name] = digest.hexdigest
end
end
tar.add_file_signed "checksums.yaml.gz", 0444, @signer do |io|
gzip_to io do |gz_io|
Psych.dump checksums_by_algorithm, gz_io
end
end
end
##
# Adds the files listed in the packages's Gem::Specification to data.tar.gz
# and adds this file to the +tar+.
def add_contents(tar) # :nodoc:
digests = tar.add_file_signed "data.tar.gz", 0444, @signer do |io|
gzip_to io do |gz_io|
Gem::Package::TarWriter.new gz_io do |data_tar|
add_files data_tar
end
end
end
@checksums["data.tar.gz"] = digests
end
##
# Adds files included the package's Gem::Specification to the +tar+ file
def add_files(tar) # :nodoc:
@spec.files.each do |file|
stat = File.lstat file
if stat.symlink?
tar.add_symlink file, File.readlink(file), stat.mode
end
next unless stat.file?
tar.add_file_simple file, stat.mode, stat.size do |dst_io|
File.open file, "rb" do |src_io|
dst_io.write src_io.read 16384 until src_io.eof?
end
end
end
end
##
# Adds the package's Gem::Specification to the +tar+ file
def add_metadata(tar) # :nodoc:
digests = tar.add_file_signed "metadata.gz", 0444, @signer do |io|
gzip_to io do |gz_io|
gz_io.write @spec.to_yaml
end
end
@checksums["metadata.gz"] = digests
end
##
# Builds this package based on the specification set by #spec=
def build(skip_validation = false, strict_validation = false)
raise ArgumentError, "skip_validation = true and strict_validation = true are incompatible" if skip_validation && strict_validation
Gem.load_yaml
@spec.mark_version
@spec.validate true, strict_validation unless skip_validation
setup_signer(
signer_options: {
expiration_length_days: Gem.configuration.cert_expiration_length_days,
}
)
@gem.with_write_io do |gem_io|
Gem::Package::TarWriter.new gem_io do |gem|
add_metadata gem
add_contents gem
add_checksums gem
end
end
say <<-EOM
Successfully built RubyGem
Name: #{@spec.name}
Version: #{@spec.version}
File: #{File.basename @gem.path}
EOM
ensure
@signer = nil
end
##
# A list of file names contained in this gem
def contents
return @contents if @contents
verify unless @spec
@contents = []
@gem.with_read_io do |io|
gem_tar = Gem::Package::TarReader.new io
gem_tar.each do |entry|
next unless entry.full_name == "data.tar.gz"
open_tar_gz entry do |pkg_tar|
pkg_tar.each do |contents_entry|
@contents << contents_entry.full_name
end
end
return @contents
end
end
end
##
# Creates a digest of the TarEntry +entry+ from the digest algorithm set by
# the security policy.
def digest(entry) # :nodoc:
algorithms = if @checksums
@checksums.keys
else
[Gem::Security::DIGEST_NAME].compact
end
algorithms.each do |algorithm|
digester = Gem::Security.create_digest(algorithm)
digester << entry.read(16384) until entry.eof?
entry.rewind
@digests[algorithm][entry.full_name] = digester
end
@digests
end
##
# Extracts the files in this package into +destination_dir+
#
# If +pattern+ is specified, only entries matching that glob will be
# extracted.
def extract_files(destination_dir, pattern = "*")
verify unless @spec
FileUtils.mkdir_p destination_dir, :mode => dir_mode && 0755
@gem.with_read_io do |io|
reader = Gem::Package::TarReader.new io
reader.each do |entry|
next unless entry.full_name == "data.tar.gz"
extract_tar_gz entry, destination_dir, pattern
return # ignore further entries
end
end
end
##
# Extracts all the files in the gzipped tar archive +io+ into
# +destination_dir+.
#
# If an entry in the archive contains a relative path above
# +destination_dir+ or an absolute path is encountered an exception is
# raised.
#
# If +pattern+ is specified, only entries matching that glob will be
# extracted.
def extract_tar_gz(io, destination_dir, pattern = "*") # :nodoc:
directories = []
symlinks = []
open_tar_gz io do |tar|
tar.each do |entry|
full_name = entry.full_name
next unless File.fnmatch pattern, full_name, File::FNM_DOTMATCH
destination = install_location full_name, destination_dir
if entry.symlink?
link_target = entry.header.linkname
real_destination = link_target.start_with?("/") ? link_target : File.expand_path(link_target, File.dirname(destination))
raise Gem::Package::SymlinkError.new(full_name, real_destination, destination_dir) unless
normalize_path(real_destination).start_with? normalize_path(destination_dir + "/")
symlinks << [full_name, link_target, destination, real_destination]
end
FileUtils.rm_rf destination
mkdir_options = {}
mkdir_options[:mode] = dir_mode ? 0755 : (entry.header.mode if entry.directory?)
mkdir =
if entry.directory?
destination
else
File.dirname destination
end
unless directories.include?(mkdir)
FileUtils.mkdir_p mkdir, **mkdir_options
directories << mkdir
end
File.open destination, "wb" do |out|
out.write entry.read
FileUtils.chmod file_mode(entry.header.mode), destination
end if entry.file?
verbose destination
end
end
symlinks.each do |name, target, destination, real_destination|
if File.exist?(real_destination)
File.symlink(target, destination)
else
alert_warning "#{@spec.full_name} ships with a dangling symlink named #{name} pointing to missing #{target} file. Ignoring"
end
end
if dir_mode
File.chmod(dir_mode, *directories)
end
end
def file_mode(mode) # :nodoc:
((mode & 0111).zero? ? data_mode : prog_mode) || mode
end
##
# Gzips content written to +gz_io+ to +io+.
#--
# Also sets the gzip modification time to the package build time to ease
# testing.
def gzip_to(io) # :yields: gz_io
gz_io = Zlib::GzipWriter.new io, Zlib::BEST_COMPRESSION
gz_io.mtime = @build_time
yield gz_io
ensure
gz_io.close
end
##
# Returns the full path for installing +filename+.
#
# If +filename+ is not inside +destination_dir+ an exception is raised.
def install_location(filename, destination_dir) # :nodoc:
raise Gem::Package::PathError.new(filename, destination_dir) if
filename.start_with? "/"
destination_dir = File.realpath(destination_dir)
destination = File.expand_path(filename, destination_dir)
raise Gem::Package::PathError.new(destination, destination_dir) unless
normalize_path(destination).start_with? normalize_path(destination_dir + "/")
destination.tap(&Gem::UNTAINT)
destination
end
def normalize_path(pathname)
if Gem.win_platform?
pathname.downcase
else
pathname
end
end
##
# Loads a Gem::Specification from the TarEntry +entry+
def load_spec(entry) # :nodoc:
case entry.full_name
when "metadata" then
@spec = Gem::Specification.from_yaml entry.read
when "metadata.gz" then
Zlib::GzipReader.wrap(entry, external_encoding: Encoding::UTF_8) do |gzio|
@spec = Gem::Specification.from_yaml gzio.read
end
end
end
##
# Opens +io+ as a gzipped tar archive
def open_tar_gz(io) # :nodoc:
Zlib::GzipReader.wrap io do |gzio|
tar = Gem::Package::TarReader.new gzio
yield tar
end
end
##
# Reads and loads checksums.yaml.gz from the tar file +gem+
def read_checksums(gem)
Gem.load_yaml
@checksums = gem.seek "checksums.yaml.gz" do |entry|
Zlib::GzipReader.wrap entry do |gz_io|
Gem::SafeYAML.safe_load gz_io.read
end
end
end
##
# Prepares the gem for signing and checksum generation. If a signing
# certificate and key are not present only checksum generation is set up.
def setup_signer(signer_options: {})
passphrase = ENV["GEM_PRIVATE_KEY_PASSPHRASE"]
if @spec.signing_key
@signer =
Gem::Security::Signer.new(
@spec.signing_key,
@spec.cert_chain,
passphrase,
signer_options
)
@spec.signing_key = nil
@spec.cert_chain = @signer.cert_chain.map {|cert| cert.to_s }
else
@signer = Gem::Security::Signer.new nil, nil, passphrase
@spec.cert_chain = @signer.cert_chain.map {|cert| cert.to_pem } if
@signer.cert_chain
end
end
##
# The spec for this gem.
#
# If this is a package for a built gem the spec is loaded from the
# gem and returned. If this is a package for a gem being built the provided
# spec is returned.
def spec
verify unless @spec
@spec
end
##
# Verifies that this gem:
#
# * Contains a valid gem specification
# * Contains a contents archive
# * The contents archive is not corrupt
#
# After verification the gem specification from the gem is available from
# #spec
def verify
@files = []
@spec = nil
@gem.with_read_io do |io|
Gem::Package::TarReader.new io do |reader|
read_checksums reader
verify_files reader
end
end
verify_checksums @digests, @checksums
@security_policy.verify_signatures @spec, @digests, @signatures if
@security_policy
true
rescue Gem::Security::Exception
@spec = nil
@files = []
raise
rescue Errno::ENOENT => e
raise Gem::Package::FormatError.new e.message
rescue Gem::Package::TarInvalidError => e
raise Gem::Package::FormatError.new e.message, @gem
end
##
# Verifies the +checksums+ against the +digests+. This check is not
# cryptographically secure. Missing checksums are ignored.
def verify_checksums(digests, checksums) # :nodoc:
return unless checksums
checksums.sort.each do |algorithm, gem_digests|
gem_digests.sort.each do |file_name, gem_hexdigest|
computed_digest = digests[algorithm][file_name]
unless computed_digest.hexdigest == gem_hexdigest
raise Gem::Package::FormatError.new \
"#{algorithm} checksum mismatch for #{file_name}", @gem
end
end
end
end
##
# Verifies +entry+ in a .gem file.
def verify_entry(entry)
file_name = entry.full_name
@files << file_name
case file_name
when /\.sig$/ then
@signatures[$`] = entry.read if @security_policy
return
else
digest entry
end
case file_name
when "metadata", "metadata.gz" then
load_spec entry
when "data.tar.gz" then
verify_gz entry
end
rescue
warn "Exception while verifying #{@gem.path}"
raise
end
##
# Verifies the files of the +gem+
def verify_files(gem)
gem.each do |entry|
verify_entry entry
end
unless @spec
raise Gem::Package::FormatError.new "package metadata is missing", @gem
end
unless @files.include? "data.tar.gz"
raise Gem::Package::FormatError.new \
"package content (data.tar.gz) is missing", @gem
end
if duplicates = @files.group_by {|f| f }.select {|k,v| v.size > 1 }.map(&:first) and duplicates.any?
raise Gem::Security::Exception, "duplicate files in the package: (#{duplicates.map(&:inspect).join(', ')})"
end
end
##
# Verifies that +entry+ is a valid gzipped file.
def verify_gz(entry) # :nodoc:
Zlib::GzipReader.wrap entry do |gzio|
gzio.read 16384 until gzio.eof? # gzip checksum verification
end
rescue Zlib::GzipFile::Error => e
raise Gem::Package::FormatError.new(e.message, entry.full_name)
end
end
require_relative "package/digest_io"
require_relative "package/source"
require_relative "package/file_source"
require_relative "package/io_source"
require_relative "package/old"
require_relative "package/tar_header"
require_relative "package/tar_reader"
require_relative "package/tar_reader/entry"
require_relative "package/tar_writer"
|