Class: Archive::Zip

Inherits:
Object
  • Object
show all
Includes:
Enumerable
Defined in:
lib/archive/zip.rb,
lib/archive/zip/codec.rb,
lib/archive/zip/entry.rb,
lib/archive/zip/entry.rb,
lib/archive/zip/entry.rb,
lib/archive/zip/entry.rb,
lib/archive/zip/error.rb,
lib/archive/zip/version.rb,
lib/archive/zip/codec/store.rb,
lib/archive/zip/extra_field.rb,
lib/archive/zip/codec/deflate.rb,
lib/archive/zip/data_descriptor.rb,
lib/archive/zip/extra_field/raw.rb,
lib/archive/zip/extra_field/unix.rb,
lib/archive/zip/codec/null_encryption.rb,
lib/archive/zip/codec/traditional_encryption.rb,
lib/archive/zip/extra_field/extended_timestamp.rb

Overview

Archive::Zip represents a ZIP archive compatible with InfoZip tools and the archives they generate. It currently supports both stored and deflated ZIP entries, directory entries, file entries, and symlink entries. File and directory accessed and modified times, POSIX permissions, and ownerships can be archived and restored as well depending on platform support for such metadata. Traditional (weak) encryption is also supported.

Zip64, digital signatures, and strong encryption are not supported. ZIP archives can only be read from seekable kinds of IO, such as files; reading archives from pipes or any other non-seekable kind of IO is not supported. However, writing to such IO objects IS supported.

Defined Under Namespace

Modules: Codec, Entry, ExtraField Classes: DataDescriptor, EntryError, Error, ExtraFieldError, IOError, UnzipError

Constant Summary collapse

EOCD_SIGNATURE =

The lead-in marker for the end of central directory record.

"PK\x5\x6"
DS_SIGNATURE =

The lead-in marker for the digital signature record.

"PK\x5\x5"
Z64EOCD_SIGNATURE =

The lead-in marker for the ZIP64 end of central directory record.

"PK\x6\x6"
Z64EOCDL_SIGNATURE =

The lead-in marker for the ZIP64 end of central directory locator record.

"PK\x6\x7"
CFH_SIGNATURE =

The lead-in marker for a central file record.

"PK\x1\x2"
LFH_SIGNATURE =

The lead-in marker for a local file record.

"PK\x3\x4"
DD_SIGNATURE =

The lead-in marker for data descriptor record.

"PK\x7\x8"
VERSION =

The current version of this gem.

"0.4.0"

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(archive, mode = :r) ⇒ Zip

Opens an existing archive and/or creates a new archive.

If archive is a String, it will be treated as a file path; otherwise, it is assumed to be an IO-like object with the necessary read or write support depending on the setting of mode. IO-like objects are not closed when the archive is closed, but files opened from file paths are. Set mode to :r or "r" to read the archive, and set it to :w or "w" to write the archive.

NOTE: The #close method must be called in order to save any modifications to the archive. Due to limitations in the Ruby finalization capabilities, the #close method is not automatically called when this object is garbage collected. Make sure to call #close when finished with this object.



133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/archive/zip.rb', line 133

def initialize(archive, mode = :r)
  @archive = archive
  mode = mode.to_sym
  if mode == :r || mode == :w then
    @mode = mode
  else
    raise ArgumentError, "illegal access mode #{mode}"
  end

  @close_delegate = false
  if @archive.kind_of?(String) then
    @close_delegate = true
    if mode == :r then
      @archive = File.open(@archive, 'rb')
    else
      @archive = File.open(@archive, 'wb')
    end
  end
  @entries = []
  @comment = ''
  @closed = false
end

Instance Attribute Details

#commentObject

A comment string for the ZIP archive.



157
158
159
# File 'lib/archive/zip.rb', line 157

def comment
  @comment
end

Class Method Details

.archive(archive, paths, options = {}) ⇒ Object

Creates or possibly updates an archive using paths for new contents.

If archive is a String, it is treated as a file path which will receive the archive contents. If the file already exists, it is assumed to be an archive and will be updated “in place”. Otherwise, a new archive is created. The archive will be closed once written.

If archive has any other kind of value, it is treated as a writable IO-like object which will be left open after the completion of this method.

NOTE: No attempt is made to prevent adding multiple entries with the same archive path.

