rubyzip 2.4.1 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/Changelog.md +549 -0
  3. data/LICENSE.md +24 -0
  4. data/README.md +250 -44
  5. data/Rakefile +15 -13
  6. data/lib/rubyzip.rb +3 -0
  7. data/lib/zip/central_directory.rb +194 -125
  8. data/lib/zip/compressor.rb +3 -1
  9. data/lib/zip/constants.rb +30 -21
  10. data/lib/zip/crypto/aes_encryption.rb +213 -0
  11. data/lib/zip/crypto/decrypted_io.rb +27 -15
  12. data/lib/zip/crypto/encryption.rb +13 -2
  13. data/lib/zip/crypto/null_encryption.rb +5 -13
  14. data/lib/zip/crypto/traditional_encryption.rb +13 -7
  15. data/lib/zip/decompressor.rb +4 -3
  16. data/lib/zip/deflater.rb +12 -8
  17. data/lib/zip/dirtyable.rb +32 -0
  18. data/lib/zip/dos_time.rb +64 -6
  19. data/lib/zip/entry.rb +432 -270
  20. data/lib/zip/entry_set.rb +11 -9
  21. data/lib/zip/errors.rb +152 -16
  22. data/lib/zip/extra_field/aes.rb +50 -0
  23. data/lib/zip/extra_field/generic.rb +10 -11
  24. data/lib/zip/extra_field/ntfs.rb +6 -4
  25. data/lib/zip/extra_field/old_unix.rb +5 -3
  26. data/lib/zip/extra_field/universal_time.rb +3 -1
  27. data/lib/zip/extra_field/unix.rb +7 -5
  28. data/lib/zip/extra_field/unknown.rb +35 -0
  29. data/lib/zip/extra_field/zip64.rb +19 -5
  30. data/lib/zip/extra_field.rb +25 -23
  31. data/lib/zip/file.rb +261 -262
  32. data/lib/zip/file_split.rb +91 -0
  33. data/lib/zip/filesystem/dir.rb +86 -0
  34. data/lib/zip/filesystem/directory_iterator.rb +48 -0
  35. data/lib/zip/filesystem/file.rb +263 -0
  36. data/lib/zip/filesystem/file_stat.rb +110 -0
  37. data/lib/zip/filesystem/zip_file_name_mapper.rb +81 -0
  38. data/lib/zip/filesystem.rb +27 -596
  39. data/lib/zip/inflater.rb +13 -10
  40. data/lib/zip/input_stream.rb +102 -61
  41. data/lib/zip/ioextras/abstract_input_stream.rb +156 -58
  42. data/lib/zip/ioextras/abstract_output_stream.rb +15 -3
  43. data/lib/zip/ioextras/fake_io.rb +39 -0
  44. data/lib/zip/ioextras.rb +12 -16
  45. data/lib/zip/null_compressor.rb +3 -1
  46. data/lib/zip/null_decompressor.rb +6 -3
  47. data/lib/zip/null_input_stream.rb +3 -1
  48. data/lib/zip/output_stream.rb +76 -64
  49. data/lib/zip/pass_thru_compressor.rb +6 -3
  50. data/lib/zip/pass_thru_decompressor.rb +12 -9
  51. data/lib/zip/streamable_directory.rb +3 -1
  52. data/lib/zip/streamable_stream.rb +4 -1
  53. data/lib/zip/version.rb +4 -1
  54. data/lib/zip.rb +28 -23
  55. data/rubyzip.gemspec +39 -0
  56. data/samples/example.rb +8 -3
  57. data/samples/example_filesystem.rb +3 -2
  58. data/samples/example_recursive.rb +3 -1
  59. data/samples/gtk_ruby_zip.rb +5 -3
  60. data/samples/qtzip.rb +7 -6
  61. data/samples/write_simple.rb +2 -1
  62. data/samples/zipfind.rb +1 -0
  63. metadata +88 -52
  64. data/TODO +0 -15
  65. data/lib/zip/extra_field/zip64_placeholder.rb +0 -15
data/lib/zip/file.rb CHANGED
@@ -1,132 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'forwardable'
5
+
6
+ require_relative 'file_split'
7
+
1
8
  module Zip
2
- # ZipFile is modeled after java.util.zip.ZipFile from the Java SDK.
3
- # The most important methods are those inherited from
4
- # ZipCentralDirectory for accessing information about the entries in
5
- # the archive and methods such as get_input_stream and
6
- # get_output_stream for reading from and writing entries to the
9
+ # Zip::File is modeled after java.util.zip.ZipFile from the Java SDK.
10
+ # The most important methods are those for accessing information about
11
+ # the entries in
12
+ # the archive and methods such as `get_input_stream` and
13
+ # `get_output_stream` for reading from and writing entries to the
7
14
  # archive. The class includes a few convenience methods such as
