rubyzip 2.3.2 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/Changelog.md +429 -0
  3. data/LICENSE.md +24 -0
  4. data/README.md +138 -41
  5. data/Rakefile +11 -7
  6. data/lib/zip/central_directory.rb +169 -123
  7. data/lib/zip/compressor.rb +3 -1
  8. data/lib/zip/constants.rb +29 -21
  9. data/lib/zip/crypto/decrypted_io.rb +4 -2
  10. data/lib/zip/crypto/encryption.rb +4 -2
  11. data/lib/zip/crypto/null_encryption.rb +6 -4
  12. data/lib/zip/crypto/traditional_encryption.rb +8 -6
  13. data/lib/zip/decompressor.rb +4 -3
  14. data/lib/zip/deflater.rb +10 -8
  15. data/lib/zip/dirtyable.rb +32 -0
  16. data/lib/zip/dos_time.rb +51 -5
  17. data/lib/zip/entry.rb +364 -222
  18. data/lib/zip/entry_set.rb +11 -9
  19. data/lib/zip/errors.rb +136 -16
  20. data/lib/zip/extra_field/generic.rb +6 -13
  21. data/lib/zip/extra_field/ntfs.rb +6 -4
  22. data/lib/zip/extra_field/old_unix.rb +3 -1
  23. data/lib/zip/extra_field/universal_time.rb +3 -1
  24. data/lib/zip/extra_field/unix.rb +5 -3
  25. data/lib/zip/extra_field/unknown.rb +33 -0
  26. data/lib/zip/extra_field/zip64.rb +12 -5
  27. data/lib/zip/extra_field.rb +16 -22
  28. data/lib/zip/file.rb +178 -223
  29. data/lib/zip/file_split.rb +91 -0
  30. data/lib/zip/filesystem/dir.rb +86 -0
  31. data/lib/zip/filesystem/directory_iterator.rb +48 -0
  32. data/lib/zip/filesystem/file.rb +262 -0
  33. data/lib/zip/filesystem/file_stat.rb +110 -0
  34. data/lib/zip/filesystem/zip_file_name_mapper.rb +81 -0
  35. data/lib/zip/filesystem.rb +27 -596
  36. data/lib/zip/inflater.rb +6 -4
  37. data/lib/zip/input_stream.rb +50 -37
  38. data/lib/zip/ioextras/abstract_input_stream.rb +15 -10
  39. data/lib/zip/ioextras/abstract_output_stream.rb +5 -3
  40. data/lib/zip/ioextras.rb +7 -7
  41. data/lib/zip/null_compressor.rb +3 -1
  42. data/lib/zip/null_decompressor.rb +3 -1
  43. data/lib/zip/null_input_stream.rb +3 -1
  44. data/lib/zip/output_stream.rb +53 -47
  45. data/lib/zip/pass_thru_compressor.rb +3 -1
  46. data/lib/zip/pass_thru_decompressor.rb +4 -2
  47. data/lib/zip/streamable_directory.rb +3 -1
  48. data/lib/zip/streamable_stream.rb +3 -0
  49. data/lib/zip/version.rb +3 -1
  50. data/lib/zip.rb +19 -3
  51. data/rubyzip.gemspec +39 -0
  52. data/samples/example.rb +8 -3
  53. data/samples/example_filesystem.rb +3 -2
  54. data/samples/example_recursive.rb +3 -1
  55. data/samples/gtk_ruby_zip.rb +4 -2
  56. data/samples/qtzip.rb +6 -5
  57. data/samples/write_simple.rb +2 -1
  58. data/samples/zipfind.rb +1 -0
  59. metadata +83 -49
  60. data/TODO +0 -15
  61. data/lib/zip/extra_field/zip64_placeholder.rb +0 -15
data/lib/zip/entry.rb CHANGED
@@ -1,21 +1,45 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'pathname'
4
+
5
+ require_relative 'constants'
6
+ require_relative 'dirtyable'
7
+
2
8
  module Zip
9
+ # Zip::Entry represents an entry in a Zip archive.
3
10
  class Entry
4
- STORED = 0
5
- DEFLATED = 8
11
+ include Dirtyable
12
+
13
+ # Constant used to specify that the entry is stored (i.e., not compressed).
14
+ STORED = ::Zip::COMPRESSION_METHOD_STORE
15
+
16
+ # Constant used to specify that the entry is deflated (i.e., compressed).
17
+ DEFLATED = ::Zip::COMPRESSION_METHOD_DEFLATE
18
+
6
19
  # Language encoding flag (EFS) bit
7
- EFS = 0b100000000000
8
-
9
- attr_accessor :comment, :compressed_size, :crc, :extra, :compression_method,
10
- :name, :size, :local_header_offset, :zipfile, :fstype, :external_file_attributes,
11
- :internal_file_attributes,
12
- :gp_flags, :header_signature, :follow_symlinks,
13
- :restore_times, :restore_permissions, :restore_ownership,
14
- :unix_uid, :unix_gid, :unix_perms,
15
- :dirty
16
- attr_reader :ftype, :filepath # :nodoc:
17
-
18
- def set_default_vars_values
20
+ EFS = 0b100000000000 # :nodoc:
21
+
22
+ # Compression level flags (used as part of the gp flags).
23
+ COMPRESSION_LEVEL_SUPERFAST_GPFLAG = 0b110 # :nodoc:
24
+ COMPRESSION_LEVEL_FAST_GPFLAG = 0b100 # :nodoc:
25
+ COMPRESSION_LEVEL_MAX_GPFLAG = 0b010 # :nodoc:
26
+
27
+ attr_accessor :comment, :compressed_size, :follow_symlinks, :name,
28
+ :restore_ownership, :restore_permissions, :restore_times,
29
+ :unix_gid, :unix_perms, :unix_uid
30
+
31
+ attr_accessor :crc, :external_file_attributes, :fstype, :gp_flags,
32
+ :internal_file_attributes, :local_header_offset # :nodoc:
33
+
34
+ attr_reader :extra, :compression_level, :filepath # :nodoc:
35
+
36
+ attr_writer :size # :nodoc:
37
+
38
+ mark_dirty :comment=, :compressed_size=, :external_file_attributes=,
39
+ :fstype=, :gp_flags=, :name=, :size=,
40
+ :unix_gid=, :unix_perms=, :unix_uid=
41
+
42
+ def set_default_vars_values # :nodoc:
19
43
  @local_header_offset = 0