See the instance method #archive for more information about paths and options.



62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/archive/zip.rb', line 62

def self.archive(archive, paths, options = {})
  if archive.kind_of?(String) && File.exist?(archive) then
    # Update the archive "in place".
    tmp_archive_path = nil
    File.open(archive) do |archive_in|
      Tempfile.open(*File.split(archive_in.path).reverse) do |archive_out|
        # Save off the path so that the temporary file can be renamed to the
        # archive file later.
        tmp_archive_path = archive_out.path
        # Ensure the file is in binary mode for Windows.
        archive_out.binmode
        # Update the archive.
        open(archive_in, :r) do |z_in|
          open(archive_out, :w) do |z_out|
            z_in.each  { |entry| z_out << entry }
            z_out.archive(paths, options)
          end
        end
      end
    end
    # Set more reasonable permissions than those set by Tempfile.
    File.chmod(0666 & ~File.umask, tmp_archive_path)
    # Replace the input archive with the output archive.
    File.rename(tmp_archive_path, archive)
  else
    open(archive, :w) { |z| z.archive(paths, options) }
  end
end

.extract(archive, destination, options = {}) ⇒ Object

Extracts the entries from an archive to destination.

If archive is a String, it is treated as a file path pointing to an existing archive file. Otherwise, it is treated as a seekable and readable IO-like object.

See the instance method #extract for more information about destination and options.



99
100
101
# File 'lib/archive/zip.rb', line 99

def self.extract(archive, destination, options = {})
  open(archive, :r) { |z| z.extract(destination, options) }
end

.open(archive, mode = :r) ⇒ Object

Calls #new with the given arguments and yields the resulting Zip instance to the given block. Returns the result of the block and ensures that the Zip instance is closed.

This is a synonym for #new if no block is given.



108
109
110
111
112
113
114
115
116
117
# File 'lib/archive/zip.rb', line 108

def self.open(archive, mode = :r)
  zf = new(archive, mode)
  return zf unless block_given?

  begin
    yield(zf)
  ensure
    zf.close unless zf.closed?
  end
end

Instance Method Details

#add_entry(entry) ⇒ Object Also known as: <<

Adds entry into a writable ZIP archive.

NOTE: No attempt is made to prevent adding multiple entries with the same archive path.

Raises Archive::Zip::IOError if called on a non-writable archive or after the archive is closed.

Raises:



222
223
224
225
226
227
228
229
230
231
# File 'lib/archive/zip.rb', line 222

def add_entry(entry)
  raise IOError, 'non-writable archive' unless writable?
  raise IOError, 'closed archive' if closed?
  unless entry.kind_of?(Entry) then
    raise ArgumentError, 'Archive::Zip::Entry instance required'
  end

  @entries << entry
  self
end

#archive(paths, options = {}) ⇒ Object

Adds paths to the archive. paths may be either a single path or an Array of paths. The files and directories referenced by paths are added using their respective basenames as their zip paths. The exception to this is when the basename for a path is either "." or "..". In this case, the path is replaced with the paths to the contents of the directory it references.

options is a Hash optionally containing the following:

:path_prefix