8
- # #extract for extracting entries to the filesystem, and #remove,
9
- # #replace, #rename and #mkdir for making simple modifications to
10
- # the archive.
15
+ # `extract` for extracting entries to the filesystem, `extract_all` for
16
+ # extracting every entry, `add_recursive` for adding the contents of a
17
+ # directory tree, and `remove`, `replace`, `rename` and `mkdir` for
18
+ # making simple modifications to the archive.
11
19
  #
12
- # Modifications to a zip archive are not committed until #commit or
13
- # #close is called. The method #open accepts a block following
14
- # the pattern from File.open offering a simple way to
20
+ # Modifications to a zip archive are not committed until `commit` or
21
+ # `close` is called. The method `open` accepts a block following
22
+ # the pattern from ::File.open offering a simple way to
15
23
  # automatically close the archive when the block returns.
16
24
  #
17
- # The following example opens zip archive <code>my.zip</code>
25
+ # The following example opens zip archive `my.zip`
18
26
  # (creating it if it doesn't exist) and adds an entry
19
- # <code>first.txt</code> and a directory entry <code>a_dir</code>
27
+ # `first.txt` and a directory entry `a_dir`
20
28
  # to it.
21
29
  #
22
- # require 'zip'
30
+ # ```
31
+ # require 'zip'
23
32
  #
24
- # Zip::File.open("my.zip", Zip::File::CREATE) {
25
- # |zipfile|
26
- # zipfile.get_output_stream("first.txt") { |f| f.puts "Hello from ZipFile" }
27
- # zipfile.mkdir("a_dir")
28
- # }
33
+ # Zip::File.open('my.zip', create: true) do |zipfile|
34
+ # zipfile.get_output_stream('first.txt') { |f| f.puts 'Hello from Zip::File' }
35
+ # zipfile.mkdir('a_dir')
36
+ # end
37
+ # ```
29
38
  #
30
- # The next example reopens <code>my.zip</code> writes the contents of
31
- # <code>first.txt</code> to standard out and deletes the entry from
39
+ # The next example reopens `my.zip`, writes the contents of
40
+ # `first.txt` to standard out and deletes the entry from
32
41
  # the archive.
33
42
  #
34
- # require 'zip'
43
+ # ```
44
+ # require 'zip'
35
45
  #
36
- # Zip::File.open("my.zip", Zip::File::CREATE) {
37
- # |zipfile|
38
- # puts zipfile.read("first.txt")
39
- # zipfile.remove("first.txt")
40
- # }
46
+ # Zip::File.open('my.zip', create: true) do |zipfile|
47
+ # puts zipfile.read('first.txt')
48
+ # zipfile.remove('first.txt')
49
+ # end
41
50
  #
42
- # ZipFileSystem offers an alternative API that emulates ruby's
43
- # interface for accessing the filesystem, ie. the File and Dir classes.
44
-
45
- class File < CentralDirectory
46
- CREATE = true
47
- SPLIT_SIGNATURE = 0x08074b50
48
- ZIP64_EOCD_SIGNATURE = 0x06064b50
49
- MAX_SEGMENT_SIZE = 3_221_225_472
50
- MIN_SEGMENT_SIZE = 65_536
51
- DATA_BUFFER_SIZE = 8192
52
- IO_METHODS = [:tell, :seek, :read, :eof, :close]
53
-
54
- DEFAULT_OPTIONS = {
55
- restore_ownership: false,
56
- restore_permissions: false,
57
- restore_times: false
58
- }.freeze
51
+ # Zip::FileSystem offers an alternative API that emulates ruby's
52
+ # interface for accessing the filesystem, ie. the ::File and ::Dir classes.
53
+ class File
54
+ include Enumerable
55
+ extend Forwardable
56
+ extend FileSplit
59
57
 
58
+ IO_METHODS = [:tell, :seek, :read, :eof, :close].freeze # :nodoc:
59
+
60
+ # The name of this zip archive.
60
61
  attr_reader :name
61
62
 
62
63
  # default -> false.
63
64
  attr_accessor :restore_ownership
64
65
 
65
- # default -> false, but will be set to true in a future version.
66
+ # default -> true.
66
67
  attr_accessor :restore_permissions
67
68
 
68
- # default -> false, but will be set to true in a future version.
69
+ # default -> true.
69
70
  attr_accessor :restore_times
70
71
 
71
- # Returns the zip files comment, if it has one
72
- attr_accessor :comment
72
+ def_delegators :@cdir, :comment, :comment=, :each, :entries, :glob, :size
73
73
 
74
- # Opens a zip archive. Pass true as the second parameter to create
74
+ # Opens a zip archive. Pass create: true to create
75
75
  # a new archive if it doesn't exist already.
