jphastings/rubyzip

View on GitHub
lib/zip/entry.rb

Summary

Maintainability
F
4 days
Test Coverage
module Zip
  class Entry
    STORED   = 0
    DEFLATED = 8
    ENCRYPTED = 99
    # Language encoding flag (EFS) bit
    EFS = 0b100000000000

    attr_accessor :comment, :compressed_size, :crc, :extra, :compression_method,
                  :name, :size, :local_header_offset, :zipfile, :fstype, :external_file_attributes,
                  :gp_flags, :header_signature, :follow_symlinks,
                  :restore_times, :restore_permissions, :restore_ownership,
                  :unix_uid, :unix_gid, :unix_perms,
                  :dirty
    attr_reader :ftype, :filepath # :nodoc:

    def set_default_vars_values
      @local_header_offset      = 0
      @local_header_size        = nil # not known until local entry is created or read
      @internal_file_attributes = 1
      @external_file_attributes = 0
      @header_signature         = ::Zip::CENTRAL_DIRECTORY_ENTRY_SIGNATURE

      @version_needed_to_extract = VERSION_NEEDED_TO_EXTRACT
      @version                   = VERSION_MADE_BY

      @ftype           = nil          # unspecified or unknown
      @filepath        = nil
      @gp_flags        = 0
      if ::Zip.unicode_names
        @gp_flags |= EFS
        @version = 63
      end
      @follow_symlinks = false

      @restore_times       = true
      @restore_permissions = false
      @restore_ownership   = false
      # BUG: need an extra field to support uid/gid's
      @unix_uid            = nil
      @unix_gid            = nil
      @unix_perms          = nil
      #@posix_acl = nil
      #@ntfs_acl = nil
      @dirty               = false
    end

    def check_name(name)
      if name.start_with?('/')
        raise ::Zip::EntryNameError, "Illegal ZipEntry name '#{name}', name must not start with /"
      end
    end

    def initialize(*args)
      name = args[1] || ''
      check_name(name)

      set_default_vars_values
      @fstype = ::Zip::RUNNING_ON_WINDOWS ? ::Zip::FSTYPE_FAT : ::Zip::FSTYPE_UNIX

      @zipfile            = args[0] || ''
      @name               = name
      @comment            = args[2] || ''
      @extra              = args[3] || ''
      @compressed_size    = args[4] || 0
      @crc                = args[5] || 0
      @compression_method = args[6] || ::Zip::Entry::DEFLATED
      @size               = args[7] || 0
      @time               = args[8] || ::Zip::DOSTime.now

      @ftype = name_is_directory? ? :directory : :file
      @extra = ::Zip::ExtraField.new(@extra.to_s) unless ::Zip::ExtraField === @extra
    end

    def time
      if @extra['UniversalTime']
        @extra['UniversalTime'].mtime
      else
        # Standard time field in central directory has local time
        # under archive creator. Then, we can't get timezone.
        @time
      end
    end

    alias :mtime :time

    def time=(value)
      unless @extra.member?('UniversalTime')
        @extra.create('UniversalTime')
      end
      @extra['UniversalTime'].mtime = value
      @time                         = value
    end

    def file_type_is?(type)
      raise InternalError, "current filetype is unknown: #{self.inspect}" unless @ftype
      @ftype == type
    end

    # Dynamic checkers
    %w(directory file symlink).each do |k|
      define_method "#{k}?" do
        file_type_is?(k.to_sym)
      end
    end

    def name_is_directory? #:nodoc:all
      @name.end_with?('/')
    end

    def local_entry_offset #:nodoc:all
      local_header_offset + @local_header_size
    end

    def name_size
      @name ? @name.bytesize : 0
    end

    def extra_size
      @extra ? @extra.local_size : 0
    end

    def comment_size
      @comment ? @comment.bytesize : 0
    end

    def calculate_local_header_size #:nodoc:all
      LOCAL_ENTRY_STATIC_HEADER_LENGTH + name_size + extra_size
    end

    # check before rewriting an entry (after file sizes are known)
    # that we didn't change the header size (and thus clobber file data or something)
    def verify_local_header_size!
      return if @local_header_size.nil?
      new_size = calculate_local_header_size
      raise Error, "local header size changed (#{@local_header_size} -> #{new_size})" if @local_header_size != new_size
    end

    def cdir_header_size #:nodoc:all
      CDIR_ENTRY_STATIC_HEADER_LENGTH + name_size +
        (@extra ? @extra.c_dir_size : 0) + comment_size
    end

    def next_header_offset #:nodoc:all
      # FIXME. the data descriptor can be only 12 bytes long, if the signature isn't present
      # I need to add code to deal with this!
      # I also have no idea why I have to subtract 8 bytes for AES  zips. It works, sooooo...
      local_entry_offset + self.compressed_size + (@data_descriptor_present ? 16 : 0) - (@extra.member?('AES') ? 8 : 0)
    end

    # Extracts entry to file dest_path (defaults to @name).
    def extract(dest_path = @name, &block)
      block ||= proc { ::Zip.on_exists_proc }

      if directory? || file? || symlink?
        self.__send__("create_#{@ftype}", dest_path, &block)
      else
        raise RuntimeError, "unknown file type #{self.inspect}"
      end

      self
    end

    def to_s
      @name
    end

    protected

    class << self
      def read_zip_short(io) # :nodoc:
        io.read(2).unpack('v')[0]
      end

      def read_zip_long(io) # :nodoc:
        io.read(4).unpack('V')[0]
      end

      def read_zip_64_long(io) # :nodoc:
        io.read(8).unpack('Q<')[0]
      end

      def read_c_dir_entry(io) #:nodoc:all
        path = if io.is_a?(::IO)
              io.path
             else
               io
             end
        entry = new(path)
        entry.read_c_dir_entry(io)
        entry
      rescue Error
        nil
      end

      def read_local_entry(io)
        entry = self.new(io)
        entry.read_local_entry(io)
        entry
      rescue Error
        nil
      end

    end

    public

    def unpack_local_entry(buf)
      @header_signature,
        @version,
        @fstype,
        @gp_flags,
        @compression_method,
        @last_mod_time,
        @last_mod_date,
        @crc,
        @compressed_size,
        @size,
        @name_length,
        @extra_length = buf.unpack('VCCvvvvVVVvv')
    end

    def read_local_entry(io) #:nodoc:all
      @local_header_offset = io.tell

      static_sized_fields_buf = io.read(::Zip::LOCAL_ENTRY_STATIC_HEADER_LENGTH)

      unless static_sized_fields_buf.bytesize == ::Zip::LOCAL_ENTRY_STATIC_HEADER_LENGTH
        raise Error, "Premature end of file. Not enough data for zip entry local header"
      end

      unpack_local_entry(static_sized_fields_buf)

      unless @header_signature == ::Zip::LOCAL_ENTRY_SIGNATURE
        raise ::Zip::Error, "Zip local header magic not found at location '#{local_header_offset}'"
      end
      set_time(@last_mod_date, @last_mod_time)

      @name = io.read(@name_length)
      extra = io.read(@extra_length)

      @name.gsub!('\\', '/')

      if extra && extra.bytesize != @extra_length
        raise ::Zip::Error, "Truncated local zip entry header"
      else
        if ::Zip::ExtraField === @extra
          @extra.merge(extra)
        else
          @extra = ::Zip::ExtraField.new(extra)
        end
      end
      parse_zip64_extra(true)
      @local_header_size = calculate_local_header_size

      # If the "data descriptor present" bit is set in the general flags
      # we need to read the uncompressed size and CRC from the data descriptor
      # otherwise they'll remain as 0
      @data_descriptor_present = (@gp_flags & ::Zip::GP_FLAGS_DESCRIPTOR_PRESENT != 0)
      if @data_descriptor_present
        pos = io.tell
        # We need to seek forwards until we find the data descriptor signature
        # (504B0708) or the next record's local file header signature (504B0304)
        # and scan back a few
        last_four = []

        loop do
          last_four.push(io.read(1))
          last_four = last_four[-4..-1] if last_four.length > 4

          case last_four
          when %W{\x50 \x4B \x07 \x08}
            break
          when %W{\x50 \x4B \x03 \x04}
            io.seek(-12,IO::SEEK_CUR )
            break
          end
        end

        @crc, @compressed_size, @size = io.read(12).unpack("VVV")
        io.seek(pos)
      end
    end

    def pack_local_entry
      zip64 = @extra['Zip64']
      [::Zip::LOCAL_ENTRY_SIGNATURE,
       @version_needed_to_extract, # version needed to extract
       @gp_flags, # @gp_flags                  ,
       @compression_method,
       @time.to_binary_dos_time, # @last_mod_time              ,
       @time.to_binary_dos_date, # @last_mod_date              ,
       @crc,
       (zip64 && zip64.compressed_size) ? 0xFFFFFFFF : @compressed_size,
       (zip64 && zip64.original_size) ? 0xFFFFFFFF : @size,
       name_size,
       @extra ? @extra.local_size : 0].pack('VvvvvvVVVvv')
    end

    def write_local_entry(io, rewrite = false) #:nodoc:all
      prep_zip64_extra(true)
      verify_local_header_size! if rewrite
      @local_header_offset = io.tell

      io << pack_local_entry

      io << @name
      io << @extra.to_local_bin if @extra
      @local_header_size = io.tell - @local_header_offset
    end

    def unpack_c_dir_entry(buf)
      @header_signature,
        @version, # version of encoding software
        @fstype, # filesystem type
        @version_needed_to_extract,
        @gp_flags,
        @compression_method,
        @last_mod_time,
        @last_mod_date,
        @crc,
        @compressed_size,
        @size,
        @name_length,
        @extra_length,
        @comment_length,
        _, # diskNumberStart
        @internal_file_attributes,
        @external_file_attributes,
        @local_header_offset,
        @name,
        @extra,
        @comment = buf.unpack('VCCvvvvvVVVvvvvvVV')
    end

    def set_ftype_from_c_dir_entry
      @ftype = case @fstype
               when ::Zip::FSTYPE_UNIX
                 @unix_perms = (@external_file_attributes >> 16) & 07777
                 case (@external_file_attributes >> 28)
                 when ::Zip::FILE_TYPE_DIR
                   :directory
                 when ::Zip::FILE_TYPE_FILE
                   :file
                 when ::Zip::FILE_TYPE_SYMLINK
                   :symlink
                 else
                   #best case guess for whether it is a file or not
                   #Otherwise this would be set to unknown and that entry would never be able to extracted
                   if name_is_directory?
                     :directory
                   else
                     :file
                   end
                 end
               else
                 if name_is_directory?
                   :directory
                 else
                   :file
                 end
               end
    end

    def check_c_dir_entry_static_header_length(buf)
      unless buf.bytesize == ::Zip::CDIR_ENTRY_STATIC_HEADER_LENGTH
        raise Error, 'Premature end of file. Not enough data for zip cdir entry header'
      end
    end

    def check_c_dir_entry_signature
      unless header_signature == ::Zip::CENTRAL_DIRECTORY_ENTRY_SIGNATURE
        raise Error, "Zip local header magic not found at location '#{local_header_offset}'"
      end
    end

    def check_c_dir_entry_comment_size
      unless @comment && @comment.bytesize == @comment_length
        raise ::Zip::Error, "Truncated cdir zip entry header"
      end
    end

    def read_c_dir_extra_field(io)
      if @extra.is_a?(::Zip::ExtraField)
        @extra.merge(io.read(@extra_length))
      else
        @extra = ::Zip::ExtraField.new(io.read(@extra_length))
      end
    end

    def read_c_dir_entry(io) #:nodoc:all
      static_sized_fields_buf = io.read(::Zip::CDIR_ENTRY_STATIC_HEADER_LENGTH)
      check_c_dir_entry_static_header_length(static_sized_fields_buf)
      unpack_c_dir_entry(static_sized_fields_buf)
      check_c_dir_entry_signature
      set_time(@last_mod_date, @last_mod_time)
      @name = io.read(@name_length).gsub('\\', '/')
      read_c_dir_extra_field(io)
      @comment = io.read(@comment_length)
      check_c_dir_entry_comment_size
      set_ftype_from_c_dir_entry
      parse_zip64_extra(false)
    end

    def file_stat(path) # :nodoc:
      if @follow_symlinks
        ::File::stat(path)
      else
        ::File::lstat(path)
      end
    end

    def get_extra_attributes_from_path(path) # :nodoc:
      unless Zip::RUNNING_ON_WINDOWS
        stat        = file_stat(path)
        @unix_uid   = stat.uid
        @unix_gid   = stat.gid
        @unix_perms = stat.mode & 07777
      end
    end

    def set_unix_permissions_on_path(dest_path)
      # BUG: does not update timestamps into account
      # ignore setuid/setgid bits by default.  honor if @restore_ownership
      unix_perms_mask = 01777
      unix_perms_mask = 07777 if @restore_ownership
      ::FileUtils.chmod(@unix_perms & unix_perms_mask, dest_path) if @restore_permissions && @unix_perms
      ::FileUtils.chown(@unix_uid, @unix_gid, dest_path) if @restore_ownership && @unix_uid && @unix_gid && ::Process.egid == 0
      # File::utimes()
    end

    def set_extra_attributes_on_path(dest_path) # :nodoc:
      return unless (file? || directory?)

      case @fstype
      when ::Zip::FSTYPE_UNIX
        set_unix_permissions_on_path(dest_path)
      end
    end

    def pack_c_dir_entry
      zip64 = @extra['Zip64']
      [
        @header_signature,
        @version, # version of encoding software
        @fstype, # filesystem type
        @version_needed_to_extract, # @versionNeededToExtract           ,
        @gp_flags, # @gp_flags                          ,
        @compression_method,
        @time.to_binary_dos_time, # @last_mod_time                      ,
        @time.to_binary_dos_date, # @last_mod_date                      ,
        @crc,
        (zip64 && zip64.compressed_size) ? 0xFFFFFFFF : @compressed_size,
        (zip64 && zip64.original_size) ? 0xFFFFFFFF : @size,
        name_size,
        @extra ? @extra.c_dir_size : 0,
        comment_size,
        (zip64 && zip64.disk_start_number) ? 0xFFFF : 0, # disk number start
        @internal_file_attributes, # file type (binary=0, text=1)
        @external_file_attributes, # native filesystem attributes
        (zip64 && zip64.relative_header_offset) ? 0xFFFFFFFF : @local_header_offset,
        @name,
        @extra,
        @comment
      ].pack('VCCvvvvvVVVvvvvvVV')
    end

    def write_c_dir_entry(io) #:nodoc:all
      prep_zip64_extra(false)
      case @fstype
      when ::Zip::FSTYPE_UNIX
        ft = case @ftype
             when :file
               @unix_perms ||= 0644
               ::Zip::FILE_TYPE_FILE
             when :directory
               @unix_perms ||= 0755
               ::Zip::FILE_TYPE_DIR
             when :symlink
               @unix_perms ||= 0755
               ::Zip::FILE_TYPE_SYMLINK
             end

        unless ft.nil?
          @external_file_attributes = (ft << 12 | (@unix_perms & 07777)) << 16
        end
      end

      io << pack_c_dir_entry

      io << @name
      io << (@extra ? @extra.to_c_dir_bin : '')
      io << @comment
    end

    def ==(other)
      return false unless other.class == self.class
      # Compares contents of local entry and exposed fields
      keys_equal = %w(compression_method crc compressed_size size name extra filepath).all? do |k|
        other.__send__(k.to_sym) == self.__send__(k.to_sym)
      end
      keys_equal && self.time.dos_equals(other.time)
    end

    def <=> (other)
      self.to_s <=> other.to_s
    end

    # Returns an IO like object for the given ZipEntry.
    # Warning: may behave weird with symlinks.
    def get_input_stream(&block)
      if @ftype == :directory
        yield ::Zip::NullInputStream if block_given?
        ::Zip::NullInputStream
      elsif @filepath
        case @ftype
        when :file
          ::File.open(@filepath, 'rb', &block)
        when :symlink
          linkpath = ::File.readlink(@filepath)
          stringio = ::StringIO.new(linkpath)
          yield(stringio) if block_given?
          stringio
        else
          raise "unknown @file_type #{@ftype}"
        end
      else
        zis = ::Zip::InputStream.new(@zipfile, local_header_offset)
        zis.get_next_entry
        if block_given?
          begin
            yield(zis)
          ensure
            zis.close
          end
        else
          zis
        end
      end
    end

    def gather_fileinfo_from_srcpath(src_path) # :nodoc:
      stat   = file_stat(src_path)
      @ftype = case stat.ftype
               when 'file'
                 if name_is_directory?
                   raise ArgumentError,
                         "entry name '#{newEntry}' indicates directory entry, but "+
                           "'#{src_path}' is not a directory"
                 end
                 :file
               when 'directory'
                 @name += '/' unless name_is_directory?
                 :directory
               when 'link'
                 if name_is_directory?
                   raise ArgumentError,
                         "entry name '#{newEntry}' indicates directory entry, but "+
                           "'#{src_path}' is not a directory"
                 end
                 :symlink
               else
                 raise RuntimeError, "unknown file type: #{src_path.inspect} #{stat.inspect}"
               end

      @filepath = src_path
      get_extra_attributes_from_path(@filepath)
    end

    def write_to_zip_output_stream(zip_output_stream) #:nodoc:all
      if @ftype == :directory
        zip_output_stream.put_next_entry(self, nil, nil, ::Zip::Entry::STORED)
      elsif @filepath
        zip_output_stream.put_next_entry(self, nil, nil, self.compression_method || ::Zip::Entry::DEFLATED)
        get_input_stream { |is| ::Zip::IOExtras.copy_stream(zip_output_stream, is) }
      else
        zip_output_stream.copy_raw_entry(self)
      end
    end

    def parent_as_string
      entry_name  = name.chomp('/')
      slash_index = entry_name.rindex('/')
      slash_index ? entry_name.slice(0, slash_index+1) : nil
    end

    def get_raw_input_stream(&block)
      if @zipfile.is_a?(::IO) || @zipfile.is_a?(::StringIO)
        yield @zipfile
      else
        ::File.open(@zipfile, "rb", &block)
      end
    end

    def clean_up
      # By default, do nothing
    end

    private

    def set_time(binary_dos_date, binary_dos_time)
      @time = ::Zip::DOSTime.parse_binary_dos_format(binary_dos_date, binary_dos_time)
    rescue ArgumentError
      puts "Invalid date/time in zip entry"
    end

    def create_file(dest_path, continue_on_exists_proc = proc { Zip.continue_on_exists_proc })
      if ::File.exist?(dest_path) && !yield(self, dest_path)
        raise ::Zip::DestinationFileExistsError,
              "Destination '#{dest_path}' already exists"
      end
      ::File.open(dest_path, "wb") do |os|
        get_input_stream do |is|
          set_extra_attributes_on_path(dest_path)

          buf = ''
          while buf = is.sysread(::Zip::Decompressor::CHUNK_SIZE, buf)
            os << buf
          end
        end
      end
    end

    def create_directory(dest_path)
      return if ::File.directory?(dest_path)
      if ::File.exist?(dest_path)
        if block_given? && yield(self, dest_path)
          ::FileUtils::rm_f dest_path
        else
          raise ::Zip::DestinationFileExistsError,
                "Cannot create directory '#{dest_path}'. "+
                  "A file already exists with that name"
        end
      end
      ::FileUtils.mkdir_p(dest_path)
      set_extra_attributes_on_path(dest_path)
    end

    # BUG: create_symlink() does not use &block
    def create_symlink(dest_path)
      stat = nil
      begin
        stat = ::File.lstat(dest_path)
      rescue Errno::ENOENT
      end

      io     = get_input_stream
      linkto = io.read

      if stat
        if stat.symlink?
          if ::File.readlink(dest_path) == linkto
            return
          else
            raise ::Zip::DestinationFileExistsError,
                  "Cannot create symlink '#{dest_path}'. "+
                    "A symlink already exists with that name"
          end
        else
          raise ::Zip::DestinationFileExistsError,
                "Cannot create symlink '#{dest_path}'. "+
                  "A file already exists with that name"
        end
      end

      ::File.symlink(linkto, dest_path)
    end

    # apply missing data from the zip64 extra information field, if present
    # (required when file sizes exceed 2**32, but can be used for all files)
    def parse_zip64_extra(for_local_header) #:nodoc:all
      if zip64 = @extra['Zip64']
        if for_local_header
          @size, @compressed_size = zip64.parse(@size, @compressed_size)
        else
          @size, @compressed_size, @local_header_offset = zip64.parse(@size, @compressed_size, @local_header_offset)
        end
      end
    end

    # create a zip64 extra information field if we need one
    def prep_zip64_extra(for_local_header) #:nodoc:all
      return unless ::Zip.write_zip64_support
      need_zip64 = @size >= 0xFFFFFFFF || @compressed_size >= 0xFFFFFFFF
      unless for_local_header
        need_zip64 ||= @local_header_offset >= 0xFFFFFFFF
      end

      if need_zip64
        @version_needed_to_extract = VERSION_NEEDED_TO_EXTRACT_ZIP64
        @extra.delete('Zip64Placeholder')
        zip64 = @extra.create('Zip64')
        if for_local_header
          # local header always includes size and compressed size
          zip64.original_size = @size
          zip64.compressed_size = @compressed_size
        else
          # central directory entry entries include whichever fields are necessary
          zip64.original_size = @size if @size >= 0xFFFFFFFF
          zip64.compressed_size = @compressed_size if @compressed_size >= 0xFFFFFFFF
          zip64.relative_header_offset = @local_header_offset if @local_header_offset >= 0xFFFFFFFF
        end
      else
        @extra.delete('Zip64')

        # if this is a local header entry, create a placeholder
        # so we have room to write a zip64 extra field afterward
        # (we won't know if it's needed until the file data is written)
        if for_local_header
          @extra.create('Zip64Placeholder')
        else
          @extra.delete('Zip64Placeholder')
        end
      end
    end

  end
end

# Copyright (C) 2002, 2003 Thomas Sondergaard
# rubyzip is free software; you can redistribute it and/or
# modify it under the terms of the ruby license.