/lib/rubygems/package.rb
Ruby | 713 lines | 415 code | 149 blank | 149 comment | 29 complexity | c552f14781cd921059086a69a708b17c MD5 | raw file
Possible License(s): LGPL-2.1, AGPL-3.0, 0BSD, Unlicense, GPL-2.0, BSD-3-Clause
- # 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_relative "../rubygems"
- require_relative 'security'
- require_relative '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_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'