76
- def initialize(path_or_io, dep_create = false, dep_buffer = false,
77
- create: false, buffer: false, **options)
76
+ def initialize(path_or_io, create: false, buffer: false,
77
+ restore_ownership: DEFAULT_RESTORE_OPTIONS[:restore_ownership],
78
+ restore_permissions: DEFAULT_RESTORE_OPTIONS[:restore_permissions],
79
+ restore_times: DEFAULT_RESTORE_OPTIONS[:restore_times],
80
+ compression_level: ::Zip.default_compression,
81
+ suppress_extra_fields: false)
78
82
  super()
79
83
 
80
- Zip.warn_about_v3_api('File#new') if dep_create || dep_buffer
81
-
82
- options = DEFAULT_OPTIONS.merge(options)
83
84
  @name = path_or_io.respond_to?(:path) ? path_or_io.path : path_or_io
84
- @comment = ''
85
- @create = create || dep_create ? true : false # allow any truthy value to mean true
86
- buffer ||= dep_buffer
85
+ @create = create ? true : false # allow any truthy value to mean true
87
86
 
88
- if ::File.size?(@name.to_s)
89
- # There is a file, which exists, that is associated with this zip.
90
- @create = false
91
- @file_permissions = ::File.stat(@name).mode
87
+ initialize_cdir(path_or_io, buffer: buffer)
92
88
 
93
- if buffer
94
- read_from_stream(path_or_io)
95
- else
96
- ::File.open(@name, 'rb') do |f|
97
- read_from_stream(f)
98
- end
99
- end
100
- elsif buffer && path_or_io.size > 0
101
- # This zip is probably a non-empty StringIO.
102
- @create = false
103
- read_from_stream(path_or_io)
104
- elsif @create
105
- # This zip is completely new/empty and is to be created.
106
- @entry_set = EntrySet.new
107
- elsif ::File.zero?(@name)
108
- # A file exists, but it is empty.
109
- raise Error, "File #{@name} has zero size. Did you mean to pass the create flag?"
110
- else
111
- # Everything is wrong.
112
- raise Error, "File #{@name} not found"
113
- end
114
-
115
- @stored_entries = @entry_set.dup
116
- @stored_comment = @comment
117
- @restore_ownership = options[:restore_ownership]
118
- @restore_permissions = options[:restore_permissions]
119
- @restore_times = options[:restore_times]
89
+ @restore_ownership = restore_ownership
90
+ @restore_permissions = restore_permissions
91
+ @restore_times = restore_times
92
+ @compression_level = compression_level
93
+ @suppress_extra_fields = suppress_extra_fields
120
94
  end
121
95
 
122
96
  class << self
123
97
  # Similar to ::new. If a block is passed the Zip::File object is passed
124
98
  # to the block and is automatically closed afterwards, just as with
125
99
  # ruby's builtin File::open method.
126
- def open(file_name, dep_create = false, create: false, **options)
127
- Zip.warn_about_v3_api('Zip::File.open') if dep_create
100
+ def open(file_name, create: false,
101
+ restore_ownership: DEFAULT_RESTORE_OPTIONS[:restore_ownership],
102
+ restore_permissions: DEFAULT_RESTORE_OPTIONS[:restore_permissions],
103
+ restore_times: DEFAULT_RESTORE_OPTIONS[:restore_times],
104
+ compression_level: ::Zip.default_compression,
105
+ suppress_extra_fields: false)
106
+ zf = ::Zip::File.new(file_name, create: create,
107
+ restore_ownership: restore_ownership,
108
+ restore_permissions: restore_permissions,
109
+ restore_times: restore_times,
110
+ compression_level: compression_level,
111
+ suppress_extra_fields: suppress_extra_fields)
128
112
 
129
- zf = ::Zip::File.new(file_name, create: (dep_create || create), buffer: false, **options)
130
113
  return zf unless block_given?
131
114
 
132
115
  begin
@@ -136,31 +119,30 @@ module Zip
136
119
  end
137
120
  end
138
121
 
139
- # Same as #open. But outputs data to a buffer instead of a file
140
- def add_buffer
141
- Zip.warn_about_v3_api('Zip::File.add_buffer')
142
-
143
- io = ::StringIO.new
144
- zf = ::Zip::File.new(io, true, true)
145
- yield zf
146
- zf.write_buffer(io)
147
- end
148
-
149
122
  # Like #open, but reads zip archive contents from a String or open IO
150
123
  # stream, and outputs data to a buffer.
151
124
  # (This can be used to extract data from a
152
125
  # downloaded zip archive without first saving it to disk.)
