# # Copyright (C) 2004 Mauricio Julio Fernández Pradier # See LICENSE.txt for additional licensing information. # require 'yaml' require 'yaml/syck' require 'fileutils' require 'zlib' require 'digest/md5' require 'fileutils' require 'find' require 'stringio' require 'rubygems/specification' require 'rubygems/security' # Wrapper for FileUtils meant to provide logging and additional operations if # needed. class Gem::FileOperations extend FileUtils def initialize(logger = nil) @logger = logger end def method_missing(meth, *args, &block) case when FileUtils.respond_to?(meth) @logger.log "#{meth}: #{args}" if @logger FileUtils.send meth, *args, &block when Gem::FileOperations.respond_to?(meth) @logger.log "#{meth}: #{args}" if @logger Gem::FileOperations.send meth, *args, &block else super end end end module Gem::Package class Error < StandardError; end class NonSeekableIO < Error; end class ClosedIO < Error; end class BadCheckSum < Error; end class TooLongFileName < Error; end class FormatError < Error; end module FSyncDir private def fsync_dir(dirname) # make sure this hits the disc begin dir = open(dirname, "r") dir.fsync rescue # ignore IOError if it's an unpatched (old) Ruby ensure dir.close if dir rescue nil end end end class TarHeader FIELDS = [:name, :mode, :uid, :gid, :size, :mtime, :checksum, :typeflag, :linkname, :magic, :version, :uname, :gname, :devmajor, :devminor, :prefix] FIELDS.each {|x| attr_reader x} def self.new_from_stream(stream) data = stream.read(512) fields = data.unpack("A100" + # record name "A8A8A8" + # mode, uid, gid "A12A12" + # size, mtime "A8A" + # checksum, typeflag "A100" + # linkname "A6A2" + # magic, version "A32" + # uname "A32" + # gname "A8A8" + # devmajor, devminor "A155") # prefix name = fields.shift mode = fields.shift.oct uid = fields.shift.oct gid = fields.shift.oct size = fields.shift.oct mtime = fields.shift.oct checksum = fields.shift.oct typeflag = fields.shift linkname = fields.shift magic = fields.shift version = fields.shift.oct uname = fields.shift gname = fields.shift devmajor = fields.shift.oct devminor = fields.shift.oct prefix = fields.shift empty = (data == "\0" * 512) new(:name=>name, :mode=>mode, :uid=>uid, :gid=>gid, :size=>size, :mtime=>mtime, :checksum=>checksum, :typeflag=>typeflag, :magic=>magic, :version=>version, :uname=>uname, :gname=>gname, :devmajor=>devmajor, :devminor=>devminor, :prefix=>prefix, :empty => empty ) end def initialize(vals) unless vals[:name] && vals[:size] && vals[:prefix] && vals[:mode] raise ArgumentError, ":name, :size, :prefix and :mode required" end vals[:uid] ||= 0 vals[:gid] ||= 0 vals[:mtime] ||= 0 vals[:checksum] ||= "" vals[:typeflag] ||= "0" vals[:magic] ||= "ustar" vals[:version] ||= "00" vals[:uname] ||= "wheel" vals[:gname] ||= "wheel" vals[:devmajor] ||= 0 vals[:devminor] ||= 0 FIELDS.each {|x| instance_variable_set "@#{x.to_s}", vals[x]} @empty = vals[:empty] end def empty? @empty end def to_s update_checksum header(checksum) end def update_checksum h = header(" " * 8) @checksum = oct(calculate_checksum(h), 6) end private def oct(num, len) "%0#{len}o" % num end def calculate_checksum(hdr) hdr.unpack("C*").inject{|a,b| a+b} end def header(chksum) # struct tarfile_entry_posix { # char name[100]; # ASCII + (Z unless filled) # char mode[8]; # 0 padded, octal, null # char uid[8]; # ditto # char gid[8]; # ditto # char size[12]; # 0 padded, octal, null # char mtime[12]; # 0 padded, octal, null # char checksum[8]; # 0 padded, octal, null, space # char typeflag[1]; # file: "0" dir: "5" # char linkname[100]; # ASCII + (Z unless filled) # char magic[6]; # "ustar\0" # char version[2]; # "00" # char uname[32]; # ASCIIZ # char gname[32]; # ASCIIZ # char devmajor[8]; # 0 padded, octal, null # char devminor[8]; # o padded, octal, null # char prefix[155]; # ASCII + (Z unless filled) # }; arr = [name, oct(mode, 7), oct(uid, 7), oct(gid, 7), oct(size, 11), oct(mtime, 11), chksum, " ", typeflag, linkname, magic, version, uname, gname, oct(devmajor, 7), oct(devminor, 7), prefix] str = arr.pack("a100a8a8a8a12a12" + # name, mode, uid, gid, size, mtime "a7aaa100a6a2" + # chksum, typeflag, linkname, magic, version "a32a32a8a8a155") # uname, gname, devmajor, devminor, prefix str + "\0" * ((512 - str.size) % 512) end end class TarWriter class FileOverflow < StandardError; end class BlockNeeded < StandardError; end class BoundedStream attr_reader :limit, :written def initialize(io, limit) @io = io @limit = limit @written = 0 end def write(data) if data.size + @written > @limit raise FileOverflow, "You tried to feed more data than fits in the file." end @io.write data @written += data.size data.size end end class RestrictedStream def initialize(anIO) @io = anIO end def write(data) @io.write data end end def self.new(anIO) writer = super(anIO) return writer unless block_given? begin yield writer ensure writer.close end nil end def initialize(anIO) @io = anIO @closed = false end def add_file_simple(name, mode, size) raise BlockNeeded unless block_given? raise ClosedIO if @closed name, prefix = split_name(name) header = TarHeader.new(:name => name, :mode => mode, :size => size, :prefix => prefix).to_s @io.write header os = BoundedStream.new(@io, size) yield os #FIXME: what if an exception is raised in the block? min_padding = size - os.written @io.write("\0" * min_padding) remainder = (512 - (size % 512)) % 512 @io.write("\0" * remainder) end def add_file(name, mode) raise BlockNeeded unless block_given? raise ClosedIO if @closed raise NonSeekableIO unless @io.respond_to? :pos= name, prefix = split_name(name) init_pos = @io.pos @io.write "\0" * 512 # placeholder for the header yield RestrictedStream.new(@io) #FIXME: what if an exception is raised in the block? #FIXME: what if an exception is raised in the block? size = @io.pos - init_pos - 512 remainder = (512 - (size % 512)) % 512 @io.write("\0" * remainder) final_pos = @io.pos @io.pos = init_pos header = TarHeader.new(:name => name, :mode => mode, :size => size, :prefix => prefix).to_s @io.write header @io.pos = final_pos end def mkdir(name, mode) raise ClosedIO if @closed name, prefix = split_name(name) header = TarHeader.new(:name => name, :mode => mode, :typeflag => "5", :size => 0, :prefix => prefix).to_s @io.write header nil end def flush raise ClosedIO if @closed @io.flush if @io.respond_to? :flush end def close #raise ClosedIO if @closed return if @closed @io.write "\0" * 1024 @closed = true end private def split_name name raise TooLongFileName if name.size > 256 if name.size <= 100 prefix = "" else parts = name.split(/\//) newname = parts.pop nxt = "" loop do nxt = parts.pop break if newname.size + 1 + nxt.size > 100 newname = nxt + "/" + newname end prefix = (parts + [nxt]).join "/" name = newname raise TooLongFileName if name.size > 100 || prefix.size > 155 end return name, prefix end end class TarReader include Gem::Package class UnexpectedEOF < StandardError; end module InvalidEntry def read(len=nil); raise ClosedIO; end def getc; raise ClosedIO; end def rewind; raise ClosedIO; end end class Entry TarHeader::FIELDS.each{|x| attr_reader x} def initialize(header, anIO) @io = anIO @name = header.name @mode = header.mode @uid = header.uid @gid = header.gid @size = header.size @mtime = header.mtime @checksum = header.checksum @typeflag = header.typeflag @linkname = header.linkname @magic = header.magic @version = header.version @uname = header.uname @gname = header.gname @devmajor = header.devmajor @devminor = header.devminor @prefix = header.prefix @read = 0 @orig_pos = @io.pos end def read(len = nil) return nil if @read >= @size len ||= @size - @read max_read = [len, @size - @read].min ret = @io.read(max_read) @read += ret.size ret end def getc return nil if @read >= @size ret = @io.getc @read += 1 if ret ret end def is_directory? @typeflag == "5" end def is_file? @typeflag == "0" end def eof? @read >= @size end def pos @read end def rewind raise NonSeekableIO unless @io.respond_to? :pos= @io.pos = @orig_pos @read = 0 end alias_method :is_directory, :is_directory? alias_method :is_file, :is_file def bytes_read @read end def full_name if @prefix != "" File.join(@prefix, @name) else @name end end def close invalidate end private def invalidate extend InvalidEntry end end def self.new(anIO) reader = super(anIO) return reader unless block_given? begin yield reader ensure reader.close end nil end def initialize(anIO) @io = anIO @init_pos = anIO.pos end def each(&block) each_entry(&block) end # do not call this during a #each or #each_entry iteration def rewind if @init_pos == 0 raise NonSeekableIO unless @io.respond_to? :rewind @io.rewind else raise NonSeekableIO unless @io.respond_to? :pos= @io.pos = @init_pos end end def each_entry loop do return if @io.eof? header = TarHeader.new_from_stream(@io) return if header.empty? entry = Entry.new header, @io size = entry.size yield entry skip = (512 - (size % 512)) % 512 #if @io.respond_to? :seek ## avoid reading... # @io.seek(size - entry.bytes_read, IO::SEEK_CUR) # else pending = size - entry.bytes_read while pending > 0 bread = @io.read([pending, 4096].min).size raise UnexpectedEOF if @io.eof? pending -= bread end end @io.read(skip) # discard trailing zeros # make sure nobody can use #read, #getc or #rewind anymore entry.close end end def close end end class TarInput include FSyncDir include Enumerable attr_reader :metadata class << self; private :new end def initialize(io, security_policy = nil) @io = io @tarreader = TarReader.new(@io) has_meta = false data_sig, meta_sig, data_dgst, meta_dgst = nil, nil, nil, nil dgst_algo = security_policy ? Gem::Security::OPT[:dgst_algo] : nil @tarreader.each do |entry| case entry.full_name when "metadata" # (GS) Changed to line below: @metadata = YAML.load(entry.read) rescue nil @metadata = load_gemspec(entry.read) has_meta = true break when "metadata.gz" begin # if we have a security_policy, then pre-read the # metadata file and calculate it's digest sio = nil if security_policy Gem.ensure_ssl_available sio = StringIO.new(entry.read) meta_dgst = dgst_algo.digest(sio.string) sio.rewind end gzis = Zlib::GzipReader.new(sio || entry) # YAML wants an instance of IO # (GS) Changed to line below: @metadata = YAML.load(gzis) rescue nil @metadata = load_gemspec(gzis) has_meta = true ensure gzis.close unless gzis.nil? end when 'metadata.gz.sig' meta_sig = entry.read when 'data.tar.gz.sig' data_sig = entry.read when 'data.tar.gz' if security_policy Gem.ensure_ssl_available data_dgst = dgst_algo.digest(entry.read) end end end if security_policy Gem.ensure_ssl_available # map trust policy from string to actual class (or a # serialized YAML file, if that exists) if (security_policy.is_a?(String)) if Gem::Security.constants.index(security_policy) # load one of the pre-defined security policies security_policy = Gem::Security.const_get(security_policy) elsif File.exist?(security_policy) # FIXME: this doesn't work yet security_policy = YAML::load(File.read(security_policy)) else raise Gem::Exception, "Unknown trust policy '#{security_policy}'" end end if data_sig && data_dgst && meta_sig && meta_dgst # the user has a trust policy, and we have a signed gem # file, so use the trust policy to verify the gem signature begin security_policy.verify_gem(data_sig, data_dgst, @metadata.cert_chain) rescue Exception => e raise "Couldn't verify data signature: #{e}" end begin security_policy.verify_gem(meta_sig, meta_dgst, @metadata.cert_chain) rescue Exception => e raise "Couldn't verify metadata signature: #{e}" end elsif security_policy.only_signed raise Gem::Exception, "Unsigned gem" else # FIXME: should display warning here (trust policy, but # either unsigned or badly signed gem file) end end @tarreader.rewind @fileops = Gem::FileOperations.new raise FormatError, "No metadata found!" unless has_meta end # Attempt to YAML-load a gemspec from the given _io_ parameter. Return # nil if it fails. def load_gemspec(io) Gem::Specification.from_yaml(io) rescue Gem::Exception nil end def self.open(filename, security_policy = nil, &block) open_from_io(File.open(filename, "rb"), security_policy, &block) end def self.open_from_io(io, security_policy = nil, &block) raise "Want a block" unless block_given? begin is = new(io, security_policy) yield is ensure is.close if is end end def each(&block) @tarreader.each do |entry| next unless entry.full_name == "data.tar.gz" is = zipped_stream(entry) begin TarReader.new(is) do |inner| inner.each(&block) end ensure is.close if is end end @tarreader.rewind end # Return an IO stream for the zipped entry. # # NOTE: Originally this method used two approaches, Return a GZipReader # directly, or read the GZipReader into a string and return a StringIO on # the string. The string IO approach was used for versions of ZLib before # 1.2.1 to avoid buffer errors on windows machines. Then we found that # errors happened with 1.2.1 as well, so we changed the condition. Then # we discovered errors occurred with versions as late as 1.2.3. At this # point (after some benchmarking to show we weren't seriously crippling # the unpacking speed) we threw our hands in the air and declared that # this method would use the String IO approach on all platforms at all # times. And that's the way it is. def zipped_stream(entry) zis = Zlib::GzipReader.new entry dis = zis.read is = StringIO.new(dis) ensure zis.finish if zis end def extract_entry(destdir, entry, expected_md5sum = nil) if entry.is_directory? dest = File.join(destdir, entry.full_name) if file_class.dir? dest @fileops.chmod entry.mode, dest, :verbose=>false else @fileops.mkdir_p(dest, :mode => entry.mode, :verbose=>false) end fsync_dir dest fsync_dir File.join(dest, "..") return end # it's a file md5 = Digest::MD5.new if expected_md5sum destdir = File.join(destdir, File.dirname(entry.full_name)) @fileops.mkdir_p(destdir, :mode => 0755, :verbose=>false) destfile = File.join(destdir, File.basename(entry.full_name)) @fileops.chmod(0600, destfile, :verbose=>false) rescue nil # Errno::ENOENT file_class.open(destfile, "wb", entry.mode) do |os| loop do data = entry.read(4096) break unless data md5 << data if expected_md5sum os.write(data) end os.fsync end @fileops.chmod(entry.mode, destfile, :verbose=>false) fsync_dir File.dirname(destfile) fsync_dir File.join(File.dirname(destfile), "..") if expected_md5sum && expected_md5sum != md5.hexdigest raise BadCheckSum end end def close @io.close @tarreader.close end private def file_class File end end class TarOutput class << self; private :new end def initialize(io) @io = io @external = TarWriter.new @io end def external_handle @external end def self.open(filename, signer = nil, &block) io = File.open(filename, "wb") open_from_io(io, signer, &block) nil end def self.open_from_io(io, signer = nil, &block) outputter = new(io) metadata = nil set_meta = lambda{|x| metadata = x} raise "Want a block" unless block_given? begin data_sig, meta_sig = nil, nil outputter.external_handle.add_file("data.tar.gz", 0644) do |inner| begin sio = signer ? StringIO.new : nil os = Zlib::GzipWriter.new(sio || inner) TarWriter.new(os) do |inner_tar_stream| klass = class << inner_tar_stream; self end if RUBY_VERSION >= "1.9" then klass.funcall(:define_method, :metadata=, &set_meta) else klass.send(:define_method, :metadata=, &set_meta) end block.call inner_tar_stream end ensure os.flush os.finish #os.close # if we have a signing key, then sign the data # digest and return the signature data_sig = nil if signer dgst_algo = Gem::Security::OPT[:dgst_algo] dig = dgst_algo.digest(sio.string) data_sig = signer.sign(dig) inner.write(sio.string) end end end # if we have a data signature, then write it to the gem too if data_sig sig_file = 'data.tar.gz.sig' outputter.external_handle.add_file(sig_file, 0644) do |os| os.write(data_sig) end end outputter.external_handle.add_file("metadata.gz", 0644) do |os| begin sio = signer ? StringIO.new : nil gzos = Zlib::GzipWriter.new(sio || os) gzos.write metadata ensure gzos.flush gzos.finish # if we have a signing key, then sign the metadata # digest and return the signature if signer dgst_algo = Gem::Security::OPT[:dgst_algo] dig = dgst_algo.digest(sio.string) meta_sig = signer.sign(dig) os.write(sio.string) end end end # if we have a metadata signature, then write to the gem as # well if meta_sig sig_file = 'metadata.gz.sig' outputter.external_handle.add_file(sig_file, 0644) do |os| os.write(meta_sig) end end ensure outputter.close end nil end def close @external.close @io.close end end #FIXME: refactor the following 2 methods def self.open(dest, mode = "r", signer = nil, &block) raise "Block needed" unless block_given? case mode when "r" security_policy = signer TarInput.open(dest, security_policy, &block) when "w" TarOutput.open(dest, signer, &block) else raise "Unknown Package open mode" end end def self.open_from_io(io, mode = "r", signer = nil, &block) raise "Block needed" unless block_given? case mode when "r" security_policy = signer TarInput.open_from_io(io, security_policy, &block) when "w" TarOutput.open_from_io(io, signer, &block) else raise "Unknown Package open mode" end end def self.pack(src, destname, signer = nil) TarOutput.open(destname, signer) do |outp| dir_class.chdir(src) do outp.metadata = (file_class.read("RPA/metadata") rescue nil) find_class.find('.') do |entry| case when file_class.file?(entry) entry.sub!(%r{\./}, "") next if entry =~ /\ARPA\// stat = File.stat(entry) outp.add_file_simple(entry, stat.mode, stat.size) do |os| file_class.open(entry, "rb") do |f| os.write(f.read(4096)) until f.eof? end end when file_class.dir?(entry) entry.sub!(%r{\./}, "") next if entry == "RPA" outp.mkdir(entry, file_class.stat(entry).mode) else raise "Don't know how to pack this yet!" end end end end end class << self def file_class File end def dir_class Dir end def find_class Find end end end