20
44
  @local_header_size = nil # not known until local entry is created or read
21
45
  @internal_file_attributes = 1
@@ -34,178 +58,252 @@ module Zip
34
58
  end
35
59
  @follow_symlinks = false
36
60
 
37
- @restore_times = false
38
- @restore_permissions = false
39
- @restore_ownership = false
61
+ @restore_times = DEFAULT_RESTORE_OPTIONS[:restore_times]
62
+ @restore_permissions = DEFAULT_RESTORE_OPTIONS[:restore_permissions]
63
+ @restore_ownership = DEFAULT_RESTORE_OPTIONS[:restore_ownership]
40
64
  # BUG: need an extra field to support uid/gid's
41
65
  @unix_uid = nil
42
66
  @unix_gid = nil
43
67
  @unix_perms = nil
44
- # @posix_acl = nil
45
- # @ntfs_acl = nil
46
- @dirty = false
47
68
  end
48
69
 
49
- def check_name(name)
50
- return unless name.start_with?('/')
51
-
52
- raise ::Zip::EntryNameError, "Illegal ZipEntry name '#{name}', name must not start with /"
70
+ def check_name(name) # :nodoc:
71
+ raise EntryNameError, name if name.start_with?('/')
72
+ raise EntryNameError if name.length > 65_535
53
73
  end
54
74
 
55
- def initialize(*args)
56
- name = args[1] || ''
57
- check_name(name)
75
+ # Create a new Zip::Entry.
76
+ def initialize(
77
+ zipfile = '', name = '',
78
+ comment: '', size: nil, compressed_size: 0, crc: 0,
79
+ compression_method: DEFLATED,
80
+ compression_level: ::Zip.default_compression,
81
+ time: ::Zip::DOSTime.now, extra: ::Zip::ExtraField.new
82
+ )
83
+ super()
84
+ @name = name
85
+ check_name(@name)
58
86
 
59
87
  set_default_vars_values
60
88
  @fstype = ::Zip::RUNNING_ON_WINDOWS ? ::Zip::FSTYPE_FAT : ::Zip::FSTYPE_UNIX
61
89
 
62
- @zipfile = args[0] || ''
63
- @name = name
64
- @comment = args[2] || ''
65
- @extra = args[3] || ''
66
- @compressed_size = args[4] || 0
67
- @crc = args[5] || 0
68
- @compression_method = args[6] || ::Zip::Entry::DEFLATED
69
- @size = args[7] || 0
70
- @time = args[8] || ::Zip::DOSTime.now
71
-
72
- @ftype = name_is_directory? ? :directory : :file
73
- @extra = ::Zip::ExtraField.new(@extra.to_s) unless @extra.kind_of?(::Zip::ExtraField)
74
- end
75
-
90
+ @zipfile = zipfile
91
+ @comment = comment || ''
92
+ @compression_method = compression_method || DEFLATED
93
+ @compression_level = compression_level || ::Zip.default_compression
94
+ @compressed_size = compressed_size || 0
95
+ @crc = crc || 0
96
+ @size = size
97
+ @time = case time
98
+ when ::Zip::DOSTime
99
+ time
100
+ when Time
101
+ ::Zip::DOSTime.from_time(time)
102
+ else
103
+ ::Zip::DOSTime.now
104
+ end
105
+ @extra =
106
+ extra.kind_of?(ExtraField) ? extra : ExtraField.new(extra.to_s)
107
+
108
+ set_compression_level_flags
109
+ end
110
+
111
+ # Is this entry encrypted?
76
112
  def encrypted?
77
113
  gp_flags & 1 == 1
78
114
  end
79
115
 
80
- def incomplete?
81
- gp_flags & 8 == 8
116
+ def incomplete? # :nodoc:
117
+ (gp_flags & 8 == 8) && (crc == 0 || size == 0 || compressed_size == 0)
82
118
  end
83
119
 
84
- def time
85
- if @extra['UniversalTime']
86
- @extra['UniversalTime'].mtime
87
- elsif @extra['NTFS']
88
- @extra['NTFS'].mtime
89
- else
90
- # Standard time field in central directory has local time
91
- # under archive creator. Then, we can't get timezone.
92
- @time
93
- end
120
+ # The uncompressed size of the entry.
121
+ def size
122
+ @size || 0
123
+ end
124
+
125
+ # Get a timestamp component of this entry.
126
+ #
127
+ # Returns modification time by default.
128
+ def time(component: :mtime)
129
+ time =
130
+ if @extra['UniversalTime']
131
+ @extra['UniversalTime'].send(component)
132
+ elsif @extra['NTFS']
133
+ @extra['NTFS'].send(component)
134
+ end
135
+
136
+ # Standard time field in central directory has local time
137
+ # under archive creator. Then, we can't get timezone.
138
+ time || (@time if component == :mtime)
94
139
  end
95
140
 
96
141
  alias mtime time
97
142
 
98
- def time=(value)
143
+ # Get the last access time of this entry, if available.
144
+ def atime
145
+ time(component: :atime)
146
+ end
147
+
148
+ # Get the creation time of this entry, if available.
149
+ def ctime
150
+ time(component: :ctime)
151
+ end
152
+
153
+ # Set a timestamp component of this entry.
154
+ #
155
+ # Sets modification time by default.
156
+ def time=(value, component: :mtime)
157
+ @dirty = true
99
158
  unless @extra.member?('UniversalTime') || @extra.member?('NTFS')