153
- def open_buffer(io, **options)
126
+ def open_buffer(io = ::StringIO.new, create: false,
127
+ restore_ownership: DEFAULT_RESTORE_OPTIONS[:restore_ownership],
128
+ restore_permissions: DEFAULT_RESTORE_OPTIONS[:restore_permissions],
129
+ restore_times: DEFAULT_RESTORE_OPTIONS[:restore_times],
130
+ compression_level: ::Zip.default_compression,
131
+ suppress_extra_fields: false)
154
132
  unless IO_METHODS.map { |method| io.respond_to?(method) }.all? || io.kind_of?(String)
155
- raise "Zip::File.open_buffer expects a String or IO-like argument (responds to #{IO_METHODS.join(', ')}). Found: #{io.class}"
133
+ raise 'Zip::File.open_buffer expects a String or IO-like argument' \
134
+ "(responds to #{IO_METHODS.join(', ')}). Found: #{io.class}"
156
135
  end
157
136
 
158
137
  io = ::StringIO.new(io) if io.kind_of?(::String)
159
138
 
160
- # https://github.com/rubyzip/rubyzip/issues/119
161
- io.binmode if io.respond_to?(:binmode)
139
+ zf = ::Zip::File.new(io, create: create, buffer: true,
140
+ restore_ownership: restore_ownership,
141
+ restore_permissions: restore_permissions,
142
+ restore_times: restore_times,
143
+ compression_level: compression_level,
144
+ suppress_extra_fields: suppress_extra_fields)
162
145
 
163
- zf = ::Zip::File.new(io, create: true, buffer: true, **options)
164
146
  return zf unless block_given?
165
147
 
166
148
  yield zf
@@ -184,88 +166,40 @@ module Zip
184
166
  end
185
167
  end
186
168
 
187
- def get_segment_size_for_split(segment_size)
188
- if MIN_SEGMENT_SIZE > segment_size
189
- MIN_SEGMENT_SIZE
190
- elsif MAX_SEGMENT_SIZE < segment_size
191
- MAX_SEGMENT_SIZE
192
- else
193
- segment_size
194
- end
195
- end
169
+ # Count the entries in a zip archive without reading the whole set of
170
+ # entry data into memory.
171
+ def count_entries(path_or_io)
172
+ cdir = ::Zip::CentralDirectory.new
196
173
 
197
- def get_partial_zip_file_name(zip_file_name, partial_zip_file_name)
198
- unless partial_zip_file_name.nil?
199
- partial_zip_file_name = zip_file_name.sub(/#{::File.basename(zip_file_name)}\z/,
200
- partial_zip_file_name + ::File.extname(zip_file_name))
174
+ if path_or_io.kind_of?(String)
175
+ ::File.open(path_or_io, 'rb') do |f|
176
+ cdir.count_entries(f)
177
+ end
178
+ else
179
+ cdir.count_entries(path_or_io)
201
180
  end
202
- partial_zip_file_name ||= zip_file_name
203
- partial_zip_file_name
204
181
  end
205
182
 
206
- def get_segment_count_for_split(zip_file_size, segment_size)
207
- (zip_file_size / segment_size).to_i + (zip_file_size % segment_size == 0 ? 0 : 1)
208
- end
209
-
210
- def put_split_signature(szip_file, segment_size)
211
- signature_packed = [SPLIT_SIGNATURE].pack('V')
212
- szip_file << signature_packed
213
- segment_size - signature_packed.size
214
- end
215
-
216
- #
217
- # TODO: Make the code more understandable
183
+ # Recursively adds the contents of `src_dir` to the new archive
184
+ # `zip_file_name`, nested under `prefix` if given (the archive root
185
+ # otherwise). `src_dir` itself is not added, only its contents.
218
186
  #
219
- def save_splited_part(zip_file, partial_zip_file_name, zip_file_size, szip_file_index, segment_size, segment_count)
220
- ssegment_size = zip_file_size - zip_file.pos
221
- ssegment_size = segment_size if ssegment_size > segment_size
222
- szip_file_name = "#{partial_zip_file_name}.#{format('%03d', szip_file_index)}"
223
- ::File.open(szip_file_name, 'wb') do |szip_file|
224
- if szip_file_index == 1
225
- ssegment_size = put_split_signature(szip_file, segment_size)
226
- end
227
- chunk_bytes = 0
228
- until ssegment_size == chunk_bytes || zip_file.eof?
229
- segment_bytes_left = ssegment_size - chunk_bytes
230
- buffer_size = segment_bytes_left < DATA_BUFFER_SIZE ? segment_bytes_left : DATA_BUFFER_SIZE
231
- chunk = zip_file.read(buffer_size)
232
- chunk_bytes += buffer_size
233
- szip_file << chunk
234
- # Info for track splitting
235
- yield segment_count, szip_file_index, chunk_bytes, ssegment_size if block_given?
236
- end
187
+ # Symlinks are ignored (skipped, with a warning) rather than followed
188
+ # or added, to avoid the security issues they can pose. `max_depth`
189
+ # limits how many directory levels below `src_dir` are walked; anything
190
+ # deeper is skipped, also with a warning (default: 16).
191
+ def add_recursive(zip_file_name, src_dir, prefix: '', max_depth: 16, &continue_on_exists_proc)
192
+ Zip::File.open(zip_file_name, create: true) do |zf|
193
+ zf.add_recursive(src_dir, prefix: prefix, max_depth: max_depth, &continue_on_exists_proc)
237
194
  end
