mirror of
https://github.com/ruby/ruby.git
synced 2022-11-09 12:17:21 -05:00
68a5b0f086
When extracting files from the tarball, a mode is retrieved from the header. Occasionally you'll encounter a gem that was packaged on a system whose permission bits result in a value that is larger than the value that File.chmod will allow (anything >= 2^16). In that case the extraction fails with a RangeError, which is pretty esoteric. If you extract the tarball with the tar and gunzip utilities, the file permissions end up being just the bottom 16 bits masked off from the original value. I've mirrored that behavior here. Per the tar spec: > Modes which are not supported by the operating system restoring > files from the archive will be ignored. I think that basically means what I've done here. --- This commit also changes the behavior very slightly with regard to when the chmod is called. Previously it was called while the file descriptor was still open, but after the write call. When write flushes, the file permissions are changed to the mode value from the File.open call, undoing the changes made by FileUtils.chmod. CRuby appears to flush the buffer after the chmod call, whereas TruffleRuby flushes before the chmod call. So the file permissions can change depending on implementation. Both implementations end up getting the correct file permissions for the bottom 9 bits (user, group, world), but differ with regard to the sticky bit in the next 3. To get consistent behavior, this commit changes it to close the file descriptor before attempting to chmod anything, which makes it consistent because the write flushes in both cases. https://github.com/rubygems/rubygems/commit/22ce076e99
720 lines
18 KiB
Ruby
720 lines
18 KiB
Ruby
# 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
|
|
|
|
if entry.file?
|
|
File.open(destination, "wb") {|out| out.write entry.read }
|
|
FileUtils.chmod file_mode(entry.header.mode), destination
|
|
end
|
|
|
|
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) ||
|
|
# If we're not using one of the default modes, then we're going to fall
|
|
# back to the mode from the tarball. In this case we need to mask it down
|
|
# to fit into 2^16 bits (the maximum value for a mode in CRuby since it
|
|
# gets put into an unsigned short).
|
|
(mode & ((1 << 16) - 1))
|
|
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)) && 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"
|