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 +4 -4
- data/Changelog.md +11 -0
- data/README.md +33 -6
- data/lib/zip/constants.rb +1 -0
- data/lib/zip/crypto/aes_encryption.rb +102 -12
- data/lib/zip/crypto/encryption.rb +9 -0
- data/lib/zip/entry.rb +40 -5
- data/lib/zip/extra_field/aes.rb +1 -1
- data/lib/zip/file.rb +95 -3
- data/lib/zip/input_stream.rb +7 -5
- data/lib/zip/ioextras/abstract_input_stream.rb +16 -6
- data/lib/zip/ioextras/abstract_output_stream.rb +2 -0
- data/lib/zip/ioextras/fake_io.rb +39 -0
- data/lib/zip/ioextras.rb +2 -9
- data/lib/zip/output_stream.rb +14 -11
- data/lib/zip/pass_thru_compressor.rb +3 -2
- data/lib/zip/version.rb +1 -1
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a74cb8c2351c40e483f8f97f3197d4c7629d69b09e820233e7905840ba141f03
|
|
4
|
+
data.tar.gz: a5f7473b90231c59e65d0e2a72fe42f61c3a2ac80c9e3c04998ad29ecd86bf49
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
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
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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.
|
data/lib/zip/extra_field/aes.rb
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
16
|
-
#
|
|
17
|
-
#
|
|
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
|
|
data/lib/zip/input_stream.rb
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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:
|
|
@@ -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
|
-
|
|
35
|
-
|
|
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
|
data/lib/zip/output_stream.rb
CHANGED
|
@@ -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
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.
|
|
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.
|
|
200
|
-
documentation_uri: https://www.rubydoc.info/gems/rubyzip/3.
|
|
201
|
-
source_code_uri: https://github.com/rubyzip/rubyzip/tree/v3.
|
|
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: []
|