rubyzip 3.6.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8ad461af594aaafc83f2a9af47a7e72681431835864534c174970e2245748764
4
- data.tar.gz: 6c5bd535a0b049a606bd64ca90106e633c385926d91c32ca3db72314d2b031f0
3
+ metadata.gz: a74cb8c2351c40e483f8f97f3197d4c7629d69b09e820233e7905840ba141f03
4
+ data.tar.gz: a5f7473b90231c59e65d0e2a72fe42f61c3a2ac80c9e3c04998ad29ecd86bf49
5
5
  SHA512:
6
- metadata.gz: ecd014dee5b905fe5a85af4273e564a0abbdb9c75409a489363352999b2298bfa6baf311c4722c022ddd87807e402d2b9b03f54ec2c110a4cf8c882ed916d301
7
- data.tar.gz: 7711a4b6bd467e46d020b9d36b58ad726c0ff312d2247e49c9030cbdfb53d206dd5e16ddbb265f34f3f9802c728de06517926de352db7fac8f43ceafd3a3f496
6
+ metadata.gz: bca6294a1125d0af478c8e46b7cb53da2e1971c75a0994b68e35bddf897402eca9cb6dedb6d11b240c4b58177053de1aa442ba14aa0c303351b2d077f56ede74
7
+ data.tar.gz: 86c85eb799f2a89eeab405f758fffc31a707f1360bd135da0b16c05c1964e6b8fddc38eba512f23ac3531d807956a6f5e3a32fa2ca70b57ae1810a6a2569f4f5
data/Changelog.md CHANGED
@@ -1,3 +1,14 @@
1
+ # 3.7.0 (2026-09-18)
2
+
3
+ - Add AES Encryption. [#678](https://github.com/rubyzip/rubyzip/pull/678)
4
+ - Add methods to `Zip::File` for bulk adding and extraction of entries. [#679](https://github.com/rubyzip/rubyzip/pull/679)
5
+ - Support setting the encoding of an `InputStream`. [#668](https://github.com/rubyzip/rubyzip/pull/668)
6
+
7
+ Tooling/internal:
8
+
9
+ - Add an AGENTS.md for those using such.
10
+ - Attempt to fix transient failures in encryption tests.
11
+
1
12
  # 3.6.0 (2026-09-01)
2
13
 
3
14
  - Forward options from OutputStream.open without a block. [#674](https://github.com/rubyzip/rubyzip/pull/674)
data/README.md CHANGED
@@ -118,7 +118,17 @@ Note that there are some extra fields that cannot be suppressed at all (e.g. `:a
118
118
 
119
119
  ### Zipping a directory recursively
120
120
 
121
- Copy from [here](https://github.com/rubyzip/rubyzip/blob/9d891f7353e66052283562d3e252fe380bb4b199/samples/example_recursive.rb)
121
+ `Zip::File#add_recursive` adds the contents of a directory (not the directory itself) to an archive, recursing into subdirectories:
122
+
123
+ ```ruby
124
+ Zip::File.open('archive.zip', create: true) do |zipfile|
125
+ zipfile.add_recursive('/path/to/directory')
126
+ end
127
+ ```
128
+
129
+ Pass `prefix:` (default `''`) to nest the contents under a prefix inside the archive, and `max_depth:` (default `16`) to limit how many directory levels are walked. Symlinks are always skipped (ignored, with a warning), rather than followed or added.
130
+
131
+ For more manual control, here is a hand-rolled version of the same thing:
122
132
 
123
133
  ```ruby
124
134
  require 'zip'
@@ -229,6 +239,16 @@ Zip::File.open('foo.zip') do |zip_file|
229
239
  end
230
240
  ```
231
241
 
242
+ To extract every entry in an archive at once, preserving its directory structure, use `Zip::File#extract_all`:
243
+
244
+ ```ruby
245
+ Zip::File.open('foo.zip') do |zip_file|
246
+ zip_file.extract_all('/path/to/destination')
247
+ end
248
+ ```
249
+
250
+ As with `extract`, existing files at the destination will raise `Zip::DestinationExistsError` unless a block is passed to resolve the conflict (see [Existing Files](#existing-files) below). Symlink entries are always skipped (ignored, with a warning), rather than extracted.
251
+
232
252
  ### Reading a Zip file with `Zip::InputStream`
233
253
 
234
254
  `Zip::InputStream` can be used for faster reading of zip file content because it does not read the Central directory up front.
@@ -251,9 +271,9 @@ end # The `InputStream` is closed at the end of the block.
251
271
 
252
272
  Any attempt to move about in a zip file opened with `Zip::InputStream` could result in the incorrect entry being accessed and/or Zlib buffer errors. If you need random access in a zip file, use `Zip::File`.
253
273
 
254
- ### Password Protection (experimental)
274
+ ### Encryption and Password Protection (experimental)
255
275
 
256
- Rubyzip supports reading zip files with AES encryption (version 3.1 and later), and reading and writing zip files with traditional zip encryption (a.k.a. "ZipCrypto"). Encryption is currently only available with the stream API, with either files or buffers, e.g.:
276
+ Rubyzip supports reading and writing zip files with AES encryption (reading: version 3.1 and later; writing: version 3.7 and later), and reading and writing zip files with traditional zip encryption (a.k.a. "ZipCrypto"). Encryption is currently only available with the stream API, with either files or buffers, e.g.:
257
277
 
258
278
  #### Version 2.x (ZipCrypto only)
259
279
 
@@ -274,9 +294,16 @@ Zip::InputStream.open(buffer, 0, dec) do |input|
274
294
  end
275
295
  ```
276
296
 
277
- #### Version 3.x (AES reading and ZipCrypto read/write)
297
+ #### Version 3.x (AES and ZipCrypto)
278
298
 
279
299
  ```ruby
300
+ # Writing AES, version 3.7 and later.
301
+ enc = Zip::AESEncrypter.new('password', Zip::AESEncryption::STRENGTH_256_BIT)
302
+ Zip::OutputStream.open('aes-encrypted-file.zip', encrypter: enc) do |output|
303
+ output.put_next_entry('my_file.txt')
304
+ output.write my_data
305
+ end
306
+
280
307
  # Reading AES, version 3.1 and later.
281
308
  dec = Zip::AESDecrypter.new('password', Zip::AESEncryption::STRENGTH_256_BIT)
282
309
  Zip::InputStream.open('aes-encrypted-file.zip', decrypter: dec) do |input|
@@ -285,14 +312,14 @@ Zip::InputStream.open('aes-encrypted-file.zip', decrypter: dec) do |input|
285
312
  puts input.read
286
313
  end
287
314
 
288
- # Writing.
315
+ # Writing ZipCrypto.
289
316
  enc = Zip::TraditionalEncrypter.new('password')
290
317
  buffer = Zip::OutputStream.write_buffer(encrypter: enc) do |output|
291
318
  output.put_next_entry("my_file.txt")
292
319
  output.write my_data
293
320
  end
294
321
 
295
- # Reading.
322
+ # Reading ZipCrypto.
296
323
  dec = Zip::TraditionalDecrypter.new('password')
297
324
  Zip::InputStream.open(buffer, decrypter: dec) do |input|
298
325
  entry = input.get_next_entry
data/lib/zip/constants.rb CHANGED
@@ -14,6 +14,7 @@ module Zip
14
14
  VERSION_MADE_BY = 52 # this library's version
15
15
  VERSION_NEEDED_TO_EXTRACT = 20
16
16
  VERSION_NEEDED_TO_EXTRACT_ZIP64 = 45
17
+ VERSION_NEEDED_TO_EXTRACT_AES = 51
17
18
 
18
19
  SPLIT_FILE_SIGNATURE = 0x08074b50
19
20
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'securerandom'
4
+
3
5
  module Zip
4
6
  module AESEncryption # :nodoc:
5
7
  VERIFIER_LENGTH = 2
@@ -62,6 +64,105 @@ module Zip
62
64
  def gp_flags
63
65
  0x0001
64
66
  end
67
+
68
+ private
69
+
70
+ # Derive the encryption key, HMAC key and password-verification value
71
+ # from the password and a salt, as specified by the WinZip AES format.
72
+ def derive_keys(salt)
73
+ raise Error, "Unsupported encryption AES-#{@bits}" unless STRENGTHS.include? @strength
74
+
75
+ key_material = OpenSSL::KDF.pbkdf2_hmac(
76
+ @password,
77
+ salt: salt,
78
+ iterations: 1000,
79
+ length: (2 * @key_length) + VERIFIER_LENGTH,
80
+ hash: 'sha1'
81
+ )
82
+
83
+ [
84
+ key_material[0...@key_length],
85
+ key_material[@key_length...(2 * @key_length)],
86
+ key_material[-VERIFIER_LENGTH..]
87
+ ]
88
+ end
89
+ end
90
+
91
+ class AESEncrypter < Encrypter # :nodoc:
92
+ include AESEncryption
93
+
94
+ def header(_mtime)
95
+ @salt + @pwd_verify
96
+ end
97
+
98
+ # `Deflater`/`PassThruCompressor` call this with whatever, arbitrarily
99
+ # sized (and not necessarily block-aligned) buffer they happen to have
100
+ # flushed, potentially many times per entry. Only whole 16-byte blocks
101
+ # are actually run through the cipher here; any trailing partial block
102
+ # is buffered in `@pending` until either more data completes it or
103
+ # `trailer` forces the final flush. This keeps the CTR counter aligned
104
+ # with the true byte offset in the plaintext stream regardless of how
105
+ # callers happen to chunk their writes.
106
+ def encrypt(data)
107
+ @pending << data
108
+ encrypted_data = encrypt_blocks
109
+ @hmac.update(encrypted_data)
110
+ encrypted_data
111
+ end
112
+
113
+ def data_descriptor(*)
114
+ ''
115
+ end
116
+
117
+ def trailer
118
+ encrypted_data = encrypt_blocks(final: true)
119
+ @hmac.update(encrypted_data)
120
+ encrypted_data + @hmac.digest[0...AUTHENTICATION_CODE_LENGTH]
121
+ end
122
+
123
+ def crc(_computed_crc)
124
+ 0
125
+ end
126
+
127
+ def reset!
128
+ @salt = SecureRandom.random_bytes(@salt_length)
129
+ enc_key, enc_hmac_key, @pwd_verify = derive_keys(@salt)
130
+
131
+ @counter = 0
132
+ @pending = +''.b
133
+ @cipher = OpenSSL::Cipher::AES.new(@bits, :CTR)
134
+ @cipher.encrypt
135
+ @cipher.key = enc_key
136
+ @hmac = OpenSSL::HMAC.new(enc_hmac_key, OpenSSL::Digest.new('SHA1'))
137
+ end
138
+
139
+ def prepare_entry(entry)
140
+ entry.prep_aes_extra(AESEncryption::VERSION_AE_2, @strength)
141
+ end
142
+
143
+ private
144
+
145
+ def encrypt_blocks(final: false)
146
+ length = final ? @pending.bytesize : (@pending.bytesize / BLOCK_SIZE) * BLOCK_SIZE
147
+ return '' if length.zero?
148
+
149
+ data = @pending.slice!(0, length)
150
+ encrypted_data = +''.b
151
+ offset = 0
152
+
153
+ while offset < data.bytesize
154
+ @cipher.iv = [@counter + 1].pack('Vx12')
155
+ encrypted_data << @cipher.update(data[offset, BLOCK_SIZE])
156
+ @counter += 1
157
+ offset += BLOCK_SIZE
158
+ end
159
+
160
+ # JRuby requires finalization of the cipher when the last block fed to
161
+ # it is a partial one. This is a bug, as noted in
162
+ # jruby/jruby-openssl#182 and jruby/jruby-openssl#183.
163
+ encrypted_data << @cipher.final if final && defined?(JRUBY_VERSION)
164
+ encrypted_data
165
+ end
65
166
  end
66
167
 
67
168
  class AESDecrypter < Decrypter # :nodoc:
@@ -91,20 +192,9 @@ module Zip
91
192
  end
92
193
 
93
194
  def reset!(header)
94
- raise Error, "Unsupported encryption AES-#{@bits}" unless STRENGTHS.include? @strength
95
-
96
195
  salt = header[0...@salt_length]
97
196
  pwd_verify = header[-VERIFIER_LENGTH..]
98
- key_material = OpenSSL::KDF.pbkdf2_hmac(
99
- @password,
100
- salt: salt,
101
- iterations: 1000,
102
- length: (2 * @key_length) + VERIFIER_LENGTH,
103
- hash: 'sha1'
104
- )
105
- enc_key = key_material[0...@key_length]
106
- enc_hmac_key = key_material[@key_length...(2 * @key_length)]
107
- enc_pwd_verify = key_material[-VERIFIER_LENGTH..]
197
+ enc_key, enc_hmac_key, enc_pwd_verify = derive_keys(salt)
108
198
 
109
199
  raise Error, 'Bad password' if enc_pwd_verify != pwd_verify
110
200
 
@@ -2,6 +2,15 @@
2
2
 
3
3
  module Zip
4
4
  class Encrypter # :nodoc:all
5
+ def trailer
6
+ ''
7
+ end
8
+
9
+ def crc(computed_crc)
10
+ computed_crc
11
+ end
12
+
13
+ def prepare_entry(_entry); end
5
14
  end
6
15
 
7
16
  class Decrypter # :nodoc:all
data/lib/zip/entry.rb CHANGED
@@ -208,6 +208,21 @@ module Zip
208
208
  !@extra[:aes].nil?
209
209
  end
210
210
 
211
+ # Called by `AESEncrypter#prepare_entry` before the local header is first
212
+ # written, so the extra field is in place before its size is measured.
213
+ # Directories have no content to encrypt, so they are left untouched.
214
+ def prep_aes_extra(vendor_version, strength) # :nodoc:
215
+ return if directory?
216
+
217
+ @version_needed_to_extract =
218
+ [@version_needed_to_extract, Zip::VERSION_NEEDED_TO_EXTRACT_AES].max
219
+ aes = @extra[:aes] || @extra.create(:aes)
220
+ aes.vendor_version = vendor_version
221
+ aes.vendor_id = 'AE'
222
+ aes.encryption_strength = strength
223
+ aes.compression_method = compression_method
224
+ end
225
+
211
226
  def file_type_is?(type) # :nodoc:
212
227
  ftype == type
213
228
  end
@@ -286,7 +301,16 @@ module Zip
286
301
  #
287
302
  # NB: The caller is responsible for making sure `destination_directory` is
288
303
  # safe, if it is passed.
289
- def extract(entry_path = @name, destination_directory: '.', &block)
304
+ #
305
+ # `create_parent_directories`, if true, creates any missing intermediate
306
+ # directories for a file entry before writing it. This defaults to
307
+ # false: a missing intermediate directory usually means an earlier
308
+ # entry (e.g. a symlink) was deliberately skipped as unsafe, and letting
309
+ # a later entry silently succeed by re-creating that path as a plain
310
+ # directory would mask that. Bulk operations such as `extract_all`,
311
+ # which have no such earlier-entry to skip past, opt into this.
312
+ def extract(entry_path = @name, destination_directory: '.',
313
+ create_parent_directories: false, &block)
290
314
  dest_dir = ::File.absolute_path(destination_directory || '.')
291
315
  extract_path = ::File.absolute_path(::File.join(dest_dir, entry_path))
292
316
 
@@ -299,6 +323,8 @@ module Zip
299
323
 
300
324
  raise "unknown file type #{inspect}" unless directory? || file? || symlink?
301
325
 
326
+ ::FileUtils.mkdir_p(::File.dirname(extract_path)) if create_parent_directories && file?
327
+
302
328
  __send__(:"create_#{ftype}", extract_path, &block)
303
329
  self
304
330
  end
@@ -396,7 +422,7 @@ module Zip
396
422
  [::Zip::LOCAL_ENTRY_SIGNATURE,
397
423
  @version_needed_to_extract, # version needed to extract
398
424
  @gp_flags, # @gp_flags
399
- compression_method,
425
+ stored_compression_method,
400
426
  @time.to_binary_dos_time, # @last_mod_time
401
427
  @time.to_binary_dos_date, # @last_mod_date
402
428
  @crc,
@@ -578,7 +604,7 @@ module Zip
578
604
  @fstype, # filesystem type
579
605
  @version_needed_to_extract, # @versionNeededToExtract
580
606
  @gp_flags, # @gp_flags
581
- compression_method,
607
+ stored_compression_method,
582
608
  @time.to_binary_dos_time, # @last_mod_time
583
609
  @time.to_binary_dos_date, # @last_mod_date
584
610
  @crc,
@@ -824,6 +850,13 @@ module Zip
824
850
  @compression_method = @extra[:aes].compression_method if ftype != :directory
825
851
  end
826
852
 
853
+ # AES-encrypted entries always store `COMPRESSION_METHOD_AES` (99) on the
854
+ # wire; the real compression method lives in the AES extra field instead
855
+ # (see `parse_aes_extra`, which reverses this on read).
856
+ def stored_compression_method # :nodoc:
857
+ aes? ? COMPRESSION_METHOD_AES : compression_method
858
+ end
859
+
827
860
  # For DEFLATED compression *only*: set the general purpose flags 1 and 2 to
828
861
  # indicate compression level. This seems to be mainly cosmetic but they are
829
862
  # generally set by other tools - including in docx files. It is these flags
@@ -853,7 +886,8 @@ module Zip
853
886
  # Might not know size here, so need ZIP64 just in case.
854
887
  # If we already have a ZIP64 extra (placeholder) then we must fill it in.
855
888
  if zip64? || @size.nil? || @size >= 0xFFFFFFFF || @compressed_size >= 0xFFFFFFFF
856
- @version_needed_to_extract = VERSION_NEEDED_TO_EXTRACT_ZIP64
889
+ @version_needed_to_extract =
890
+ [@version_needed_to_extract, VERSION_NEEDED_TO_EXTRACT_ZIP64].max
857
891
  zip64 = @extra[:zip64] || @extra.create(:zip64)
858
892
 
859
893
  # Local header always includes size and compressed size.
@@ -867,7 +901,8 @@ module Zip
867
901
 
868
902
  if (@size && @size >= 0xFFFFFFFF) || @compressed_size >= 0xFFFFFFFF ||
869
903
  @local_header_offset >= 0xFFFFFFFF
870
- @version_needed_to_extract = VERSION_NEEDED_TO_EXTRACT_ZIP64
904
+ @version_needed_to_extract =
905
+ [@version_needed_to_extract, VERSION_NEEDED_TO_EXTRACT_ZIP64].max
871
906
  zip64 = @extra[:zip64] || @extra.create(:zip64)
872
907
 
873
908
  # Central directory entry entries include whichever fields are necessary.
@@ -3,7 +3,7 @@
3
3
  module Zip
4
4
  # Info-ZIP Extra for AES encryption
5
5
  class ExtraField::AES < ExtraField::Generic # :nodoc:
6
- attr_reader :vendor_version, :vendor_id, :encryption_strength, :compression_method
6
+ attr_accessor :vendor_version, :vendor_id, :encryption_strength, :compression_method
7
7
 
8
8
  HEADER_ID = [0x9901].pack('v')
9
9
  register_map
data/lib/zip/file.rb CHANGED
@@ -12,9 +12,10 @@ module Zip
12
12
  # the archive and methods such as `get_input_stream` and
13
13
  # `get_output_stream` for reading from and writing entries to the
14
14
  # archive. The class includes a few convenience methods such as
15
- # `extract` for extracting entries to the filesystem, and `remove`,
16
- # `replace`, `rename` and `mkdir` for making simple modifications to
17
- # 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.
18
19
  #
19
20
  # Modifications to a zip archive are not committed until `commit` or
20
21
  # `close` is called. The method `open` accepts a block following
@@ -178,6 +179,28 @@ module Zip
178
179
  cdir.count_entries(path_or_io)
179
180
  end
180
181
  end
182
+
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.
186
+ #
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)
194
+ end
195
+ end
196
+
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)
202
+ end
203
+ end
181
204
  end
182
205
 
183
206
  # Returns an input stream to the specified entry. If a block is passed
@@ -252,6 +275,25 @@ module Zip
252
275
  add(entry, src_path, &continue_on_exists_proc)
253
276
  end
254
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
+
255
297
  # Removes the specified entry.
256
298
  def remove(entry)
257
299
  @cdir.delete(get_entry(entry))
@@ -286,6 +328,33 @@ module Zip
286
328
  found_entry.extract(entry_path, destination_directory: destination_directory, &block)
287
329
  end
288
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
356
+ end
357
+
289
358
  # Commits changes that has been made since the previous commit to
290
359
  # the zip archive.
291
360
  def commit
@@ -363,6 +432,29 @@ module Zip
363
432
 
364
433
  private
365
434
 
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
+
366
458
  def initialize_cdir(path_or_io, buffer: false)
367
459
  @cdir = ::Zip::CentralDirectory.new
368
460
 
@@ -51,8 +51,10 @@ module Zip
51
51
  #
52
52
  # @param context [String||IO||StringIO] file path or IO/StringIO object
53
53
  # @param offset [Integer] offset in the IO/StringIO
54
- def initialize(context, offset: 0, decrypter: nil)
55
- super()
54
+ # This method also accepts the standard IO encoding options:
55
+ # `external_encoding:`, `internal_encoding:` and `encoding:`.
56
+ def initialize(context, offset: 0, decrypter: nil, **opts)
57
+ super(**opts)
56
58
  @archive_io = get_io(context, offset)
57
59
  @decompressor = ::Zip::NullDecompressor
58
60
  @decrypter = decrypter
@@ -107,7 +109,7 @@ module Zip
107
109
  output = produce_input(maxlen)
108
110
 
109
111
  if out_string.nil?
110
- output.force_encoding(Encoding::ASCII_8BIT)
112
+ output.force_encoding(@internal_encoding || @external_encoding)
111
113
  else
112
114
  encoding = out_string.encoding
113
115
  out_string.replace(output).force_encoding(encoding)
@@ -125,8 +127,8 @@ module Zip
125
127
  # Same as #initialize but if a block is passed the opened
126
128
  # stream is passed to the block and closed when the block
127
129
  # returns.
128
- def open(filename_or_io, offset: 0, decrypter: nil)
129
- zio = new(filename_or_io, offset: offset, decrypter: decrypter)
130
+ def open(filename_or_io, offset: 0, decrypter: nil, **opts)
131
+ zio = new(filename_or_io, offset: offset, decrypter: decrypter, **opts)
130
132
  return zio unless block_given?
131
133
 
132
134
  begin
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'fake_io'
4
+
3
5
  module Zip
4
6
  module IOExtras # :nodoc:
5
7
  # Implements many of the convenience methods of IO
@@ -9,7 +11,11 @@ module Zip
9
11
  include Enumerable
10
12
  include FakeIO
11
13
 
12
- def initialize # :nodoc:
14
+ # Creates a new input stream wrapper.
15
+ #
16
+ # This method accepts the standard IO encoding options:
17
+ # `external_encoding:`, `internal_encoding:` and `encoding:`.
18
+ def initialize(**opts)
13
19
  super
14
20
  @lineno = 0
15
21
  @pos = 0
@@ -33,7 +39,7 @@ module Zip
33
39
  # containing the bytes read. The string's encoding is the unchanged
34
40
  # encoding of `out_string`, if `out_string` is given; `ASCII-8BIT`,
35
41
  # otherwise.
36
- def read(maxlen = nil, out_string = nil) # rubocop:disable Metrics/PerceivedComplexity
42
+ def read(maxlen = nil, out_string = nil) # rubocop:disable Metrics/PerceivedComplexity,Metrics/CyclomaticComplexity
37
43
  return (maxlen.nil? || maxlen.zero? ? '' : nil) if eof?
38
44
 
39
45
  tbuf = if @output_buffer.bytesize > 0
@@ -60,7 +66,7 @@ module Zip
60
66
  @pos += tbuf.length
61
67
 
62
68
  if out_string.nil?
63
- tbuf.force_encoding(Encoding::ASCII_8BIT)
69
+ tbuf.force_encoding(@internal_encoding || @external_encoding)
64
70
  else
65
71
  encoding = out_string.encoding
66
72
  out_string.replace(tbuf).force_encoding(encoding)
@@ -109,7 +115,7 @@ module Zip
109
115
  #
110
116
  # Optional keyword argument `chomp` specifies whether line separators
111
117
  # are to be omitted.
112
- def gets(sep = $INPUT_RECORD_SEPARATOR, limit = nil, chomp: false)
118
+ def gets(sep = $INPUT_RECORD_SEPARATOR, limit = nil, chomp: false) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
113
119
  if sep.nil?
114
120
  return nil if eof?
115
121
 
@@ -124,6 +130,8 @@ module Zip
124
130
  sep = "#{$INPUT_RECORD_SEPARATOR}#{$INPUT_RECORD_SEPARATOR}"
125
131
  end
126
132
 
133
+ encoding = @internal_encoding || @external_encoding
134
+
127
135
  buffer_index = 0
128
136
  while (sep_index = @output_buffer.index(sep, buffer_index)).nil?
129
137
  break if limit && @output_buffer.bytesize >= limit
@@ -133,7 +141,7 @@ module Zip
133
141
 
134
142
  @lineno = @lineno.next
135
143
  @pos += @output_buffer.bytesize
136
- return @output_buffer.slice!(0..)
144
+ return @output_buffer.slice!(0..).force_encoding(encoding)
137
145
  end
138
146
 
139
147
  buffer_index = [buffer_index, @output_buffer.bytesize - sep.bytesize].max
@@ -144,7 +152,9 @@ module Zip
144
152
  cut_index = sep_index ? [sep_index + sep.bytesize, limit].min : limit
145
153
  @lineno = @lineno.next
146
154
  @pos += cut_index
147
- chomp ? @output_buffer.slice!(0, cut_index).chomp(sep) : @output_buffer.slice!(0, cut_index)
155
+ data = @output_buffer.slice!(0, cut_index)
156
+ data&.chomp!(sep) if chomp
157
+ data&.force_encoding(encoding)
148
158
  end
149
159
 
150
160
  def ungetc(byte) # :nodoc:
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'fake_io'
4
+
3
5
  module Zip
4
6
  module IOExtras # :nodoc:
5
7
  # Implements many of the output convenience methods of IO.
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zip
4
+ module IOExtras # :nodoc:
5
+ module FakeIO # :nodoc:
6
+ attr_reader :external_encoding, :internal_encoding
7
+
8
+ def initialize(**opts)
9
+ @internal_encoding = Encoding.find(opts[:internal_encoding]) if opts[:internal_encoding]
10
+
11
+ if opts[:encoding].kind_of?(String)
12
+ _ext, int = opts[:encoding].split(':', 2)
13
+ @internal_encoding = Encoding.find(int) unless @internal_encoding || int.nil?
14
+ end
15
+
16
+ @external_encoding = Encoding::ASCII_8BIT
17
+ end
18
+
19
+ def set_encoding(ext_enc, int_enc = nil)
20
+ if ext_enc.kind_of?(String) && ext_enc.include?(':')
21
+ _ext_enc, int_enc = ext_enc.split(':', 2)
22
+ end
23
+
24
+ begin
25
+ @internal_encoding = Encoding.find(int_enc) if int_enc
26
+ rescue ArgumentError
27
+ warn "warning: Unsupported encoding #{int_enc} ignored"
28
+ end
29
+
30
+ @external_encoding = Encoding::ASCII_8BIT
31
+ end
32
+
33
+ # Implement kind_of? in order to pretend to be an IO object.
34
+ def kind_of?(object)
35
+ object == IO || super
36
+ end
37
+ end
38
+ end
39
+ end
data/lib/zip/ioextras.rb CHANGED
@@ -21,18 +21,11 @@ module Zip
21
21
  end
22
22
  end
23
23
  end
24
-
25
- # Implements kind_of? in order to pretend to be an IO object
26
- module FakeIO # :nodoc:
27
- def kind_of?(object)
28
- object == IO || super
29
- end
30
- end
31
24
  end
32
25
  end
33
26
 
34
- require 'zip/ioextras/abstract_input_stream'
35
- require 'zip/ioextras/abstract_output_stream'
27
+ require_relative 'ioextras/abstract_input_stream'
28
+ require_relative 'ioextras/abstract_output_stream'
36
29
 
37
30
  # Copyright (C) 2002-2004 Thomas Sondergaard
38
31
  # rubyzip is free software; you can redistribute it and/or
@@ -29,8 +29,8 @@ module Zip
29
29
 
30
30
  # Opens the indicated zip file. If a file with that name already
31
31
  # exists it will be overwritten.
32
- def initialize(file_name, stream: false, encrypter: nil, suppress_extra_fields: false)
33
- super()
32
+ def initialize(file_name, stream: false, encrypter: nil, suppress_extra_fields: false, **opts)
33
+ super(**opts)
34
34
  @file_name = file_name
35
35
  @output_stream = if stream
36
36
  iostream = Zip::RUNNING_ON_WINDOWS ? @file_name : @file_name.dup
@@ -52,27 +52,28 @@ module Zip
52
52
  # Same as #initialize but if a block is passed the opened
53
53
  # stream is passed to the block and closed when the block
54
54
  # returns.
55
- def open(file_name, encrypter: nil, suppress_extra_fields: false)
55
+ def open(file_name, encrypter: nil, suppress_extra_fields: false, **opts)
56
56
  unless block_given?
57
57
  return new(
58
58
  file_name,
59
59
  encrypter: encrypter,
60
- suppress_extra_fields: suppress_extra_fields
60
+ suppress_extra_fields: suppress_extra_fields,
61
+ **opts
61
62
  )
62
63
  end
63
64
 
64
65
  zos = new(file_name, stream: false, encrypter: encrypter,
65
- suppress_extra_fields: suppress_extra_fields)
66
+ suppress_extra_fields: suppress_extra_fields, **opts)
66
67
  yield zos
67
68
  ensure
68
69
  zos.close if zos
69
70
  end
70
71
 
71
72
  # Same as #open but writes to a filestream instead
72
- def write_buffer(io = ::StringIO.new, encrypter: nil, suppress_extra_fields: false)
73
+ def write_buffer(io = ::StringIO.new, encrypter: nil, suppress_extra_fields: false, **opts)
73
74
  io.binmode if io.respond_to?(:binmode)
74
75
  zos = new(io, stream: true, encrypter: encrypter,
75
- suppress_extra_fields: suppress_extra_fields)
76
+ suppress_extra_fields: suppress_extra_fields, **opts)
76
77
  yield zos
77
78
  zos.close_buffer
78
79
  end
@@ -148,27 +149,29 @@ module Zip
148
149
  return unless @current_entry
149
150
 
150
151
  finish
152
+ @output_stream << @encrypter.trailer unless @current_entry.directory?
151
153
  @current_entry.compressed_size = @output_stream.tell -
152
154
  @current_entry.local_header_offset -
153
155
  @current_entry.calculate_local_header_size
154
156
  @current_entry.size = @compressor.size
155
- @current_entry.crc = @compressor.crc
157
+ @current_entry.crc = @encrypter.crc(@compressor.crc)
156
158
  @output_stream << @encrypter.data_descriptor(
157
159
  @current_entry.crc,
158
160
  @current_entry.compressed_size,
159
161
  @current_entry.size
160
162
  )
161
- @current_entry.gp_flags |= @encrypter.gp_flags
163
+ @current_entry.gp_flags |= @encrypter.gp_flags unless @current_entry.directory?
162
164
  @current_entry = nil
163
165
  @compressor = ::Zip::NullCompressor.instance
164
166
  end
165
167
 
166
168
  def init_next_entry(entry)
167
169
  finalize_current_entry
170
+ @encrypter.prepare_entry(entry)
168
171
  @cdir << entry
169
172
  entry.write_local_entry(@output_stream, suppress_extra_fields: @suppress_extra_fields)
170
173
  @encrypter.reset!
171
- @output_stream << @encrypter.header(entry.mtime)
174
+ @output_stream << @encrypter.header(entry.mtime) unless entry.directory?
172
175
  @compressor = get_compressor(entry)
173
176
  end
174
177
 
@@ -177,7 +180,7 @@ module Zip
177
180
  when Entry::DEFLATED
178
181
  ::Zip::Deflater.new(@output_stream, entry.compression_level, @encrypter)
179
182
  when Entry::STORED
180
- ::Zip::PassThruCompressor.new(@output_stream)
183
+ ::Zip::PassThruCompressor.new(@output_stream, @encrypter)
181
184
  else
182
185
  raise ::Zip::CompressionMethodError, entry.compression_method
183
186
  end
@@ -2,9 +2,10 @@
2
2
 
3
3
  module Zip
4
4
  class PassThruCompressor < Compressor # :nodoc:all
5
- def initialize(output_stream)
5
+ def initialize(output_stream, encrypter = NullEncrypter.new)
6
6
  super()
7
7
  @output_stream = output_stream
8
+ @encrypter = encrypter
8
9
  @crc = Zlib.crc32
9
10
  @size = 0
10
11
  end
@@ -13,7 +14,7 @@ module Zip
13
14
  val = data.to_s
14
15
  @crc = Zlib.crc32(val, @crc)
15
16
  @size += val.bytesize
16
- @output_stream << val
17
+ @output_stream << @encrypter.encrypt(val)
17
18
  end
18
19
 
19
20
  attr_reader :size, :crc
data/lib/zip/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Zip
4
4
  # The version of the Rubyzip library.
5
- VERSION = '3.6.0'
5
+ VERSION = '3.7.0'
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubyzip
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.6.0
4
+ version: 3.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Robert Haines
@@ -174,6 +174,7 @@ files:
174
174
  - lib/zip/ioextras.rb
175
175
  - lib/zip/ioextras/abstract_input_stream.rb
176
176
  - lib/zip/ioextras/abstract_output_stream.rb
177
+ - lib/zip/ioextras/fake_io.rb
177
178
  - lib/zip/null_compressor.rb
178
179
  - lib/zip/null_decompressor.rb
179
180
  - lib/zip/null_input_stream.rb
@@ -196,9 +197,9 @@ licenses:
196
197
  - BSD-2-Clause
197
198
  metadata:
198
199
  bug_tracker_uri: https://github.com/rubyzip/rubyzip/issues
199
- changelog_uri: https://github.com/rubyzip/rubyzip/blob/v3.6.0/Changelog.md
200
- documentation_uri: https://www.rubydoc.info/gems/rubyzip/3.6.0
201
- source_code_uri: https://github.com/rubyzip/rubyzip/tree/v3.6.0
200
+ changelog_uri: https://github.com/rubyzip/rubyzip/blob/v3.7.0/Changelog.md
201
+ documentation_uri: https://www.rubydoc.info/gems/rubyzip/3.7.0
202
+ source_code_uri: https://github.com/rubyzip/rubyzip/tree/v3.7.0
202
203
  wiki_uri: https://github.com/rubyzip/rubyzip/wiki
203
204
  rubygems_mfa_required: 'true'
204
205
  rdoc_options: []