238
195
  end
239
196
 
240
- # Splits an archive into parts with segment size
241
- def split(zip_file_name,
242
- dep_segment_size = MAX_SEGMENT_SIZE, dep_delete_zip_file = true, dep_partial_zip_file_name = nil,
243
- segment_size: MAX_SEGMENT_SIZE, delete_zip_file: nil, partial_zip_file_name: nil)
244
- raise Error, "File #{zip_file_name} not found" unless ::File.exist?(zip_file_name)
245
- raise Errno::ENOENT, zip_file_name unless ::File.readable?(zip_file_name)
246
-
247
- if dep_segment_size != MAX_SEGMENT_SIZE || !dep_delete_zip_file || dep_partial_zip_file_name
248
- Zip.warn_about_v3_api('Zip::File.split')
249
- end
250
-
251
- zip_file_size = ::File.size(zip_file_name)
252
- segment_size = get_segment_size_for_split(segment_size || dep_segment_size)
253
- return if zip_file_size <= segment_size
254
-
255
- segment_count = get_segment_count_for_split(zip_file_size, segment_size)
256
- # Checking for correct zip structure
257
- ::Zip::File.open(zip_file_name) {}
258
- partial_zip_file_name = get_partial_zip_file_name(zip_file_name, (partial_zip_file_name || dep_partial_zip_file_name))
259
- szip_file_index = 0
260
- ::File.open(zip_file_name, 'rb') do |zip_file|
261
- until zip_file.eof?
262
- szip_file_index += 1
263
- save_splited_part(zip_file, partial_zip_file_name, zip_file_size, szip_file_index, segment_size, segment_count)
264
- end
197
+ # Extracts every entry in the zip archive `zip_file_name` into
198
+ # `destination_directory`, preserving the archive's directory structure.
199
+ def extract_all(zip_file_name, destination_directory = '.', &block)
200
+ Zip::File.open(zip_file_name) do |zf|
201
+ zf.extract_all(destination_directory, &block)
265
202
  end
266
- delete_zip_file = delete_zip_file.nil? ? dep_delete_zip_file : delete_zip_file
267
- ::File.delete(zip_file_name) if delete_zip_file
268
- szip_file_index
269
203
  end
270
204
  end
271
205
 
@@ -281,45 +215,30 @@ module Zip
281
215
  # specified. If a block is passed the stream object is passed to the block and
282
216
  # the stream is automatically closed afterwards just as with ruby's builtin
283
217
  # File.open method.
284
- # rubocop:disable Metrics/ParameterLists, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
285
- def get_output_stream(entry,
286
- dep_permission_int = nil, dep_comment = nil,
287
- dep_extra = nil, dep_compressed_size = nil, dep_crc = nil,
288
- dep_compression_method = nil, dep_size = nil, dep_time = nil,
289
- permission_int: nil, comment: nil,
218
+ def get_output_stream(entry, permissions: nil, comment: nil,
290
219
  extra: nil, compressed_size: nil, crc: nil,
291
- compression_method: nil, size: nil, time: nil,
292
- &a_proc)
293
-
294
- unless dep_permission_int.nil? && dep_comment.nil? && dep_extra.nil? &&
295
- dep_compressed_size.nil? && dep_crc.nil? && dep_compression_method.nil? &&
296
- dep_size.nil? && dep_time.nil?
297
- Zip.warn_about_v3_api('Zip::File#get_output_stream')
298
- end
299
-
220
+ compression_method: nil, compression_level: nil,
221
+ size: nil, time: nil, &a_proc)
300
222
  new_entry =
301
223
  if entry.kind_of?(Entry)
302
224
  entry
303
225
  else
304
- Entry.new(@name, entry.to_s,
305
- comment: (comment || dep_comment),
306
- extra: (extra || dep_extra),
307
- compressed_size: (compressed_size || dep_compressed_size),
308
- crc: (crc || dep_crc),
309
- compression_method: (compression_method || dep_compression_method),
310
- size: (size || dep_size),
311
- time: (time || dep_time))
226
+ Entry.new(
227
+ @name, entry.to_s, comment: comment, extra: extra,
228
+ compressed_size: compressed_size, crc: crc, size: size,
229
+ compression_method: compression_method,
230
+ compression_level: compression_level, time: time
231
+ )
312
232
  end