Specifies a prefix to be added to the zip_path attribute of each entry where `/' is the file separator character. This defaults to the empty string. All values are passed through Archive::Zip::Entry.expand_path before use.

:recursion

When set to true (the default), the contents of directories are recursively added to the archive.

:directories

When set to true (the default), entries are added to the archive for directories. Otherwise, the entries for directories will not be added; however, the contents of the directories will still be considered if the :recursion option is true.

:symlinks

When set to false (the default), entries for symlinks are excluded from the archive. Otherwise, they are included. NOTE: Unless :follow_symlinks is explicitly set, it will be set to the logical NOT of this option in calls to Archive::Zip::Entry.from_file. If symlinks should be completely ignored, set both this option and :follow_symlinks to false. See Archive::Zip::Entry.from_file for details regarding :follow_symlinks.

:flatten

When set to false (the default), the directory paths containing archived files will be included in the zip paths of entries representing the files. When set to true, files are archived without any containing directory structure in the zip paths. Setting to true implies that :directories is false and :path_prefix is empty.

:exclude

Specifies a proc or lambda which takes a single argument containing a prospective zip entry and returns true if the entry should be excluded from the archive and false if it should be included. NOTE: If a directory is excluded in this way, the :recursion option has no effect for it.

:password

Specifies a proc, lambda, or a String. If a proc or lambda is used, it must take a single argument containing a zip entry and return a String to be used as an encryption key for the entry. If a String is used, it will be used as an encryption key for all encrypted entries.

:on_error

Specifies a proc or lambda which is called when an exception is raised during the archival of an entry. It takes two arguments, a file path and an exception object generated while attempting to archive the entry. If :retry is returned, archival of the entry is attempted again. If :skip is returned, the entry is skipped. Otherwise, the exception is raised.

Any other options which are supported by Archive::Zip::Entry.from_file are also supported.

NOTE: No attempt is made to prevent adding multiple entries with the same archive path.

Raises Archive::Zip::IOError if called on a non-writable archive or after the archive is closed. Raises Archive::Zip::EntryError if the :on_error option is either unset or indicates that the error should be raised and Archive::Zip::Entry.from_file raises an error.

Example

A directory contains:

zip-test
+- dir1
|  +- file2.txt
+- dir2
+- file1.txt

Create some archives:

Archive::Zip.open('zip-test1.zip') do |z|
  z.archive('zip-test')
end

Archive::Zip.open('zip-test2.zip') do |z|
  z.archive('zip-test/.', :path_prefix => 'a/b/c/d')
end

Archive::Zip.open('zip-test3.zip') do |z|
  z.archive('zip-test', :directories => false)
end

Archive::Zip.open('zip-test4.zip') do |z|
  z.archive('zip-test', :exclude => lambda { |e| e.file? })
end

The archives contain:

zip-test1.zip -> zip-test/
                 zip-test/dir1/
                 zip-test/dir1/file2.txt
                 zip-test/dir2/
                 zip-test/file1.txt

zip-test2.zip -> a/b/c/d/dir1/
                 a/b/c/d/dir1/file2.txt
                 a/b/c/d/dir2/
                 a/b/c/d/file1.txt

zip-test3.zip -> zip-test/dir1/file2.txt
                 zip-test/file1.txt

zip-test4.zip -> zip-test/
                 zip-test/dir1/
                 zip-test/dir2/

Raises:



343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
# File 'lib/archive/zip.rb', line 343

def archive(paths, options = {})
  raise IOError, 'non-writable archive' unless writable?
  raise IOError, 'closed archive' if closed?

  # Ensure that paths is an enumerable.
  paths = [paths] unless paths.kind_of?(Enumerable)
  # If the basename of a path is '.' or '..', replace the path with the
  # paths of all the entries contained within the directory referenced by
  # the original path.
  paths = paths.collect do |path|
    basename = File.basename(path)
    if basename == '.' || basename == '..' then
      Dir.entries(path).reject do |e|
        e == '.' || e == '..'
      end.collect do |e|
        File.join(path, e)
      end
    else
      path
    end
  end.flatten.uniq

  # Ensure that unspecified options have default values.
  options[:path_prefix]  = ''    unless options.has_key?(:path_prefix)
  options[:recursion]    = true  unless options.has_key?(:recursion)
  options[:directories]  = true  unless options.has_key?(:directories)
  options[:symlinks]     = false unless options.has_key?(:symlinks)
  options[:flatten]      = false unless options.has_key?(:flatten)

  # Flattening the directory structure implies that directories are skipped
  # and that the path prefix should be ignored.
  if options[:flatten] then
    options[:path_prefix] = ''
    options[:directories] = false
  end

  # Clean up the path prefix.
  options[:path_prefix] = Entry.expand_path(options[:path_prefix].to_s)

  paths.each do |path|
    # Generate the zip path.
    zip_entry_path = File.basename(path)
    zip_entry_path += '/' if File.directory?(path)
    unless options[:path_prefix].empty? then
      zip_entry_path = "#{options[:path_prefix]}/#{zip_entry_path}"
    end

    begin
      # Create the entry, but do not add it to the archive yet.
      zip_entry = Zip::Entry.from_file(
        path,
        options.merge(
          :zip_path        => zip_entry_path,
          :follow_symlinks => options.has_key?(:follow_symlinks) ?
                              options[:follow_symlinks] :
                              ! options[:symlinks]
        )
      )
    rescue StandardError => error
      unless options[:on_error].nil? then
        case options[:on_error][path, error]
        when :retry
          retry
        when :skip
          next
        else
          raise
        end
      else
        raise
      end
    end

    # Skip this entry if so directed.
    if (zip_entry.symlink? && ! options[:symlinks]) ||
       (! options[:exclude].nil? && options[:exclude][zip_entry]) then
      next
    end

    # Set the encryption key for the entry.
    if options[:password].kind_of?(String) then
      zip_entry.password = options[:password]
    elsif ! options[:password].nil? then
      zip_entry.password = options[:password][zip_entry]
    end

    # Add entries for directories (if requested) and files/symlinks.
    if (! zip_entry.directory? || options[:directories]) then
      add_entry(zip_entry)
    end

    # Recurse into subdirectories (if requested).
    if zip_entry.directory? && options[:recursion] then
      archive(
        Dir.entries(path).reject do |e|
          e == '.' || e == '..'
        end.collect do |e|
          File.join(path, e)
        end,
        options.merge(:path_prefix => zip_entry_path)
      )
    end
  end

  nil
end

#closeObject

Closes the archive.

Failure to close the archive by calling this method may result in a loss of data for writable archives.

NOTE: The underlying stream is only closed if the archive was opened with a String for the archive parameter.

Raises Archive::Zip::IOError if called more than once.

Raises:



168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/archive/zip.rb', line 168

def close
  raise IOError, 'closed archive' if closed?

  if writable? then
    # Write the new archive contents.
    dump(@archive)
  end

  # Note that we only close delegate streams which are opened by us so that
  # the user may do so for other delegate streams at his/her discretion.
  @archive.close if @close_delegate

  @closed = true
  nil
end

#closed?Boolean

Returns true if the ZIP archive is closed, false otherwise.



185
186
187
# File 'lib/archive/zip.rb', line 185

def closed?
  @closed
end

#each(&b) ⇒ Object

Iterates through each entry of a readable ZIP archive in turn yielding each one to the given block.

Raises Archive::Zip::IOError if called on a non-readable archive or after the archive is closed.

Raises:



204
205
206
207
208
209
210
211
212
213
# File 'lib/archive/zip.rb', line 204

def each(&b)
  raise IOError, 'non-readable archive' unless readable?
  raise IOError, 'closed archive' if closed?

  unless @parse_complete then
    parse(@archive)
    @parse_complete = true
  end
  @entries.each(&b)
end

#extract(destination, options = {}) ⇒ Object

Extracts the contents of the archive to destination, where destination is a path to a directory which will contain the contents of the archive. The destination path will be created if it does not already exist.

options is a Hash optionally containing the following:

:directories

When set to true (the default), entries representing directories in the archive are extracted. This happens after all non-directory entries are extracted so that directory metadata can be properly updated.

:symlinks

When set to false (the default), entries representing symlinks in the archive are skipped. When set to true, such entries are extracted. Exceptions may be raised on plaforms/file systems which do not support symlinks.

:overwrite

When set to :all (the default), files which already exist will be replaced. When set to :older, such files will only be replaced if they are older according to their last modified times than the zip entry which would replace them. When set to :none, such files will never be replaced. Any other value is the same as :all.

:create

When set to true (the default), files and directories which do not already exist will be extracted. When set to false, only files and directories which already exist will be extracted (depending on the setting of :overwrite).

:flatten

When set to false (the default), the directory paths containing extracted files will be created within destination in order to contain the files. When set to true, files are extracted directly to destination and directory entries are skipped.

:exclude

Specifies a proc or lambda which takes a single argument containing a zip entry and returns true if the entry should be skipped during extraction and false if it should be extracted.

:password

Specifies a proc, lambda, or a String. If a proc or lambda is used, it must take a single argument containing a zip entry and return a String to be used as a decryption key for the entry. If a String is used, it will be used as a decryption key for all encrypted entries.

:on_error

Specifies a proc or lambda which is called when an exception is raised during the extraction of an entry. It takes two arguments, a zip entry and an exception object generated while attempting to extract the entry. If :retry is returned, extraction of the entry is attempted again. If :skip is returned, the entry is skipped. Otherwise, the exception is raised.

Any other options which are supported by Archive::Zip::Entry#extract are also supported.

Raises Archive::Zip::IOError if called on a non-readable archive or after the archive is closed.

Example

An archive, archive.zip, contains:

zip-test/
zip-test/dir1/
zip-test/dir1/file2.txt
zip-test/dir2/
zip-test/file1.txt

A directory, extract4, contains:

zip-test
+- dir1
+- file1.txt

Extract the archive:

Archive::Zip.open('archive.zip') do |z|
  z.extract('extract1')
end

Archive::Zip.open('archive.zip') do |z|
  z.extract('extract2', :flatten => true)
end

Archive::Zip.open('archive.zip') do |z|
  z.extract('extract3', :create => false)
end

Archive::Zip.open('archive.zip') do |z|
  z.extract('extract3', :create => true)
end

Archive::Zip.open('archive.zip') do |z|
  z.extract( 'extract5', :exclude => lambda { |e| e.file? })
end

The directories contain:

extract1 -> zip-test
            +- dir1
            |  +- file2.txt
            +- dir2
            +- file1.txt

extract2 -> file2.txt
            file1.txt

extract3 -> <empty>

extract4 -> zip-test
            +- dir2
            +- file1.txt       <- from archive contents

extract5 -> zip-test
            +- dir1
            +- dir2

Raises:



557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
# File 'lib/archive/zip.rb', line 557

def extract(destination, options = {})
  raise IOError, 'non-readable archive' unless readable?
  raise IOError, 'closed archive' if closed?

  # Ensure that unspecified options have default values.
  options[:directories] = true  unless options.has_key?(:directories)
  options[:symlinks]    = false unless options.has_key?(:symlinks)
  options[:overwrite]   = :all  unless options[:overwrite] == :older ||
                                       options[:overwrite] == :never
  options[:create]      = true  unless options.has_key?(:create)
  options[:flatten]     = false unless options.has_key?(:flatten)

  # Flattening the archive structure implies that directory entries are
  # skipped.
  options[:directories] = false if options[:flatten]

  # First extract all non-directory entries.
  directories = []
  each do |entry|
    # Compute the target file path.
    file_path = entry.zip_path
    file_path = File.basename(file_path) if options[:flatten]
    file_path = File.join(destination, file_path)

    # Cache some information about the file path.
    file_exists = File.exist?(file_path)
    file_mtime = File.mtime(file_path) if file_exists

    begin
      # Skip this entry if so directed.
      if (! file_exists && ! options[:create]) ||
         (file_exists &&
          (options[:overwrite] == :never ||
           options[:overwrite] == :older && entry.mtime <= file_mtime)) ||
         (! options[:exclude].nil? && options[:exclude][entry]) then
        next
      end

      # Set the decryption key for the entry.
      if options[:password].kind_of?(String) then
        entry.password = options[:password]
      elsif ! options[:password].nil? then
        entry.password = options[:password][entry]
      end

      if entry.directory? then
        # Record the directories as they are encountered.
        directories << entry
      elsif entry.file? || (entry.symlink? && options[:symlinks]) then
        # Extract files and symlinks.
        entry.extract(
          options.merge(:file_path => file_path)
        )
      end
    rescue StandardError => error
      unless options[:on_error].nil? then
        case options[:on_error][entry, error]
        when :retry
          retry
        when :skip
        else
          raise
        end
      else
        raise
      end
    end
  end

  if options[:directories] then
    # Then extract the directory entries in depth first order so that time
    # stamps, ownerships, and permissions can be properly restored.
    directories.sort { |a, b| b.zip_path <=> a.zip_path }.each do |entry|
      begin
        entry.extract(
          options.merge(
            :file_path => File.join(destination, entry.zip_path)
          )
        )
      rescue StandardError => error
        unless options[:on_error].nil? then
          case options[:on_error][entry, error]
          when :retry
            retry
          when :skip
          else
            raise
          end
        else
          raise
        end
      end
    end
  end

  nil
end

#readable?Boolean

Returns true if the ZIP archive is readable, false otherwise.



190
191
192
# File 'lib/archive/zip.rb', line 190

def readable?
  @mode == :r
end

#writable?Boolean

Returns true if the ZIP archive is writable, false otherwise.



195
196
197
# File 'lib/archive/zip.rb', line 195

def writable?
  @mode == :w
end