100
159
  @extra.create('UniversalTime')
101
160
  end
102
- (@extra['UniversalTime'] || @extra['NTFS']).mtime = value
103
- @time = value
161
+
162
+ value = DOSTime.from_time(value)
163
+ comp = "#{component}=" unless component.to_s.end_with?('=')
164
+ (@extra['UniversalTime'] || @extra['NTFS']).send(comp, value)
165
+ @time = value if component == :mtime
104
166
  end
105
167
 
106
- def file_type_is?(type)
107
- raise InternalError, "current filetype is unknown: #{inspect}" unless @ftype
168
+ alias mtime= time=
108
169
 
109
- @ftype == type
170
+ # Set the last access time of this entry.
171
+ def atime=(value)
172
+ send(:time=, value, component: :atime)
173
+ end
174
+
175
+ # Set the creation time of this entry.
176
+ def ctime=(value)
177
+ send(:time=, value, component: :ctime)
178
+ end
179
+
180
+ # Does this entry return time fields with accurate timezone information?
181
+ def absolute_time?
182
+ @extra.member?('UniversalTime') || @extra.member?('NTFS')
183
+ end
184
+
185
+ # Return the compression method for this entry.
186
+ #
187
+ # Returns STORED if the entry is a directory or if the compression
188
+ # level is 0.
189
+ def compression_method
190
+ return STORED if ftype == :directory || @compression_level == 0
191
+
192
+ @compression_method
193
+ end
194
+
195
+ # Set the compression method for this entry.
196
+ def compression_method=(method)
197
+ @dirty = true
198
+ @compression_method = (ftype == :directory ? STORED : method)
199
+ end
200
+
201
+ # Does this entry use the ZIP64 extensions?
202
+ def zip64?
203
+ !@extra['Zip64'].nil?
204
+ end
205
+
206
+ def file_type_is?(type) # :nodoc:
207
+ ftype == type
208
+ end
209
+
210
+ def ftype # :nodoc:
211
+ @ftype ||= name_is_directory? ? :directory : :file
110
212
  end
111
213
 
112
214
  # Dynamic checkers
113
215
  %w[directory file symlink].each do |k|
114
- define_method "#{k}?" do
216
+ define_method :"#{k}?" do
115
217
  file_type_is?(k.to_sym)
116
218
  end
117
219
  end
118
220
 
119
- def name_is_directory? #:nodoc:all
221
+ def name_is_directory? # :nodoc:
120
222
  @name.end_with?('/')
121
223
  end
122
224
 
123
225
  # Is the name a relative path, free of `..` patterns that could lead to
124
226
  # path traversal attacks? This does NOT handle symlinks; if the path
125
227
  # contains symlinks, this check is NOT enough to guarantee safety.
126
- def name_safe?
228
+ def name_safe? # :nodoc:
127
229
  cleanpath = Pathname.new(@name).cleanpath
128
230
  return false unless cleanpath.relative?
129
231
 
130
232
  root = ::File::SEPARATOR