313
233
  if new_entry.directory?
314
234
  raise ArgumentError,
315
235
  "cannot open stream to directory entry - '#{new_entry}'"
316
236
  end
317
- new_entry.unix_perms = (permission_int || dep_permission_int)
237
+ new_entry.unix_perms = permissions
318
238
  zip_streamable_entry = StreamableStream.new(new_entry)
319
- @entry_set << zip_streamable_entry
239
+ @cdir << zip_streamable_entry
320
240
  zip_streamable_entry.get_output_stream(&a_proc)
321
241
  end
322
- # rubocop:enable Metrics/ParameterLists, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
323
242
 
324
243
  # Returns the name of the zip archive
325
244
  def to_s
@@ -335,31 +254,58 @@ module Zip
335
254
  def add(entry, src_path, &continue_on_exists_proc)
336
255
  continue_on_exists_proc ||= proc { ::Zip.continue_on_exists_proc }
337
256
  check_entry_exists(entry, continue_on_exists_proc, 'add')
338
- new_entry = entry.kind_of?(::Zip::Entry) ? entry : ::Zip::Entry.new(@name, entry.to_s)
257
+ new_entry = if entry.kind_of?(::Zip::Entry)
258
+ entry
259
+ else
260
+ ::Zip::Entry.new(
261
+ @name, entry.to_s,
262
+ compression_level: @compression_level
263
+ )
264
+ end
339
265
  new_entry.gather_fileinfo_from_srcpath(src_path)
340
- new_entry.dirty = true
341
- @entry_set << new_entry
266
+ @cdir << new_entry
342
267
  end
343
268
 
344
269
  # Convenience method for adding the contents of a file to the archive
345
270
  # in Stored format (uncompressed)
346
271
  def add_stored(entry, src_path, &continue_on_exists_proc)
347
- entry = ::Zip::Entry.new(@name, entry.to_s, nil, nil, nil, nil, ::Zip::Entry::STORED)
272
+ entry = ::Zip::Entry.new(
273
+ @name, entry.to_s, compression_method: ::Zip::Entry::STORED
274
+ )
348
275
  add(entry, src_path, &continue_on_exists_proc)
349
276
  end
350
277
 
278
+ # Recursively adds the contents of `src_dir` to the archive, nested
279
+ # under `prefix` if given (the archive root otherwise). `src_dir`
280
+ # itself is not added, only its contents.
281
+ #
282
+ # Symlinks are ignored (skipped, with a warning) rather than followed
283
+ # or added, to avoid the security issues they can pose. `max_depth`
284
+ # limits how many directory levels below `src_dir` are walked; anything
285
+ # deeper is skipped, also with a warning (default: 16).
286
+ def add_recursive(src_dir, prefix: '', max_depth: 16, &continue_on_exists_proc)
287
+ raise Errno::ENOENT, src_dir unless ::File.directory?(src_dir)
288
+ raise ArgumentError, 'max_depth must be at least 1' if max_depth < 1
289
+
290
+ prefix = prefix.to_s
291
+ prefix += '/' unless prefix.empty? || prefix.end_with?('/')
292
+
293
+ add_recursive_dir(prefix, src_dir, 1, max_depth, continue_on_exists_proc)
294
+ self
295
+ end
296
+
351
297
  # Removes the specified entry.
352
298
  def remove(entry)
353
- @entry_set.delete(get_entry(entry))
299
+ @cdir.delete(get_entry(entry))
354
300
  end
355
301
 
356
302
  # Renames the specified entry.
357
303
  def rename(entry, new_name, &continue_on_exists_proc)
358
304
  found_entry = get_entry(entry)
359
305
  check_entry_exists(new_name, continue_on_exists_proc, 'rename')
360
- @entry_set.delete(found_entry)
306
+ @cdir.delete(found_entry)
361
307
  found_entry.name = new_name
362
- @entry_set << found_entry
308
+ @cdir << found_entry
363
309
  end
364
310
 
365
311
  # Replaces the specified entry with the contents of src_path (from
@@ -370,25 +316,43 @@ module Zip
370
316
  add(entry, src_path)
371
317
  end
372
318
 
373
- # Extracts entry to file dest_path.
374
- def extract(entry, dest_path, &block)
375
- Zip.warn_about_v3_api('Zip::File#extract')
376
-
377
- block ||= proc { ::Zip.on_exists_proc }
378
- found_entry = get_entry(entry)
379
- found_entry.extract(dest_path, &block)
380
- end
381
-
382
319
  # Extracts `entry` to a file at `entry_path`, with `destination_directory`
383
320
  # as the base location in the filesystem.
