mirror of
https://github.com/ruby/ruby.git
synced 2022-11-09 12:17:21 -05:00
713 lines
18 KiB
Ruby
713 lines
18 KiB
Ruby
# 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, 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
|
|
|
|
require "rubygems"
|
|
require 'rubygems/security'
|
|
require 'rubygems/user_interaction'
|
|
|
|
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:
|
|
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|
|
|
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?
|
|
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 = [] 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.tap(&Gem::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
|
|
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 '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'
|