131
- naive_expanded_path = ::File.join(root, cleanpath.to_s)
132
- ::File.absolute_path(cleanpath.to_s, root) == naive_expanded_path
233
+ naive = Regexp.escape(::File.join(root, cleanpath.to_s))
234
+ # Allow for Windows drive mappings at the root.
235
+ ::File.absolute_path(cleanpath.to_s, root).match?(/([A-Z]:)?#{naive}/i)
133
236
  end
134
237
 
135
- def local_entry_offset #:nodoc:all
238
+ def local_entry_offset # :nodoc:
136
239
  local_header_offset + @local_header_size
137
240
  end
138
241
 
139
- def name_size
242
+ def name_size # :nodoc:
140
243
  @name ? @name.bytesize : 0
141
244
  end
142
245
 
143
- def extra_size
246
+ def extra_size # :nodoc:
144
247
  @extra ? @extra.local_size : 0
145
248
  end
146
249
 
147
- def comment_size
250
+ def comment_size # :nodoc:
148
251
  @comment ? @comment.bytesize : 0
149
252
  end
150
253
 
151
- def calculate_local_header_size #:nodoc:all
254
+ def calculate_local_header_size # :nodoc:
152
255
  LOCAL_ENTRY_STATIC_HEADER_LENGTH + name_size + extra_size
153
256
  end
154
257
 
155
258
  # check before rewriting an entry (after file sizes are known)
156
259
  # that we didn't change the header size (and thus clobber file data or something)
157
- def verify_local_header_size!
260
+ def verify_local_header_size! # :nodoc:
158
261
  return if @local_header_size.nil?
159
262
 
160
263
  new_size = calculate_local_header_size
161
- raise Error, "local header size changed (#{@local_header_size} -> #{new_size})" if @local_header_size != new_size
264
+ return unless @local_header_size != new_size
265
+
266
+ raise Error,
267
+ "Local header size changed (#{@local_header_size} -> #{new_size})"
162
268
  end
163
269
 
164
- def cdir_header_size #:nodoc:all
270
+ def cdir_header_size # :nodoc:
165
271
  CDIR_ENTRY_STATIC_HEADER_LENGTH + name_size +
166
272
  (@extra ? @extra.c_dir_size : 0) + comment_size
167
273
  end
168
274
 
169
- def next_header_offset #:nodoc:all
170
- local_entry_offset + compressed_size + data_descriptor_size
275
+ def next_header_offset # :nodoc:
276
+ local_entry_offset + compressed_size
171
277
  end
172
278
 
173
- # Extracts entry to file dest_path (defaults to @name).
174
- # NB: The caller is responsible for making sure dest_path is safe, if it
175
- # is passed.
176
- def extract(dest_path = nil, &block)
177
- if dest_path.nil? && !name_safe?
178
- warn "WARNING: skipped '#{@name}' as unsafe."
279
+ # Extracts this entry to a file at `entry_path`, with
280
+ # `destination_directory` as the base location in the filesystem.
281
+ #
282
+ # NB: The caller is responsible for making sure `destination_directory` is
283
+ # safe, if it is passed.
284
+ def extract(entry_path = @name, destination_directory: '.', &block)
285
+ dest_dir = ::File.absolute_path(destination_directory || '.')
286
+ extract_path = ::File.absolute_path(::File.join(dest_dir, entry_path))
287
+
288
+ unless extract_path.start_with?(dest_dir)
289
+ warn "WARNING: skipped extracting '#{@name}' to '#{extract_path}' as unsafe."
179
290
  return self
180
291
  end
181
292
 
182
- dest_path ||= @name
183
293
  block ||= proc { ::Zip.on_exists_proc }
184
294
 
185
295
  raise "unknown file type #{inspect}" unless directory? || file? || symlink?
186
296
 
187
- __send__("create_#{@ftype}", dest_path, &block)
297
+ __send__(:"create_#{ftype}", extract_path, &block)
188
298
  self
189
299
  end
190
300
 
191
- def to_s
301
+ def to_s # :nodoc:
192
302
  @name
193
303
  end
194
304
 
195
305
  class << self
196
- def read_zip_short(io) # :nodoc:
197
- io.read(2).unpack1('v')
198
- end
199
-
200
- def read_zip_long(io) # :nodoc:
201
- io.read(4).unpack1('V')
202
- end
203
-
204
- def read_zip_64_long(io) # :nodoc:
205
- io.read(8).unpack1('Q<')
206
- end
207
-
208
- def read_c_dir_entry(io) #:nodoc:all
306
+ def read_c_dir_entry(io) # :nodoc:
209
307
  path = if io.respond_to?(:path)
210
308
  io.path
211
309
  else
@@ -218,16 +316,18 @@ module Zip
218
316
  nil
219
317
  end
220
318
 
221
- def read_local_entry(io)
319
+ def read_local_entry(io) # :nodoc:
222
320
  entry = new(io)
223
321
  entry.read_local_entry(io)
224
322
  entry
323
+ rescue SplitArchiveError
324
+ raise
225
325
  rescue Error
226
326
  nil
227
327
  end
228
328
  end
229
329
 
230
- def unpack_local_entry(buf)
330
+ def unpack_local_entry(buf) # :nodoc:
231
331
  @header_signature,
232
332
  @version,
233
333
  @fstype,
@@ -242,62 +342,66 @@ module Zip
242
342
  @extra_length = buf.unpack('VCCvvvvVVVvv')
243
343
  end
244
344
 
245
- def read_local_entry(io) #:nodoc:all
246
- @local_header_offset = io.tell
345
+ def read_local_entry(io) # :nodoc:
346
+ @dirty = false # No changes at this point.
347
+ current_offset = io.tell
247
348
 
248
- static_sized_fields_buf = io.read(::Zip::LOCAL_ENTRY_STATIC_HEADER_LENGTH) || ''
349
+ read_local_header_fields(io)
249
350
 
250
- unless static_sized_fields_buf.bytesize == ::Zip::LOCAL_ENTRY_STATIC_HEADER_LENGTH
251
- raise Error, 'Premature end of file. Not enough data for zip entry local header'
252
- end
351
+ if @header_signature == SPLIT_FILE_SIGNATURE
352
+ raise SplitArchiveError if current_offset.zero?
253
353
 
254
- unpack_local_entry(static_sized_fields_buf)
354
+ # Rewind, skipping the data descriptor, then try to read the local header again.
355
+ current_offset += 16
356
+ io.seek(current_offset)
357
+ read_local_header_fields(io)
358
+ end
255
359
 
256
- unless @header_signature == ::Zip::LOCAL_ENTRY_SIGNATURE
257
- raise ::Zip::Error, "Zip local header magic not found at location '#{local_header_offset}'"
360
+ unless @header_signature == LOCAL_ENTRY_SIGNATURE
361
+ raise Error, "Zip local header magic not found at location '#{current_offset}'"
258
362
  end
259
363
 
364
+ @local_header_offset = current_offset
365
+
260
366
  set_time(@last_mod_date, @last_mod_time)
261
367
 
262
368
  @name = io.read(@name_length)
263
- extra = io.read(@extra_length)
264
-
265
- @name.tr!('\\', '/')
266
369
  if ::Zip.force_entry_names_encoding
267
370
  @name.force_encoding(::Zip.force_entry_names_encoding)
268
371
  end
372
+ @name.tr!('\\', '/') # Normalise filepath separators after encoding set.
373
+
374
+ # We need to do this here because `initialize` has so many side-effects.
375
+ # :-(
376
+ @ftype = name_is_directory? ? :directory : :file
269
377
 
378
+ extra = io.read(@extra_length)
270
379
  if extra && extra.bytesize != @extra_length
271
380
  raise ::Zip::Error, 'Truncated local zip entry header'
272
381
  end
273
382
 
274
- if @extra.kind_of?(::Zip::ExtraField)
275
- @extra.merge(extra) if extra
276
- else
277
- @extra = ::Zip::ExtraField.new(extra)
278
- end
279
-
383
+ read_extra_field(extra, local: true)
280
384
  parse_zip64_extra(true)
281
385
  @local_header_size = calculate_local_header_size
282
386
  end
283
387
 
284
- def pack_local_entry
388
+ def pack_local_entry # :nodoc:
285
389
  zip64 = @extra['Zip64']
286
390
  [::Zip::LOCAL_ENTRY_SIGNATURE,
287
391
  @version_needed_to_extract, # version needed to extract
288
392
  @gp_flags, # @gp_flags
289
- @compression_method,
393
+ compression_method,
290
394
  @time.to_binary_dos_time, # @last_mod_time
291
395
  @time.to_binary_dos_date, # @last_mod_date
292
396
  @crc,
293
397
  zip64 && zip64.compressed_size ? 0xFFFFFFFF : @compressed_size,
294
- zip64 && zip64.original_size ? 0xFFFFFFFF : @size,
398
+ zip64 && zip64.original_size ? 0xFFFFFFFF : (@size || 0),
295
399
  name_size,
296
400
  @extra ? @extra.local_size : 0].pack('VvvvvvVVVvv')
297
401
  end
298
402
 
299
- def write_local_entry(io, rewrite = false) #:nodoc:all
300
- prep_zip64_extra(true)
403
+ def write_local_entry(io, rewrite: false) # :nodoc:
404
+ prep_local_zip64_extra
301
405
  verify_local_header_size! if rewrite
302
406
  @local_header_offset = io.tell
303
407
 
@@ -308,7 +412,7 @@ module Zip
308
412
  @local_header_size = io.tell - @local_header_offset
309
413
  end
310
414
 
311
- def unpack_c_dir_entry(buf)
415
+ def unpack_c_dir_entry(buf) # :nodoc:
312
416
  @header_signature,
313
417
  @version, # version of encoding software
314
418
  @fstype, # filesystem type
@@ -332,7 +436,7 @@ module Zip
332
436
  @comment = buf.unpack('VCCvvvvvVVVvvvvvVV')
333
437
  end
334
438
 
335
- def set_ftype_from_c_dir_entry
439
+ def set_ftype_from_c_dir_entry # :nodoc:
336
440
  @ftype = case @fstype
337
441
  when ::Zip::FSTYPE_UNIX
338
442
  @unix_perms = (@external_file_attributes >> 16) & 0o7777
@@ -344,8 +448,9 @@ module Zip
344
448
  when ::Zip::FILE_TYPE_SYMLINK
345
449
  :symlink
346
450
  else
347
- # best case guess for whether it is a file or not
348
- # Otherwise this would be set to unknown and that entry would never be able to extracted
451
+ # Best case guess for whether it is a file or not.
452
+ # Otherwise this would be set to unknown and that
453
+ # entry would never be able to be extracted.
349
454
  if name_is_directory?
350
455
  :directory
351
456
  else
@@ -361,43 +466,47 @@ module Zip
361
466
  end
362
467
  end
363
468
 
364
- def check_c_dir_entry_static_header_length(buf)
365
- return if buf.bytesize == ::Zip::CDIR_ENTRY_STATIC_HEADER_LENGTH
469
+ def check_c_dir_entry_static_header_length(buf) # :nodoc:
470
+ return unless buf.nil? || buf.bytesize != ::Zip::CDIR_ENTRY_STATIC_HEADER_LENGTH
366
471
 
367
472
  raise Error, 'Premature end of file. Not enough data for zip cdir entry header'
368
473
  end
369
474
 
370
- def check_c_dir_entry_signature
371
- return if header_signature == ::Zip::CENTRAL_DIRECTORY_ENTRY_SIGNATURE
475
+ def check_c_dir_entry_signature # :nodoc:
476
+ return if @header_signature == ::Zip::CENTRAL_DIRECTORY_ENTRY_SIGNATURE
372
477
 
373
478
  raise Error, "Zip local header magic not found at location '#{local_header_offset}'"
374
479
  end
375
480
 
376
- def check_c_dir_entry_comment_size
481
+ def check_c_dir_entry_comment_size # :nodoc:
377
482
  return if @comment && @comment.bytesize == @comment_length
378
483
 
379
484
  raise ::Zip::Error, 'Truncated cdir zip entry header'
380
485
  end
381
486
 
382
- def read_c_dir_extra_field(io)
487
+ def read_extra_field(buf, local: false) # :nodoc:
383
488
  if @extra.kind_of?(::Zip::ExtraField)
384
- @extra.merge(io.read(@extra_length))
489
+ @extra.merge(buf, local: local) if buf
385
490
  else
386
- @extra = ::Zip::ExtraField.new(io.read(@extra_length))
491
+ @extra = ::Zip::ExtraField.new(buf, local: local)
387
492
  end
388
493
  end
389
494
 
390
- def read_c_dir_entry(io) #:nodoc:all
495
+ def read_c_dir_entry(io) # :nodoc:
496
+ @dirty = false # No changes at this point.
391
497
  static_sized_fields_buf = io.read(::Zip::CDIR_ENTRY_STATIC_HEADER_LENGTH)
392
498
  check_c_dir_entry_static_header_length(static_sized_fields_buf)
393
499
  unpack_c_dir_entry(static_sized_fields_buf)
394
500
  check_c_dir_entry_signature
395
501
  set_time(@last_mod_date, @last_mod_time)
502
+
396
503
  @name = io.read(@name_length)
397
504
  if ::Zip.force_entry_names_encoding
398
505
  @name.force_encoding(::Zip.force_entry_names_encoding)
399
506
  end
400
- read_c_dir_extra_field(io)
507
+ @name.tr!('\\', '/') # Normalise filepath separators after encoding set.
508
+
509
+ read_extra_field(io.read(@extra_length))
401
510
  @comment = io.read(@comment_length)
402
511
  check_c_dir_entry_comment_size
403
512
  set_ftype_from_c_dir_entry
@@ -413,27 +522,27 @@ module Zip
413
522
  end
414
523
 
415
524
  def get_extra_attributes_from_path(path) # :nodoc:
416
- return if Zip::RUNNING_ON_WINDOWS
525
+ stat = file_stat(path)
526
+ @time = DOSTime.from_time(stat.mtime)
527
+ return if ::Zip::RUNNING_ON_WINDOWS
417
528
 
418
- stat = file_stat(path)
419
529
  @unix_uid = stat.uid
420
530
  @unix_gid = stat.gid
421
531
  @unix_perms = stat.mode & 0o7777
422
- @time = ::Zip::DOSTime.from_time(stat.mtime)
423
532
  end
424
533
 
425
- def set_unix_attributes_on_path(dest_path)
426
- # ignore setuid/setgid bits by default. honor if @restore_ownership
427
- unix_perms_mask = 0o1777
428
- unix_perms_mask = 0o7777 if @restore_ownership
429
- ::FileUtils.chmod(@unix_perms & unix_perms_mask, dest_path) if @restore_permissions && @unix_perms
430
- ::FileUtils.chown(@unix_uid, @unix_gid, dest_path) if @restore_ownership && @unix_uid && @unix_gid && ::Process.egid == 0
431
-
432
- # Restore the timestamp on a file. This will either have come from the
433
- # original source file that was copied into the archive, or from the
434
- # creation date of the archive if there was no original source file.
435
- ::FileUtils.touch(dest_path, mtime: time) if @restore_times
534
+ # rubocop:disable Style/GuardClause
535
+ def set_unix_attributes_on_path(dest_path) # :nodoc:
536
+ # Ignore setuid/setgid bits by default. Honour if @restore_ownership.
537
+ unix_perms_mask = (@restore_ownership ? 0o7777 : 0o1777)
538
+ if @restore_permissions && @unix_perms
539
+ ::FileUtils.chmod(@unix_perms & unix_perms_mask, dest_path)
540
+ end
541
+ if @restore_ownership && @unix_uid && @unix_gid && ::Process.egid == 0
542
+ ::FileUtils.chown(@unix_uid, @unix_gid, dest_path)
543
+ end
436
544
  end
545
+ # rubocop:enable Style/GuardClause
437
546
 
438
547
  def set_extra_attributes_on_path(dest_path) # :nodoc:
439
548
  return unless file? || directory?
@@ -442,9 +551,14 @@ module Zip
442
551
  when ::Zip::FSTYPE_UNIX
443
552
  set_unix_attributes_on_path(dest_path)
444
553
  end
554
+
555
+ # Restore the timestamp on a file. This will either have come from the
556
+ # original source file that was copied into the archive, or from the
557
+ # creation date of the archive if there was no original source file.
558
+ ::FileUtils.touch(dest_path, mtime: time) if @restore_times
445
559
  end
446
560
 
447
- def pack_c_dir_entry
561
+ def pack_c_dir_entry # :nodoc:
448
562
  zip64 = @extra['Zip64']
449
563
  [
450
564
  @header_signature,
@@ -452,12 +566,12 @@ module Zip
452
566
  @fstype, # filesystem type
453
567
  @version_needed_to_extract, # @versionNeededToExtract
454
568
  @gp_flags, # @gp_flags
455
- @compression_method,
569
+ compression_method,
456
570
  @time.to_binary_dos_time, # @last_mod_time
457
571
  @time.to_binary_dos_date, # @last_mod_date
458
572
  @crc,
459
573
  zip64 && zip64.compressed_size ? 0xFFFFFFFF : @compressed_size,
460
- zip64 && zip64.original_size ? 0xFFFFFFFF : @size,
574
+ zip64 && zip64.original_size ? 0xFFFFFFFF : (@size || 0),
461
575
  name_size,
462
576
  @extra ? @extra.c_dir_size : 0,
463
577
  comment_size,
@@ -471,11 +585,12 @@ module Zip
471
585
  ].pack('VCCvvvvvVVVvvvvvVV')
472
586
  end
473
587
 
474
- def write_c_dir_entry(io) #:nodoc:all
475
- prep_zip64_extra(false)
588
+ def write_c_dir_entry(io) # :nodoc:
589
+ prep_cdir_zip64_extra
590
+
476
591
  case @fstype
477
592
  when ::Zip::FSTYPE_UNIX
478
- ft = case @ftype
593
+ ft = case ftype
479
594
  when :file
480
595
  @unix_perms ||= 0o644
481
596
  ::Zip::FILE_TYPE_FILE
@@ -488,7 +603,7 @@ module Zip
488
603
  end
489
604
 
490
605
  unless ft.nil?
491
- @external_file_attributes = (ft << 12 | (@unix_perms & 0o7777)) << 16
606
+ @external_file_attributes = ((ft << 12) | (@unix_perms & 0o7777)) << 16
492
607
  end
493
608
  end
494
609
 
@@ -499,43 +614,42 @@ module Zip
499
614
  io << @comment
500
615
  end
501
616
 
502
- def ==(other)
617
+ def ==(other) # :nodoc:
503
618
  return false unless other.class == self.class
504
619
 
505
620
  # Compares contents of local entry and exposed fields
506
- keys_equal = %w[compression_method crc compressed_size size name extra filepath].all? do |k|
621
+ %w[compression_method crc compressed_size size name extra filepath time].all? do |k|
507
622
  other.__send__(k.to_sym) == __send__(k.to_sym)
508
623
  end
509
- keys_equal && time.dos_equals(other.time)
510
624
  end
511
625
 
512
- def <=>(other)
626
+ def <=>(other) # :nodoc:
513
627
  to_s <=> other.to_s
514
628
  end
515
629
 
516
630
  # Returns an IO like object for the given ZipEntry.
517
631
  # Warning: may behave weird with symlinks.
518
632
  def get_input_stream(&block)
519
- if @ftype == :directory
520
- yield ::Zip::NullInputStream if block_given?
633
+ if ftype == :directory
634
+ yield ::Zip::NullInputStream if block
521
635
  ::Zip::NullInputStream
522
636
  elsif @filepath
523
- case @ftype
637
+ case ftype
524
638
  when :file
525
639
  ::File.open(@filepath, 'rb', &block)
526
640
  when :symlink
527
641
  linkpath = ::File.readlink(@filepath)
528
642
  stringio = ::StringIO.new(linkpath)
529
- yield(stringio) if block_given?
643
+ yield(stringio) if block
530
644
  stringio
531
645
  else
532
- raise "unknown @file_type #{@ftype}"
646
+ raise "unknown @file_type #{ftype}"
533
647
  end
534
648
  else
535
- zis = ::Zip::InputStream.new(@zipfile, local_header_offset)
649
+ zis = ::Zip::InputStream.new(@zipfile, offset: local_header_offset)
536
650
  zis.instance_variable_set(:@complete_entry, self)
537
651
  zis.get_next_entry
538
- if block_given?
652
+ if block
539
653
  begin
540
654
  yield(zis)
541
655
  ensure
@@ -554,7 +668,7 @@ module Zip
554
668
  if name_is_directory?
555
669
  raise ArgumentError,
556
670
  "entry name '#{newEntry}' indicates directory entry, but " \
557
- "'#{src_path}' is not a directory"
671
+ "'#{src_path}' is not a directory"
558
672
  end
559
673
  :file
560
674
  when 'directory'
@@ -564,7 +678,7 @@ module Zip
564
678
  if name_is_directory?
565
679
  raise ArgumentError,
566
680
  "entry name '#{newEntry}' indicates directory entry, but " \
567
- "'#{src_path}' is not a directory"
681
+ "'#{src_path}' is not a directory"
568
682
  end
569
683
  :symlink
570
684
  else
@@ -572,27 +686,30 @@ module Zip
572
686
  end
573
687
 
574
688
  @filepath = src_path
689
+ @size = stat.size
575
690
  get_extra_attributes_from_path(@filepath)
576
691
  end
577
692
 
578
- def write_to_zip_output_stream(zip_output_stream) #:nodoc:all
579
- if @ftype == :directory
580
- zip_output_stream.put_next_entry(self, nil, nil, ::Zip::Entry::STORED)
693
+ def write_to_zip_output_stream(zip_output_stream) # :nodoc:
694
+ if ftype == :directory
695
+ zip_output_stream.put_next_entry(self)
581
696
  elsif @filepath
582
- zip_output_stream.put_next_entry(self, nil, nil, compression_method || ::Zip::Entry::DEFLATED)
583
- get_input_stream { |is| ::Zip::IOExtras.copy_stream(zip_output_stream, is) }
697
+ zip_output_stream.put_next_entry(self)
698
+ get_input_stream do |is|
699
+ ::Zip::IOExtras.copy_stream(zip_output_stream, is)
700
+ end
584
701
  else
585
702
  zip_output_stream.copy_raw_entry(self)
586
703
  end
587
704
  end
588
705
 
589
- def parent_as_string
706
+ def parent_as_string # :nodoc:
590
707
  entry_name = name.chomp('/')
591
708
  slash_index = entry_name.rindex('/')
592
709
  slash_index ? entry_name.slice(0, slash_index + 1) : nil
593
710
  end
594
711
 
595
- def get_raw_input_stream(&block)
712
+ def get_raw_input_stream(&block) # :nodoc:
596
713
  if @zipfile.respond_to?(:seek) && @zipfile.respond_to?(:read)
597
714
  yield @zipfile
598
715
  else
@@ -600,12 +717,22 @@ module Zip
600
717
  end
601
718
  end
602
719
 
603
- def clean_up
604
- # By default, do nothing
720
+ def clean_up # :nodoc:
721
+ @dirty = false # Any changes are written at this point.
605
722
  end
606
723
 
607
724
  private
608
725
 
726
+ def read_local_header_fields(io) # :nodoc:
727
+ static_sized_fields_buf = io.read(::Zip::LOCAL_ENTRY_STATIC_HEADER_LENGTH) || ''
728
+
729
+ unless static_sized_fields_buf.bytesize == ::Zip::LOCAL_ENTRY_STATIC_HEADER_LENGTH
730
+ raise Error, 'Premature end of file. Not enough data for zip entry local header'
731
+ end
732
+
733
+ unpack_local_entry(static_sized_fields_buf)
734
+ end
735
+
609
736
  def set_time(binary_dos_date, binary_dos_time)
610
737
  @time = ::Zip::DOSTime.parse_binary_dos_format(binary_dos_date, binary_dos_time)
611
738
  rescue ArgumentError
@@ -614,9 +741,9 @@ module Zip
614
741
 
615
742
  def create_file(dest_path, _continue_on_exists_proc = proc { Zip.continue_on_exists_proc })
616
743
  if ::File.exist?(dest_path) && !yield(self, dest_path)
617
- raise ::Zip::DestinationFileExistsError,
618
- "Destination '#{dest_path}' already exists"
744
+ raise ::Zip::DestinationExistsError, dest_path
619
745
  end
746
+
620
747
  ::File.open(dest_path, 'wb') do |os|
621
748
  get_input_stream do |is|
622
749
  bytes_written = 0
@@ -627,10 +754,10 @@ module Zip
627
754
  bytes_written += buf.bytesize
628
755
  next unless bytes_written > size && !warned
629
756
 
630
- message = "entry '#{name}' should be #{size}B, but is larger when inflated."
631
- raise ::Zip::EntrySizeError, message if ::Zip.validate_entry_sizes
757
+ error = ::Zip::EntrySizeError.new(self)
758
+ raise error if ::Zip.validate_entry_sizes
632
759
 
633
- warn "WARNING: #{message}"
760
+ warn "WARNING: #{error.message}"
634
761
  warned = true
635
762
  end
636
763
  end
@@ -643,14 +770,11 @@ module Zip
643
770
  return if ::File.directory?(dest_path)
644
771
 
645
772
  if ::File.exist?(dest_path)
646
- if block_given? && yield(self, dest_path)
647
- ::FileUtils.rm_f dest_path
648
- else
649
- raise ::Zip::DestinationFileExistsError,
650
- "Cannot create directory '#{dest_path}'. " \
651
- 'A file already exists with that name'
652
- end
773
+ raise ::Zip::DestinationExistsError, dest_path unless block_given? && yield(self, dest_path)
774
+
775
+ ::FileUtils.rm_f dest_path
653
776
  end
777
+
654
778
  ::FileUtils.mkdir_p(dest_path)
655
779
  set_extra_attributes_on_path(dest_path)
656
780
  end
@@ -664,53 +788,71 @@ module Zip
664
788
 
665
789
  # apply missing data from the zip64 extra information field, if present
666
790
  # (required when file sizes exceed 2**32, but can be used for all files)
667
- def parse_zip64_extra(for_local_header) #:nodoc:all
668
- return if @extra['Zip64'].nil?
791
+ def parse_zip64_extra(for_local_header) # :nodoc:
792
+ return unless zip64?
669
793
 
670
794
  if for_local_header
671
795
  @size, @compressed_size = @extra['Zip64'].parse(@size, @compressed_size)
672
796
  else
673
- @size, @compressed_size, @local_header_offset = @extra['Zip64'].parse(@size, @compressed_size, @local_header_offset)
797
+ @size, @compressed_size, @local_header_offset = @extra['Zip64'].parse(
798
+ @size, @compressed_size, @local_header_offset
799
+ )
674
800
  end
675
801
  end
676
802
 
677
- def data_descriptor_size
678
- (@gp_flags & 0x0008) > 0 ? 16 : 0
803
+ # For DEFLATED compression *only*: set the general purpose flags 1 and 2 to
804
+ # indicate compression level. This seems to be mainly cosmetic but they are
805
+ # generally set by other tools - including in docx files. It is these flags
806
+ # that are used by commandline tools (and elsewhere) to give an indication
807
+ # of how compressed a file is. See the PKWARE APPNOTE for more information:
808
+ # https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT
809
+ #
810
+ # It's safe to simply OR these flags here as compression_level is read only.
811
+ def set_compression_level_flags
812
+ return unless compression_method == DEFLATED
813
+
814
+ case @compression_level
815
+ when 1
816
+ @gp_flags |= COMPRESSION_LEVEL_SUPERFAST_GPFLAG
817
+ when 2
818
+ @gp_flags |= COMPRESSION_LEVEL_FAST_GPFLAG
819
+ when 8, 9
820
+ @gp_flags |= COMPRESSION_LEVEL_MAX_GPFLAG
821
+ end
679
822
  end
680
823
 
681
- # create a zip64 extra information field if we need one
682
- def prep_zip64_extra(for_local_header) #:nodoc:all
824
+ # rubocop:disable Style/GuardClause
825
+ def prep_local_zip64_extra
683
826
  return unless ::Zip.write_zip64_support
827
+ return if (!zip64? && @size && @size < 0xFFFFFFFF) || !file?
684
828
 
685
- need_zip64 = @size >= 0xFFFFFFFF || @compressed_size >= 0xFFFFFFFF
686
- need_zip64 ||= @local_header_offset >= 0xFFFFFFFF unless for_local_header
687
- if need_zip64
829
+ # Might not know size here, so need ZIP64 just in case.
830
+ # If we already have a ZIP64 extra (placeholder) then we must fill it in.
831
+ if zip64? || @size.nil? || @size >= 0xFFFFFFFF || @compressed_size >= 0xFFFFFFFF
688
832
  @version_needed_to_extract = VERSION_NEEDED_TO_EXTRACT_ZIP64
689
- @extra.delete('Zip64Placeholder')
690
- zip64 = @extra.create('Zip64')
691
- if for_local_header
692
- # local header always includes size and compressed size
693
- zip64.original_size = @size
694
- zip64.compressed_size = @compressed_size
695
- else
696
- # central directory entry entries include whichever fields are necessary
697
- zip64.original_size = @size if @size >= 0xFFFFFFFF
698
- zip64.compressed_size = @compressed_size if @compressed_size >= 0xFFFFFFFF
699
- zip64.relative_header_offset = @local_header_offset if @local_header_offset >= 0xFFFFFFFF
700
- end
701
- else
702
- @extra.delete('Zip64')
833
+ zip64 = @extra['Zip64'] || @extra.create('Zip64')
703
834
 
704
- # if this is a local header entry, create a placeholder
705
- # so we have room to write a zip64 extra field afterward
706
- # (we won't know if it's needed until the file data is written)
707
- if for_local_header
708
- @extra.create('Zip64Placeholder')
709
- else
710
- @extra.delete('Zip64Placeholder')
711
- end
835
+ # Local header always includes size and compressed size.
836
+ zip64.original_size = @size || 0
837
+ zip64.compressed_size = @compressed_size
838
+ end
839
+ end
840
+
841
+ def prep_cdir_zip64_extra
842
+ return unless ::Zip.write_zip64_support
843
+
844
+ if (@size && @size >= 0xFFFFFFFF) || @compressed_size >= 0xFFFFFFFF ||
845
+ @local_header_offset >= 0xFFFFFFFF
846
+ @version_needed_to_extract = VERSION_NEEDED_TO_EXTRACT_ZIP64
847
+ zip64 = @extra['Zip64'] || @extra.create('Zip64')
848
+
849
+ # Central directory entry entries include whichever fields are necessary.
850
+ zip64.original_size = @size if @size && @size >= 0xFFFFFFFF
851
+ zip64.compressed_size = @compressed_size if @compressed_size >= 0xFFFFFFFF
852
+ zip64.relative_header_offset = @local_header_offset if @local_header_offset >= 0xFFFFFFFF
712
853
  end
713
854
  end
855
+ # rubocop:enable Style/GuardClause
714
856
  end
715
857
  end
716
858