1
0
Fork 0
mirror of https://github.com/ruby/ruby.git synced 2022-11-09 12:17:21 -05:00
ruby--ruby/lib/rubygems/package.rb

736 lines
18 KiB
Ruby
Raw Normal View History

# -*- coding: utf-8 -*-
# frozen_string_literal: true
#--
# Copyright (C) 2004 Mauricio Julio Fernández Pradier
# See LICENSE.txt for additional licensing information.
#++
#
# 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 and metadata.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
require 'rubygems/security'
require 'rubygems/specification'
require 'rubygems/user_interaction'
require 'zlib'
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 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:
@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|
YAML.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?
target_path = File.readlink(file)
unless target_path.start_with? '.'
relative_dir = File.dirname(file).sub("#{Dir.pwd}/", '')
target_path = File.join(relative_dir, target_path)
end
tar.add_symlink file, target_path, 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 =
if defined?(OpenSSL::Digest)
OpenSSL::Digest.new algorithm
else
Digest.const_get(algorithm).new
end
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 = [] if dir_mode
open_tar_gz io do |tar|
tar.each do |entry|
next unless File.fnmatch pattern, entry.full_name, File::FNM_DOTMATCH
destination = install_location entry.full_name, destination_dir
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
directories << mkdir if directories
mkdir_p_safe mkdir, mkdir_options, destination_dir, entry.full_name
File.open destination, 'wb' do |out|
out.write entry.read
FileUtils.chmod file_mode(entry.header.mode), destination
end if entry.file?
File.symlink(entry.header.linkname, destination) if entry.symlink?
verbose destination
end
end
if directories
directories.uniq!
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.expand_path(File.realpath(destination_dir))
destination = File.expand_path(File.join(destination_dir, filename))
raise Gem::Package::PathError.new(destination, destination_dir) unless
destination.start_with? destination_dir + '/'
begin
real_destination = File.expand_path(File.realpath(destination))
rescue
# it's fine if the destination doesn't exist, because rm -rf'ing it can't cause any damage
nil
else
raise Gem::Package::PathError.new(real_destination, destination_dir) unless
real_destination.start_with? destination_dir + '/'
end
destination.untaint
destination
end
def normalize_path(pathname)
if Gem.win_platform?
pathname.downcase
else
pathname
end
end
def mkdir_p_safe(mkdir, mkdir_options, destination_dir, file_name)
destination_dir = File.realpath(File.expand_path(destination_dir))
parts = mkdir.split(File::SEPARATOR)
parts.reduce do |path, basename|
path = File.realpath(path) unless path == ""
path = File.expand_path(path + File::SEPARATOR + basename)
lstat = File.lstat path rescue nil
if !lstat || !lstat.directory?
unless normalize_path(path).start_with? normalize_path(destination_dir) and (FileUtils.mkdir path, **mkdir_options rescue false)
raise Gem::Package::PathError.new(file_name, destination_dir)
end
end
path
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
Make rb_scan_args handle keywords more similar to Ruby methods (#2460) Cfuncs that use rb_scan_args with the : entry suffer similar keyword argument separation issues that Ruby methods suffer if the cfuncs accept optional or variable arguments. This makes the following changes to : handling. * Treats as **kw, prompting keyword argument separation warnings if called with a positional hash. * Do not look for an option hash if empty keywords are provided. For backwards compatibility, treat an empty keyword splat as a empty mandatory positional hash argument, but emit a a warning, as this behavior will be removed in Ruby 3. The argument number check needs to be moved lower so it can correctly handle an empty positional argument being added. * If the last argument is nil and it is necessary to treat it as an option hash in order to make sure all arguments are processed, continue to treat the last argument as the option hash. Emit a warning in this case, as this behavior will be removed in Ruby 3. * If splitting the keyword hash into two hashes, issue a warning, as we will not be splitting hashes in Ruby 3. * If the keyword argument is required to fill a mandatory positional argument, continue to do so, but emit a warning as this behavior will be going away in Ruby 3. * If keyword arguments are provided and the last argument is not a hash, that indicates something wrong. This can happen if a cfunc is calling rb_scan_args multiple times, and providing arguments that were not passed to it from Ruby. Callers need to switch to the new rb_scan_args_kw function, which allows passing of whether keywords were provided. This commit fixes all warnings caused by the changes above. It switches some function calls to *_kw versions with appropriate kw_splat flags. If delegating arguments, RB_PASS_CALLED_KEYWORDS is used. If creating new arguments, RB_PASS_KEYWORDS is used if the last argument is a hash to be treated as keywords. In open_key_args in io.c, use rb_scan_args_kw. In this case, the arguments provided come from another C function, not Ruby. The last argument may or may not be a hash, so we can't set keyword argument mode. However, if it is a hash, we don't want to warn when treating it as keywords. In Ruby files, make sure to appropriately use keyword splats or literal keywords when calling Cfuncs that now issue keyword argument separation warnings through rb_scan_args. Also, make sure not to pass nil in place of an option hash. Work around Kernel#warn warnings due to problems in the Rubygems override of the method. There is an open pull request to fix these issues in Rubygems, but part of the Rubygems tests for their override fail on ruby-head due to rb_scan_args not recognizing empty keyword splats, which this commit fixes. Implementation wise, adding rb_scan_args_kw is kind of a pain, because rb_scan_args takes a variable number of arguments. In order to not duplicate all the code, the function internals need to be split into two functions taking a va_list, and to avoid passing in a ton of arguments, a single struct argument is used to handle the variables previously local to the function.
2019-09-25 11:18:49 -07:00
opts = {}
opts[:external_encoding] = Encoding::UTF_8 if
Zlib::GzipReader.method(:wrap).arity != 1
Make rb_scan_args handle keywords more similar to Ruby methods (#2460) Cfuncs that use rb_scan_args with the : entry suffer similar keyword argument separation issues that Ruby methods suffer if the cfuncs accept optional or variable arguments. This makes the following changes to : handling. * Treats as **kw, prompting keyword argument separation warnings if called with a positional hash. * Do not look for an option hash if empty keywords are provided. For backwards compatibility, treat an empty keyword splat as a empty mandatory positional hash argument, but emit a a warning, as this behavior will be removed in Ruby 3. The argument number check needs to be moved lower so it can correctly handle an empty positional argument being added. * If the last argument is nil and it is necessary to treat it as an option hash in order to make sure all arguments are processed, continue to treat the last argument as the option hash. Emit a warning in this case, as this behavior will be removed in Ruby 3. * If splitting the keyword hash into two hashes, issue a warning, as we will not be splitting hashes in Ruby 3. * If the keyword argument is required to fill a mandatory positional argument, continue to do so, but emit a warning as this behavior will be going away in Ruby 3. * If keyword arguments are provided and the last argument is not a hash, that indicates something wrong. This can happen if a cfunc is calling rb_scan_args multiple times, and providing arguments that were not passed to it from Ruby. Callers need to switch to the new rb_scan_args_kw function, which allows passing of whether keywords were provided. This commit fixes all warnings caused by the changes above. It switches some function calls to *_kw versions with appropriate kw_splat flags. If delegating arguments, RB_PASS_CALLED_KEYWORDS is used. If creating new arguments, RB_PASS_KEYWORDS is used if the last argument is a hash to be treated as keywords. In open_key_args in io.c, use rb_scan_args_kw. In this case, the arguments provided come from another C function, not Ruby. The last argument may or may not be a hash, so we can't set keyword argument mode. However, if it is a hash, we don't want to warn when treating it as keywords. In Ruby files, make sure to appropriately use keyword splats or literal keywords when calling Cfuncs that now issue keyword argument separation warnings through rb_scan_args. Also, make sure not to pass nil in place of an option hash. Work around Kernel#warn warnings due to problems in the Rubygems override of the method. There is an open pull request to fix these issues in Rubygems, but part of the Rubygems tests for their override fail on ruby-head due to rb_scan_args not recognizing empty keyword splats, which this commit fixes. Implementation wise, adding rb_scan_args_kw is kind of a pain, because rb_scan_args takes a variable number of arguments. In order to not duplicate all the code, the function internals need to be split into two functions taking a va_list, and to avoid passing in a ton of arguments, a single struct argument is used to handle the variables previously local to the function.
2019-09-25 11:18:49 -07:00
Zlib::GzipReader.wrap(entry, **opts) 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 => e
message = "package is corrupt, exception while verifying: " +
"#{e.message} (#{e.class})"
raise Gem::Package::FormatError.new message, @gem
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 'rubygems/package/digest_io'
require 'rubygems/package/source'
require 'rubygems/package/file_source'
require 'rubygems/package/io_source'
require 'rubygems/package/old'
require 'rubygems/package/tar_header'
require 'rubygems/package/tar_reader'
require 'rubygems/package/tar_reader/entry'
require 'rubygems/package/tar_writer'