384
321
  #
385
322
  # NB: The caller is responsible for making sure `destination_directory` is
386
323
  # safe, if it is passed.
387
- def extract_v3(entry, entry_path = nil, destination_directory: '.', &block)
324
+ def extract(entry, entry_path = nil, destination_directory: '.', &block)
388
325
  block ||= proc { ::Zip.on_exists_proc }
389
326
  found_entry = get_entry(entry)
390
327
  entry_path ||= found_entry.name
391
- found_entry.extract_v3(entry_path, destination_directory: destination_directory, &block)
328
+ found_entry.extract(entry_path, destination_directory: destination_directory, &block)
329
+ end
330
+
331
+ # Extracts every entry in the archive into `destination_directory`,
332
+ # preserving the archive's directory structure.
333
+ #
334
+ # Symlink entries are ignored (skipped, with a warning) rather than
335
+ # extracted. See `Entry#extract` for the path-safety checks applied to
336
+ # every other entry.
337
+ #
338
+ # NB: The caller is responsible for making sure `destination_directory` is
339
+ # safe, if it is passed.
340
+ def extract_all(destination_directory = '.', &block)
341
+ # Partition the entries into directories and non-directories, so that
342
+ # directories are created first.
343
+ dirs, others = entries.partition(&:directory?)
344
+
345
+ (dirs + others).each do |entry|
346
+ if entry.symlink?
347
+ warn "WARNING: skipped symlink '#{entry.name}' during extract_all."
348
+ next
349
+ end
350
+
351
+ entry.extract(entry.name, destination_directory: destination_directory,
352
+ create_parent_directories: true, &block)
353
+ end
354
+
355
+ self
392
356
  end
393
357
 
394
358
  # Commits changes that has been made since the previous commit to
@@ -397,25 +361,24 @@ module Zip
397
361
  return if name.kind_of?(StringIO) || !commit_required?
398
362
 
399
363
  on_success_replace do |tmp_file|
400
- ::Zip::OutputStream.open(tmp_file) do |zos|
401
- @entry_set.each do |e|
364
+ ::Zip::OutputStream.open(tmp_file, suppress_extra_fields: @suppress_extra_fields) do |zos|
365
+ @cdir.each do |e|
402
366
  e.write_to_zip_output_stream(zos)
403
- e.dirty = false
404
367
  e.clean_up
405
368
  end
406
369
  zos.comment = comment
407
370
  end
408
371
  true
409
372
  end
410
- initialize(name)
373
+ initialize_cdir(@name)
411
374
  end
412
375
 
413
376
  # Write buffer write changes to buffer and return
414
377
  def write_buffer(io = ::StringIO.new)
415
378
  return io unless commit_required?
416
379
 
417
- ::Zip::OutputStream.write_buffer(io) do |zos|
418
- @entry_set.each { |e| e.write_to_zip_output_stream(zos) }
380
+ ::Zip::OutputStream.write_buffer(io, suppress_extra_fields: @suppress_extra_fields) do |zos|
381
+ @cdir.each { |e| e.write_to_zip_output_stream(zos) }
419
382
  zos.comment = comment
420
383
  end
421
384
  end
@@ -428,16 +391,19 @@ module Zip
428
391
  # Returns true if any changes has been made to this archive since
429
392
  # the previous commit
430
393
  def commit_required?
431
- @entry_set.each do |e|
432
- return true if e.dirty
394
+ return true if @create || @cdir.dirty?
395
+
396
+ @cdir.each do |e|
397
+ return true if e.dirty?
433
398
  end
434
- @comment != @stored_comment || @entry_set != @stored_entries || @create
399
+
400
+ false
435
401
  end
436
402
 
437
403
  # Searches for entry with the specified name. Returns nil if
438
404
  # no entry is found. See also get_entry
439
405
  def find_entry(entry_name)
440
- selected_entry = @entry_set.find_entry(entry_name)
406
+ selected_entry = @cdir.find_entry(entry_name)
441
407
  return if selected_entry.nil?
442
408
 
443
409
  selected_entry.restore_ownership = @restore_ownership
@@ -446,11 +412,6 @@ module Zip
446
412
  selected_entry
447
413
  end
448
414
 
449
- # Searches for entries given a glob
450
- def glob(*args, &block)
451
- @entry_set.glob(*args, &block)
452
- end
453
-
454
415
  # Searches for an entry just as find_entry, but throws Errno::ENOENT
455
416
  # if no entry is found.
456
417
  def get_entry(entry)
@@ -466,33 +427,73 @@ module Zip
466
427
 
467
428
  entry_name = entry_name.dup.to_s
468
429
  entry_name << '/' unless entry_name.end_with?('/')
