#– # 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 # 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, path = nil
    @path = path

    message << " in #{path}" if path

    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

##
# 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

def self.build spec, skip_validation=false
  gem_file = spec.file_name

  package = new gem_file
  package.spec = spec
  package.build skip_validation

  gem_file
end

##
# Creates a new Gem::Package for the file at +gem+.
#
# If +gem+ is an existing file in the old format a Gem::Package::Old will be
# returned.

def self.new gem
  return super unless Gem::Package == self
  return super unless File.exist? gem

  start = File.read gem, 20

  return super unless start
  return super unless start.include? 'MD5SUM ='

  Gem::Package::Old.new gem
end

##
# Creates a new package that will read or write to the file +gem+.

def initialize gem # :notnew:
  @gem = gem

  @build_time      = Time.now
  @checksums       = {}
  @contents        = nil
  @digests         = Hash.new { |h, algorithm| h[algorithm] = {} }
  @files           = nil
  @security_policy = nil
  @signatures      = {}
  @signer          = nil
  @spec            = nil
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.stat file

    next unless stat.file?

    tar.add_file_simple file, stat.mode, stat.size do |dst_io|
      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
  Gem.load_yaml
  require 'rubygems/security'

  @spec.mark_version
  @spec.validate unless skip_validation

  setup_signer

  open @gem, 'wb' 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 @spec.cache_file}

EOM

ensure
  @signer = nil
end

##
# A list of file names contained in this gem

def contents
  return @contents if @contents

  verify unless @spec

  @contents = []

  open @gem, 'rb' 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 then
                 @checksums.keys
               else
                 [Gem::Security::DIGEST_NAME].compact
               end

  algorithms.each do |algorithm|
    digester =
      if defined?(OpenSSL::Digest) then
        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

  open @gem, 'rb' 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:
  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

      FileUtils.mkdir_p File.dirname destination

      open destination, 'wb', entry.header.mode do |out|
        out.write entry.read
      end

      say destination if Gem.configuration.really_verbose
    end
  end
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 if
    File.respond_to? :realpath
  destination_dir = File.expand_path destination_dir

  destination = File.join destination_dir, filename
  destination = File.expand_path destination

  raise Gem::Package::PathError.new(destination, destination_dir) unless
    destination.start_with? destination_dir

  destination.untaint
  destination
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
    args = [entry]
    args << { :external_encoding => Encoding::UTF_8 } if
      Object.const_defined?(:Encoding) &&
        Zlib::GzipReader.method(:wrap).arity != 1

    Zlib::GzipReader.wrap(*args) 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|
      YAML.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
  passphrase = ENV['GEM_PRIVATE_KEY_PASSPHRASE']
  if @spec.signing_key then
    @signer = Gem::Security::Signer.new @spec.signing_key, @spec.cert_chain, passphrase
    @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

  open @gem, 'rb' 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 then
        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(.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 then
    raise Gem::Package::FormatError.new 'package metadata is missing', @gem
  end

  unless @files.include? 'data.tar.gz' then
    raise Gem::Package::FormatError.new              'package content (data.tar.gz) is missing', @gem
  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/old' require 'rubygems/package/tar_header' require 'rubygems/package/tar_reader' require 'rubygems/package/tar_reader/entry' require 'rubygems/package/tar_writer'