rubyzip 3.5.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: 127a7ca4438d6d96f18b7f9699d47491e416ddcc6dbcc0bc8f83f8335a2d516c
4
- data.tar.gz: f305dbf4d5f909f1218f11c9400cf41b5645a20233ccdfec8b4416a533d558de
3
+ metadata.gz: a74cb8c2351c40e483f8f97f3197d4c7629d69b09e820233e7905840ba141f03
4
+ data.tar.gz: a5f7473b90231c59e65d0e2a72fe42f61c3a2ac80c9e3c04998ad29ecd86bf49
5
5
  SHA512:
6
- metadata.gz: c7aaa339ad47862c661b1f284cb1bd38ae65583fb623b85307e20b79f3d871fb3d87254b3a183aee3ba52a16cf8865ed88118919f8997d966384a60c98141234
7
- data.tar.gz: d19bb9446945b5d5612e2b3c10dff6727f32d638fe738829ac7bcf5929d20e429683e76a3cc84543ae607c6e2d3941fcf104357e9d3d52670e0547667e43c849
6
+ metadata.gz: bca6294a1125d0af478c8e46b7cb53da2e1971c75a0994b68e35bddf897402eca9cb6dedb6d11b240c4b58177053de1aa442ba14aa0c303351b2d077f56ede74
7
+ data.tar.gz: 86c85eb799f2a89eeab405f758fffc31a707f1360bd135da0b16c05c1964e6b8fddc38eba512f23ac3531d807956a6f5e3a32fa2ca70b57ae1810a6a2569f4f5
data/Changelog.md CHANGED
@@ -1,3 +1,27 @@
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
+
12
+ # 3.6.0 (2026-09-01)
13
+
14
+ - Forward options from OutputStream.open without a block. [#674](https://github.com/rubyzip/rubyzip/pull/674)
15
+ - Clamp DOS date and time to the range the fields can hold. [#671](https://github.com/rubyzip/rubyzip/pull/671)
16
+
17
+ Tooling/internal:
18
+
19
+ - Unix owner ids no longer default to 0 (root).
20
+ - Legacy Unix owner ids no longer default to 0 (root).
21
+ - Handle short reads in `copy_stream_n`. [#675](https://github.com/rubyzip/rubyzip/pull/675)
22
+ - Retain required Zip64 offset fields. [#676](https://github.com/rubyzip/rubyzip/pull/676)
23
+ - Load openssl only when AES encryption is used. [#672](https://github.com/rubyzip/rubyzip/pull/672)
24
+
1
25
  # 3.5.0 (2026-08-18)
2
26
 
3
27
  - Fix the link to Ruby doc in README to the latest version. [#670](https://github.com/rubyzip/rubyzip/pull/670)
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,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'openssl'
3
+ require 'securerandom'
4
4
 
5
5
  module Zip
6
6
  module AESEncryption # :nodoc:
@@ -45,6 +45,11 @@ module Zip
45
45
  }.freeze
46
46
 
47
47
  def initialize(password, strength)
48
+ # Loaded here rather than at the top of the file so that `require 'zip'`
49
+ # does not pull in openssl. Only AES-encrypted archives need it, and it
50
+ # is the single largest cost of loading this library.
51
+ require 'openssl'
52
+
48
53
  @password = password
49
54
  @strength = strength
50
55
  @bits = BITS[@strength]
@@ -59,6 +64,105 @@ module Zip
59
64
  def gp_flags
60
65
  0x0001
61
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
62
166
  end
63
167
 
64
168
  class AESDecrypter < Decrypter # :nodoc:
@@ -88,20 +192,9 @@ module Zip
88
192
  end
89
193
 
90
194
  def reset!(header)
91
- raise Error, "Unsupported encryption AES-#{@bits}" unless STRENGTHS.include? @strength
92
-
93
195
  salt = header[0...@salt_length]
94
196
  pwd_verify = header[-VERIFIER_LENGTH..]
95
- key_material = OpenSSL::KDF.pbkdf2_hmac(
96
- @password,
97
- salt: salt,
98
- iterations: 1000,
99
- length: (2 * @key_length) + VERIFIER_LENGTH,
100
- hash: 'sha1'
101
- )
102
- enc_key = key_material[0...@key_length]
103
- enc_hmac_key = key_material[@key_length...(2 * @key_length)]
104
- enc_pwd_verify = key_material[-VERIFIER_LENGTH..]
197
+ enc_key, enc_hmac_key, enc_pwd_verify = derive_keys(salt)
105
198
 
106
199
  raise Error, 'Bad password' if enc_pwd_verify != pwd_verify
107
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/dos_time.rb CHANGED
@@ -16,6 +16,18 @@ module Zip
16
16
  # bits 5-8 month (1-12)
17
17
  # bits 9-15 year (four digit year minus 1980)
18
18
 
19
+ # The MS-DOS date field is seven bits of "year minus 1980", so only
20
+ # 1980-01-01 00:00:00 through 2107-12-31 23:59:58 can be represented.
21
+ # Times outside that are clamped rather than allowed to wrap: the real
22
+ # time is still carried in the universal time (UT) extra field, but the
23
+ # DOS fields have to stay in range for readers that only look at those.
24
+ DOS_EPOCH_YEAR = 1980
25
+ DOS_MAX_YEAR = 2107
26
+ DOS_MIN_DATE = 0x0021 # 1980-01-01
27
+ DOS_MAX_DATE = 0xff9f # 2107-12-31
28
+ DOS_MIN_TIME = 0x0000 # 00:00:00
29
+ DOS_MAX_TIME = 0xbf7d # 23:59:58
30
+
19
31
  attr_writer :absolute_time # :nodoc:
20
32
 
21
33
  def absolute_time?
@@ -25,15 +37,21 @@ module Zip
25
37
  end
26
38
 
27
39
  def to_binary_dos_time
40
+ return DOS_MIN_TIME if year < DOS_EPOCH_YEAR
41
+ return DOS_MAX_TIME if year > DOS_MAX_YEAR
42
+
28
43
  (sec / 2) +
29
44
  (min << 5) +
30
45
  (hour << 11)
31
46
  end
32
47
 
33
48
  def to_binary_dos_date
49
+ return DOS_MIN_DATE if year < DOS_EPOCH_YEAR
50
+ return DOS_MAX_DATE if year > DOS_MAX_YEAR
51
+
34
52
  day +
35
53
  (month << 5) +
36
- ((year - 1980) << 9)
54
+ ((year - DOS_EPOCH_YEAR) << 9)
37
55
  end
38
56
 
39
57
  # Deprecated. Remove for version 4.
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
@@ -7,8 +7,8 @@ module Zip
7
7
  register_map
8
8
 
9
9
  def initialize(binstr = nil)
10
- @uid = 0
11
- @gid = 0
10
+ @uid = nil
11
+ @gid = nil
12
12
  @atime = nil
13
13
  @mtime = nil
14
14
  binstr && merge(binstr)
@@ -7,8 +7,8 @@ module Zip
7
7
  register_map
8
8
 
9
9
  def initialize(binstr = nil)
10
- @uid = 0
11
- @gid = 0
10
+ @uid = nil
11
+ @gid = nil
12
12
  binstr && merge(binstr)
13
13
  end
14
14
 
@@ -59,8 +59,8 @@ module Zip
59
59
  # We can suppress the zip64 extra field unless we know the size is large or
60
60
  # the relative header offset is large (for central directory entries).
61
61
  def suppress?
62
- !(@original_size && @original_size >= 0xFFFFFFFF) ||
63
- (@relative_header_offset && @relative_header_offset >= 0xFFFFFFFF)
62
+ !((@original_size && @original_size >= 0xFFFFFFFF) ||
63
+ (@relative_header_offset && @relative_header_offset >= 0xFFFFFFFF))
64
64
  end
65
65
 
66
66
  def pack_for_local
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
@@ -13,23 +13,19 @@ module Zip
13
13
  toread = nbytes
14
14
  while toread > 0 && !istream.eof?
15
15
  tr = [toread, CHUNK_SIZE].min
16
- ostream.write(istream.read(tr, +''))
17
- toread -= tr
18
- end
19
- end
20
- end
16
+ chunk = istream.read(tr, +'')
17
+ break if !chunk || chunk.empty?
21
18
 
22
- # Implements kind_of? in order to pretend to be an IO object
23
- module FakeIO # :nodoc:
24
- def kind_of?(object)
25
- object == IO || super
19
+ ostream.write(chunk)
20
+ toread -= chunk.bytesize
21
+ end
26
22
  end
27
23
  end
28
24
  end
29
25
  end
30
26
 
31
- require 'zip/ioextras/abstract_input_stream'
32
- require 'zip/ioextras/abstract_output_stream'
27
+ require_relative 'ioextras/abstract_input_stream'
28
+ require_relative 'ioextras/abstract_output_stream'
33
29
 
34
30
  # Copyright (C) 2002-2004 Thomas Sondergaard
35
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,21 +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)
56
- return new(file_name) unless block_given?
55
+ def open(file_name, encrypter: nil, suppress_extra_fields: false, **opts)
56
+ unless block_given?
57
+ return new(
58
+ file_name,
59
+ encrypter: encrypter,
60
+ suppress_extra_fields: suppress_extra_fields,
61
+ **opts
62
+ )
63
+ end
57
64
 
58
65
  zos = new(file_name, stream: false, encrypter: encrypter,
59
- suppress_extra_fields: suppress_extra_fields)
66
+ suppress_extra_fields: suppress_extra_fields, **opts)
60
67
  yield zos
61
68
  ensure
62
69
  zos.close if zos
63
70
  end
64
71
 
65
72
  # Same as #open but writes to a filestream instead
66
- 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)
67
74
  io.binmode if io.respond_to?(:binmode)
68
75
  zos = new(io, stream: true, encrypter: encrypter,
69
- suppress_extra_fields: suppress_extra_fields)
76
+ suppress_extra_fields: suppress_extra_fields, **opts)
70
77
  yield zos
71
78
  zos.close_buffer
72
79
  end
@@ -142,27 +149,29 @@ module Zip
142
149
  return unless @current_entry
143
150
 
144
151
  finish
152
+ @output_stream << @encrypter.trailer unless @current_entry.directory?
145
153
  @current_entry.compressed_size = @output_stream.tell -
146
154
  @current_entry.local_header_offset -
147
155
  @current_entry.calculate_local_header_size
148
156
  @current_entry.size = @compressor.size
149
- @current_entry.crc = @compressor.crc
157
+ @current_entry.crc = @encrypter.crc(@compressor.crc)
150
158
  @output_stream << @encrypter.data_descriptor(
151
159
  @current_entry.crc,
152
160
  @current_entry.compressed_size,
153
161
  @current_entry.size
154
162
  )
155
- @current_entry.gp_flags |= @encrypter.gp_flags
163
+ @current_entry.gp_flags |= @encrypter.gp_flags unless @current_entry.directory?
156
164
  @current_entry = nil
157
165
  @compressor = ::Zip::NullCompressor.instance
158
166
  end
159
167
 
160
168
  def init_next_entry(entry)
161
169
  finalize_current_entry
170
+ @encrypter.prepare_entry(entry)
162
171
  @cdir << entry
163
172
  entry.write_local_entry(@output_stream, suppress_extra_fields: @suppress_extra_fields)
164
173
  @encrypter.reset!
165
- @output_stream << @encrypter.header(entry.mtime)
174
+ @output_stream << @encrypter.header(entry.mtime) unless entry.directory?
166
175
  @compressor = get_compressor(entry)
167
176
  end
168
177
 
@@ -171,7 +180,7 @@ module Zip
171
180
  when Entry::DEFLATED
172
181
  ::Zip::Deflater.new(@output_stream, entry.compression_level, @encrypter)
173
182
  when Entry::STORED
174
- ::Zip::PassThruCompressor.new(@output_stream)
183
+ ::Zip::PassThruCompressor.new(@output_stream, @encrypter)
175
184
  else
176
185
  raise ::Zip::CompressionMethodError, entry.compression_method
177
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.5.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.5.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.5.0/Changelog.md
200
- documentation_uri: https://www.rubydoc.info/gems/rubyzip/3.5.0
201
- source_code_uri: https://github.com/rubyzip/rubyzip/tree/v3.5.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: []