469
- @entry_set << ::Zip::StreamableDirectory.new(@name, entry_name, nil, permission)
430
+ @cdir << ::Zip::StreamableDirectory.new(@name, entry_name, nil, permission)
470
431
  end
471
432
 
472
433
  private
473
434
 
474
- def directory?(new_entry, src_path)
475
- path_is_directory = ::File.directory?(src_path)
476
- if new_entry.directory? && !path_is_directory
477
- raise ArgumentError,
478
- "entry name '#{new_entry}' indicates directory entry, but " \
479
- "'#{src_path}' is not a directory"
480
- elsif !new_entry.directory? && path_is_directory
481
- new_entry.name += '/'
435
+ def add_recursive_dir(entry_prefix, src_dir, depth, max_depth, continue_on_exists_proc)
436
+ ::Dir.children(src_dir).sort.each do |child|
437
+ src_path = ::File.join(src_dir, child)
438
+
439
+ if ::File.symlink?(src_path)
440
+ warn "WARNING: skipped symlink '#{src_path}' while adding directory contents."
441
+ elsif ::File.directory?(src_path)
442
+ entry_name = "#{entry_prefix}#{child}/"
443
+ add(entry_name, src_path, &continue_on_exists_proc)
444
+
445
+ if depth < max_depth
446
+ add_recursive_dir(entry_name, src_path, depth + 1, max_depth, continue_on_exists_proc)
447
+ else
448
+ warn "WARNING: max_depth (#{max_depth}) reached, not descending into '#{src_path}'."
449
+ end
450
+ elsif ::File.file?(src_path)
451
+ add("#{entry_prefix}#{child}", src_path, &continue_on_exists_proc)
452
+ else
453
+ warn "WARNING: skipped '#{src_path}' - not a regular file or directory."
454
+ end
455
+ end
456
+ end
457
+
458
+ def initialize_cdir(path_or_io, buffer: false)
459
+ @cdir = ::Zip::CentralDirectory.new
460
+
461
+ if ::File.size?(@name.to_s)
462
+ # There is a file, which exists, that is associated with this zip.
463
+ @create = false
464
+ @file_permissions = ::File.stat(@name).mode
465
+
466
+ if buffer
467
+ # https://github.com/rubyzip/rubyzip/issues/119
468
+ path_or_io.binmode if path_or_io.respond_to?(:binmode)
469
+ @cdir.read_from_stream(path_or_io)
470
+ else
471
+ ::File.open(@name, 'rb') do |f|
472
+ @cdir.read_from_stream(f)
473
+ end
474
+ end
475
+ elsif buffer && path_or_io.size > 0
476
+ # This zip is probably a non-empty StringIO.
477
+ @create = false
478
+ @cdir.read_from_stream(path_or_io)
479
+ elsif !@create && ::File.empty?(@name)
480
+ # A file exists, but it is empty, and we've said we're
481
+ # NOT creating a new zip.
482
+ raise Error, "File #{@name} has zero size. Did you mean to pass the create flag?"
483
+ elsif !@create
484
+ # If we get here, and we're not creating a new zip, then
485
+ # everything is wrong.
486
+ raise Error, "File #{@name} not found"
482
487
  end
483
- new_entry.directory? && path_is_directory
484
488
  end
485
489
 
486
490
  def check_entry_exists(entry_name, continue_on_exists_proc, proc_name)
491
+ return unless @cdir.include?(entry_name)
492
+
487
493
  continue_on_exists_proc ||= proc { Zip.continue_on_exists_proc }
488
- return unless @entry_set.include?(entry_name)
494
+ raise ::Zip::EntryExistsError.new proc_name, entry_name unless continue_on_exists_proc.call
489
495
 
490
- if continue_on_exists_proc.call
491
- remove get_entry(entry_name)
492
- else
493
- raise ::Zip::EntryExistsError,
494
- proc_name + " failed. Entry #{entry_name} already exists"
495
- end
496
+ remove get_entry(entry_name)
496
497
  end
497
498
 
498
499
  def check_file(path)
@@ -502,14 +503,12 @@ module Zip
502
503
  def on_success_replace
503
504
  dirname, basename = ::File.split(name)
504
505
  ::Dir::Tmpname.create(basename, dirname) do |tmp_filename|
505
- begin
506
- if yield tmp_filename
507
- ::File.rename(tmp_filename, name)
508
- ::File.chmod(@file_permissions, name) unless @create
509
- end
510
- ensure
511
- ::File.unlink(tmp_filename) if ::File.exist?(tmp_filename)
506
+ if yield tmp_filename
507
+ ::File.rename(tmp_filename, name)
508
+ ::File.chmod(@file_permissions, name) unless @create
512
509
  end
510
+ ensure
511
+ FileUtils.rm_f(tmp_filename)
513
512
  end
514
513
  end
515
514
  end