archive-zip 0.1.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.
@@ -0,0 +1,54 @@
1
+ module Archive; class Zip
2
+ # Archive::Zip::Entry::DataDescriptor is a convenience class which bundles
3
+ # imporant information concerning the compressed data in a ZIP archive entry
4
+ # and allows easy comparisons between instances of itself.
5
+ class DataDescriptor
6
+ # Create a new instance of this class where <em>crc32</em>,
7
+ # _compressed_size_, and _uncompressed_size_ are all integers representing a
8
+ # CRC32 checksum of uncompressed data, the size of compressed data, and the
9
+ # size of uncompressed data respectively.
10
+ def initialize(crc32, compressed_size, uncompressed_size)
11
+ @crc32 = crc32
12
+ @compressed_size = compressed_size
13
+ @uncompressed_size = uncompressed_size
14
+ end
15
+
16
+ # A CRC32 checksum over some set of uncompressed data.
17
+ attr_reader :crc32
18
+ # A count of the number of bytes of compressed data associated with a set of
19
+ # uncompressed data.
20
+ attr_reader :compressed_size
21
+ # A count of the number of bytes of a set of uncompressed data.
22
+ attr_reader :uncompressed_size
23
+
24
+ # Compares the attributes of this object with like-named attributes of
25
+ # _other_ and raises Archive::Zip::Error for any mismatches.
26
+ def verify(other)
27
+ unless crc32 == other.crc32 then
28
+ raise Zip::Error,
29
+ "CRC32 mismatch: #{crc32.to_s(16)} vs. #{other.crc32.to_s(16)}"
30
+ end
31
+ unless compressed_size == other.compressed_size then
32
+ raise Zip::Error,
33
+ "compressed size mismatch: #{compressed_size} vs. #{other.compressed_size}"
34
+ end
35
+ unless uncompressed_size == other.uncompressed_size then
36
+ raise Zip::Error,
37
+ "uncompressed size mismatch: #{uncompressed_size} vs. #{other.uncompressed_size}"
38
+ end
39
+ end
40
+
41
+ # Writes the data wrapped in this object to _io_ which must be a writable,
42
+ # IO-like object prividing a _write_ method. Returns the number of bytes
43
+ # written.
44
+ def dump(io)
45
+ io.write(
46
+ [
47
+ crc32,
48
+ compressed_size,
49
+ uncompressed_size
50
+ ].pack('VVV')
51
+ )
52
+ end
53
+ end
54
+ end; end
@@ -0,0 +1,991 @@
1
+ require 'archive/zip/codec/store'
2
+ require 'archive/zip/error'
3
+ require 'archive/zip/extrafield'
4
+ require 'archive/zip/datadescriptor'
5
+
6
+ module Archive; class Zip
7
+ # The Archive::Zip::Entry mixin provides classes with methods implementing
8
+ # many of the common features of all entry types. Some of these methods, such
9
+ # as _dump_local_file_record_ and _dump_central_file_record_, are required by
10
+ # Archive::Zip in order to store the entry into an archive. Those should be
11
+ # left alone. Others, such as _ftype_ and <i>mode=</i>, are expected to be
12
+ # overridden to provide sensible information for the new entry type.
13
+ #
14
+ # A class using this mixin must provide 2 methods: _extract_ and
15
+ # _dump_compressed_data_. _extract_ should be a public method with the
16
+ # following signature:
17
+ #
18
+ # def extract(options = {})
19
+ # ...
20
+ # end
21
+ #
22
+ # This method should extract the contents of the entry to the filesystem.
23
+ # _options_ should be an optional Hash containing a mapping of option names to
24
+ # option values. Please refer to Archive::Zip::Entry::File#extract,
25
+ # Archive::Zip::Entry::Symlink#extract, and
26
+ # Archive::Zip::Entry::Directory#extract for examples of the options currently
27
+ # supported.
28
+ #
29
+ # _dump_compressed_data_ should be a private method with the following
30
+ # signature:
31
+ #
32
+ # def dump_compressed_data(io)
33
+ # ...
34
+ # end
35
+ #
36
+ # This method should use the _write_ method of _io_ to write all file data.
37
+ # _io_ will be a writable, IO-like object.
38
+ #
39
+ # The class methods from_file and parse are factories for creating the 3 kinds
40
+ # of concrete entries currently implemented: File, Directory, and Symlink.
41
+ # While it is possible to create new archives using custom entry
42
+ # implementations, it is not possible to load those same entries from the
43
+ # archive since the parse factory method does not know about them. Patches
44
+ # to support new entry types are welcome.
45
+ module Entry
46
+ CFHRecord = Struct.new(
47
+ :made_by_version,
48
+ :extraction_version,
49
+ :general_purpose_flags,
50
+ :compression_method,
51
+ :mtime,
52
+ :crc32,
53
+ :compressed_size,
54
+ :uncompressed_size,
55
+ :disk_number_start,
56
+ :internal_file_attributes,
57
+ :external_file_attributes,
58
+ :local_header_position,
59
+ :zip_path,
60
+ :extra_fields,
61
+ :comment
62
+ )
63
+
64
+ LFHRecord = Struct.new(
65
+ :extraction_version,
66
+ :general_purpose_flags,
67
+ :compression_method,
68
+ :mtime,
69
+ :crc32,
70
+ :compressed_size,
71
+ :uncompressed_size,
72
+ :zip_path,
73
+ :extra_fields,
74
+ :compressed_data
75
+ )
76
+
77
+ # When this flag is set in the general purpose flags, it indicates that the
78
+ # read data descriptor record for a local file record is located after the
79
+ # entry's file data.
80
+ FLAG_DATA_DESCRIPTOR_FOLLOWS = 0b1000
81
+
82
+ # Cleans up and returns _zip_path_ by eliminating . and .. references,
83
+ # leading and trailing <tt>/</tt>'s, and runs of <tt>/</tt>'s.
84
+ def self.expand_path(zip_path)
85
+ result = []
86
+ source = zip_path.split('/')
87
+
88
+ source.each do |e|
89
+ next if e.empty? || e == '.'
90
+
91
+ if e == '..' && ! (result.last.nil? || result.last == '..') then
92
+ result.pop
93
+ else
94
+ result.push(e)
95
+ end
96
+ end
97
+ result.shift while result.first == '..'
98
+
99
+ result.join('/')
100
+ end
101
+
102
+ # Creates a new Entry based upon a file, symlink, or directory. _file_path_
103
+ # points to the source item. _options_ is a Hash optionally containing the
104
+ # following:
105
+ # <b>:zip_path</b>::
106
+ # The path for the entry in the archive where `/' is the file separator
107
+ # character. This defaults to the basename of _file_path_ if unspecified.
108
+ # <b>:follow_symlinks</b>::
109
+ # When set to +true+ (the default), symlinks are treated as the files or
110
+ # directories to which they point.
111
+ # <b>:codec</b>::
112
+ # When unset, the default codec for file entries is used; otherwise, a
113
+ # file entry which is created will use the codec set with this option.
114
+ #
115
+ # Raises Archive::Zip::EntryError if processing the given file path results
116
+ # in a file not found error.
117
+ def self.from_file(file_path, options = {})
118
+ zip_path = options.has_key?(:zip_path) ?
119
+ expand_path(options[:zip_path]) :
120
+ ::File.basename(file_path)
121
+ follow_symlinks = options.has_key?(:follow_symlinks) ?
122
+ options[:follow_symlinks] :
123
+ true
124
+
125
+ # Avoid repeatedly stat'ing the file by storing the stat structure once.
126
+ begin
127
+ stat = follow_symlinks ?
128
+ ::File.stat(file_path) :
129
+ ::File.lstat(file_path)
130
+ rescue Errno::ENOENT
131
+ if ::File.symlink?(file_path) then
132
+ raise Zip::EntryError,
133
+ "symlink at `#{file_path}' points to a non-existent file `#{::File.readlink(file_path)}'"
134
+ else
135
+ raise Zip::EntryError, "no such file or directory `#{file_path}'"
136
+ end
137
+ end
138
+
139
+ # Ensure that zip paths for directories end with '/'.
140
+ if stat.directory? then
141
+ zip_path += '/'
142
+ end
143
+
144
+ if stat.symlink? then
145
+ entry = Entry::Symlink.new(zip_path)
146
+ entry.link_target = ::File.readlink(file_path)
147
+ elsif stat.file? then
148
+ entry = Entry::File.new(zip_path)
149
+ entry.file_path = file_path
150
+ entry.codec = options[:codec] unless options[:codec].nil?
151
+ elsif stat.directory? then
152
+ entry = Entry::Directory.new(zip_path)
153
+ else
154
+ raise Zip::EntryError,
155
+ "unsupported file type `#{stat.ftype}' for file `#{file_path}'"
156
+ end
157
+ entry.uid = stat.uid
158
+ entry.gid = stat.gid
159
+ entry.mtime = stat.mtime
160
+ entry.atime = stat.atime
161
+ entry.mode = stat.mode
162
+
163
+ entry
164
+ end
165
+
166
+ # Creates and returns a new entry object by parsing from the current
167
+ # position of _io_. _io_ must be a readable, IO-like object which provides
168
+ # a _readbytes_ method, and it must be positioned at the start of a central
169
+ # file record following the signature for that record.
170
+ #
171
+ # <b>NOTE:</b> For now _io_ MUST be seekable and report such by returning
172
+ # +true+ from its <i>seekable?</i> method. See IO#seekable?.
173
+ #
174
+ # Currently, the only entry objects returned are instances of
175
+ # Archive::Zip::Entry::File, Archive::Zip::Entry::Directory, and
176
+ # Archive::Zip::Entry::Symlink. Any other kind of entry will be mapped into
177
+ # an instance of Archive::Zip::Entry::File.
178
+ #
179
+ # Raises Archive::Zip::IOError if _io_ is not seekable. Raises
180
+ # Archive::Zip::EntryError for any other errors related to processing the
181
+ # entry.
182
+ def self.parse(io)
183
+ # Error out if the IO object is not confirmed seekable.
184
+ unless io.respond_to?(:seekable?) and io.seekable? then
185
+ raise Zip::IOError, 'non-seekable IO object given'
186
+ end
187
+
188
+ # Parse the central file record and then use the information found there
189
+ # to locate and parse the corresponding local file record.
190
+ cfr = parse_central_file_record(io)
191
+ next_record_position = io.pos
192
+ io.seek(cfr.local_header_position)
193
+ unless io.readbytes(4) == LFH_SIGNATURE then
194
+ raise Zip::EntryError, 'bad local file header signature'
195
+ end
196
+ lfr = parse_local_file_record(io, cfr.compressed_size)
197
+
198
+ # Check to ensure that the contents of the central file record and the
199
+ # local file record which are supposed to be duplicated are in fact the
200
+ # same.
201
+ compare_file_records(lfr, cfr)
202
+
203
+ # Raise an error if the codec is not supported.
204
+ unless Codec.supported?(cfr.compression_method) then
205
+ raise Zip::EntryError,
206
+ "`#{cfr.zip_path}': unsupported compression method"
207
+ end
208
+
209
+ # Load the correct codec.
210
+ codec = Codec.create(cfr.compression_method, cfr.general_purpose_flags)
211
+ # Set up a data descriptor with expected values for later comparison.
212
+ data_descriptor = DataDescriptor.new(
213
+ cfr.crc32,
214
+ cfr.compressed_size,
215
+ cfr.uncompressed_size
216
+ )
217
+ # Create the entry.
218
+ expanded_path = expand_path(cfr.zip_path)
219
+ if cfr.zip_path[-1..-1] == '/' then
220
+ # This is a directory entry.
221
+ begin
222
+ data_descriptor.verify(DataDescriptor.new(0, 0, 0))
223
+ rescue => e
224
+ raise Zip::EntryError, "`#{cfr.zip_path}': #{e.message}"
225
+ end
226
+ entry = Entry::Directory.new(expanded_path)
227
+ elsif (cfr.external_file_attributes >> 16) & 0770000 == 0120000 then
228
+ # This is a symlink entry.
229
+ entry = Entry::Symlink.new(expanded_path)
230
+ decompressor = codec.decompressor(
231
+ IOWindow.new(io, io.pos, cfr.compressed_size)
232
+ )
233
+ entry.link_target = decompressor.read
234
+ begin
235
+ data_descriptor.verify(decompressor.data_descriptor)
236
+ rescue => e
237
+ raise Zip::EntryError, "`#{cfr.zip_path}': #{e.message}"
238
+ end
239
+ decompressor.close
240
+ else
241
+ # Anything else is a file entry.
242
+ entry = Entry::File.new(expanded_path)
243
+ entry.file_data = codec.decompressor(
244
+ IOWindow.new(io, io.pos, cfr.compressed_size)
245
+ )
246
+ entry.expected_data_descriptor = data_descriptor
247
+ end
248
+
249
+ # Set some entry metadata.
250
+ entry.mtime = cfr.mtime
251
+ # Only set mode bits for the entry if the external file attributes are
252
+ # Unix-compatible.
253
+ if cfr.made_by_version & 0xFF00 == 0x0300 then
254
+ entry.mode = cfr.external_file_attributes >> 16
255
+ end
256
+ entry.comment = cfr.comment
257
+ cfr.extra_fields.each { |ef| entry.add_extra_field(ef) }
258
+ lfr.extra_fields.each { |ef| entry.add_extra_field(ef) }
259
+
260
+ # Return to the beginning of the next central directory record.
261
+ io.seek(next_record_position)
262
+
263
+ entry
264
+ end
265
+
266
+ private
267
+
268
+ # Parses a central file record and returns a CFHRecord instance containing
269
+ # the parsed data. _io_ must be a readable, IO-like object which provides a
270
+ # _readbytes_ method, and it must be positioned at the start of a central
271
+ # file record following the signature for that record.
272
+ def self.parse_central_file_record(io)
273
+ cfr = CFHRecord.new
274
+
275
+ cfr.made_by_version,
276
+ cfr.extraction_version,
277
+ cfr.general_purpose_flags,
278
+ cfr.compression_method,
279
+ dos_mtime,
280
+ cfr.crc32,
281
+ cfr.compressed_size,
282
+ cfr.uncompressed_size,
283
+ file_name_length,
284
+ extra_fields_length,
285
+ comment_length,
286
+ cfr.disk_number_start,
287
+ cfr.internal_file_attributes,
288
+ cfr.external_file_attributes,
289
+ cfr.local_header_position = io.readbytes(42).unpack('vvvvVVVVvvvvvVV')
290
+
291
+ cfr.zip_path = io.readbytes(file_name_length)
292
+ cfr.extra_fields = parse_extra_fields(io.readbytes(extra_fields_length))
293
+ cfr.comment = io.readbytes(comment_length)
294
+
295
+ # Convert from MSDOS time to Unix time.
296
+ cfr.mtime = DOSTime.new(dos_mtime).to_time
297
+
298
+ cfr
299
+ rescue EOFError, TruncatedDataError
300
+ raise Zip::EntryError, 'unexpected end of file'
301
+ end
302
+
303
+ # Parses a local file record and returns a LFHRecord instance containing the
304
+ # parsed data. _io_ must be a readable, IO-like object which provides a
305
+ # readbytes method, and it must be positioned at the start of a local file
306
+ # record following the signature for that record.
307
+ #
308
+ # If the record to be parsed is flagged to have a trailing data descriptor
309
+ # record, _expected_compressed_size_ must be set to an integer counting the
310
+ # number of bytes of compressed data to skip in order to find the trailing
311
+ # data descriptor record, and _io_ must be seekable by providing _pos_ and
312
+ # <i>pos=</i> methods.
313
+ def self.parse_local_file_record(io, expected_compressed_size = nil)
314
+ lfr = LFHRecord.new
315
+
316
+ lfr.extraction_version,
317
+ lfr.general_purpose_flags,
318
+ lfr.compression_method,
319
+ dos_mtime,
320
+ lfr.crc32,
321
+ lfr.compressed_size,
322
+ lfr.uncompressed_size,
323
+ file_name_length,
324
+ extra_fields_length = io.readbytes(26).unpack('vvvVVVVvv')
325
+
326
+ lfr.zip_path = io.readbytes(file_name_length)
327
+ lfr.extra_fields = parse_extra_fields(io.readbytes(extra_fields_length))
328
+
329
+ # Convert from MSDOS time to Unix time.
330
+ lfr.mtime = DOSTime.new(dos_mtime).to_time
331
+
332
+ if lfr.general_purpose_flags & FLAG_DATA_DESCRIPTOR_FOLLOWS > 0 then
333
+ saved_pos = io.pos
334
+ io.pos += expected_compressed_size
335
+ # Because the ZIP specification has a history of murkiness, some
336
+ # libraries create trailing data descriptor records with a preceding
337
+ # signature while others do not.
338
+ # This handles both cases.
339
+ possible_signature = io.readbytes(4)
340
+ if possible_signature == DD_SIGNATURE then
341
+ lfr.crc32,
342
+ lfr.compressed_size,
343
+ lfr.uncompressed_size = io.readbytes(12).unpack('VVV')
344
+ else
345
+ lfr.crc32 = possible_signature.unpack('V')[0]
346
+ lfr.compressed_size,
347
+ lfr.uncompressed_size = io.readbytes(8).unpack('VV')
348
+ end
349
+ io.pos = saved_pos
350
+ end
351
+
352
+ lfr
353
+ rescue EOFError, TruncatedDataError
354
+ raise Zip::EntryError, 'unexpected end of file'
355
+ end
356
+
357
+ # Parses the extra fields for local and central file records and returns an
358
+ # array of extra field objects. _bytes_ must be a String containing all of
359
+ # the extra field data to be parsed.
360
+ def self.parse_extra_fields(bytes)
361
+ StringIO.open(bytes) do |io|
362
+ extra_fields = []
363
+ while ! io.eof? do
364
+ header_id, data_size = io.readbytes(4).unpack('vv')
365
+ data = io.readbytes(data_size)
366
+
367
+ extra_fields << ExtraField.parse(header_id, data)
368
+ end
369
+ extra_fields
370
+ end
371
+ end
372
+
373
+ # Compares the local and the central file records found in _lfr_ and _cfr
374
+ # respectively. Raises Archive::Zip::EntryError if the comparison fails.
375
+ def self.compare_file_records(lfr, cfr)
376
+ # Exclude the extra fields from the comparison since some implementations,
377
+ # such as InfoZip, are known to have differences in the extra fields used
378
+ # in local file records vs. central file records.
379
+ if lfr.zip_path != cfr.zip_path then
380
+ raise Zip::EntryError, "zip path differs between local and central file records: `#{lfr.zip_path}' != `#{cfr.zip_path}'"
381
+ end
382
+ if lfr.extraction_version != cfr.extraction_version then
383
+ raise Zip::EntryError, "`#{cfr.zip_path}': extraction version differs between local and central file records"
384
+ end
385
+ if lfr.crc32 != cfr.crc32 then
386
+ raise Zip::EntryError, "`#{cfr.zip_path}': CRC32 differs between local and central file records"
387
+ end
388
+ if lfr.compressed_size != cfr.compressed_size then
389
+ raise Zip::EntryError, "`#{cfr.zip_path}': compressed size differs between local and central file records"
390
+ end
391
+ if lfr.uncompressed_size != cfr.uncompressed_size then
392
+ raise Zip::EntryError, "`#{cfr.zip_path}': uncompressed size differs between local and central file records"
393
+ end
394
+ if lfr.general_purpose_flags != cfr.general_purpose_flags then
395
+ raise Zip::EntryError, "`#{cfr.zip_path}': general purpose flag differs between local and central file records"
396
+ end
397
+ if lfr.compression_method != cfr.compression_method then
398
+ raise Zip::EntryError, "`#{cfr.zip_path}': compression method differs between local and central file records"
399
+ end
400
+ if lfr.mtime != cfr.mtime then
401
+ raise Zip::EntryError, "`#{cfr.zip_path}': last modified time differs between local and central file records"
402
+ end
403
+ end
404
+
405
+ public
406
+
407
+ # Creates a new, uninitialized Entry instance using the Store compression
408
+ # method. The zip path is initialized to _zip_path_.
409
+ def initialize(zip_path)
410
+ self.zip_path = zip_path
411
+ self.mtime = Time.now
412
+ self.atime = @mtime
413
+ self.uid = nil
414
+ self.gid = nil
415
+ self.mode = 0777
416
+ self.comment = ''
417
+ self.codec = Zip::Codec::Store.new
418
+ @extra_fields = []
419
+ end
420
+
421
+ # The path for this entry in the ZIP archive.
422
+ attr_reader :zip_path
423
+ # The last accessed time.
424
+ attr_accessor :atime
425
+ # The last modified time.
426
+ attr_accessor :mtime
427
+ # The user ID of the owner of this entry.
428
+ attr_accessor :uid
429
+ # The group ID of the owner of this entry.
430
+ attr_accessor :gid
431
+ # The the file mode/permission bits for this entry.
432
+ attr_accessor :mode
433
+ # The comment associated with this entry.
434
+ attr_accessor :comment
435
+ # The selected compression codec.
436
+ attr_accessor :codec
437
+
438
+ # Sets the path in the archive for this entry to _zip_path_ after passing it
439
+ # through Archive::Zip::Entry.expand_path and ensuring that the result is
440
+ # not empty.
441
+ def zip_path=(zip_path)
442
+ @zip_path = Archive::Zip::Entry.expand_path(zip_path)
443
+ if @zip_path.empty? then
444
+ raise ArgumentError, "zip path expands to empty string"
445
+ end
446
+ end
447
+
448
+ # Returns the file type of this entry as the symbol <tt>:unknown</tt>.
449
+ #
450
+ # Override this in concrete subclasses to return an appropriate symbol.
451
+ def ftype
452
+ :unknown
453
+ end
454
+
455
+ # Returns false.
456
+ def file?
457
+ false
458
+ end
459
+
460
+ # Returns false.
461
+ def symlink?
462
+ false
463
+ end
464
+
465
+ # Returns false.
466
+ def directory?
467
+ false
468
+ end
469
+
470
+ # Override this method in descendent classes. It should cause the entry to
471
+ # be extracted from the archive. This implementation does nothing.
472
+ # _options_ should be a hash used for specifying extraction options, the
473
+ # keys of which should not collide with keys used by Archive::Zip#extract.
474
+ def extract(options = {})
475
+ end
476
+
477
+ # Adds _extra_field_ as an extra field specification to this entry. If
478
+ # _extra_field_ is an instance of
479
+ # Archive::Zip::Entry::ExtraField::ExtendedTimestamp, the values of that
480
+ # field are used to set mtime and atime for this entry. If _extra_field_ is
481
+ # an instance of Archive::Zip::Entry::ExtraField::Unix, the values of that
482
+ # field are used to set mtime, atime, uid, and gid for this entry.
483
+ def add_extra_field(extra_field)
484
+ @extra_field_data = nil
485
+ @extra_fields << extra_field
486
+
487
+ if extra_field.kind_of?(ExtraField::ExtendedTimestamp) then
488
+ self.mtime = extra_field.mtime
489
+ self.atime = extra_field.atime
490
+ elsif extra_field.kind_of?(ExtraField::Unix) then
491
+ self.mtime = extra_field.mtime
492
+ self.atime = extra_field.atime
493
+ self.uid = extra_field.uid
494
+ self.gid = extra_field.gid
495
+ end
496
+ self
497
+ end
498
+
499
+ # Writes the local file record for this entry to _io_, a writable, IO-like
500
+ # object which provides a _write_ method. _local_file_record_position_ is
501
+ # the offset within _io_ at which writing will begin. This is used so that
502
+ # when writing to a non-seekable IO object it is possible to avoid calling
503
+ # the _pos_ method of _io_. Returns the number of bytes written.
504
+ #
505
+ # <b>NOTE:</b> This method should only be called by Archive::Zip.
506
+ def dump_local_file_record(io, local_file_record_position)
507
+ @local_file_record_position = local_file_record_position
508
+ bytes_written = 0
509
+
510
+ general_purpose_flags = codec.general_purpose_flags
511
+ # Flag that the data descriptor record will follow the compressed file
512
+ # data of this entry unless the IO object can be access randomly.
513
+ general_purpose_flags |= 0b1000 unless io.seekable?
514
+
515
+ bytes_written += io.write(LFH_SIGNATURE)
516
+ bytes_written += io.write(
517
+ [
518
+ codec.version_needed_to_extract,
519
+ general_purpose_flags,
520
+ codec.compression_method,
521
+ mtime.to_dos_time.to_i,
522
+ 0,
523
+ 0,
524
+ 0,
525
+ zip_path.length,
526
+ extra_field_data.length
527
+ ].pack('vvvVVVVvv')
528
+ )
529
+ bytes_written += io.write(zip_path)
530
+ bytes_written += io.write(extra_field_data)
531
+
532
+ # Get a compressor, write all the file data to it, and get a data
533
+ # descriptor from it.
534
+ codec.compressor(io) do |c|
535
+ dump_compressed_data(c)
536
+ c.close(false)
537
+ @data_descriptor = c.data_descriptor
538
+ end
539
+
540
+ bytes_written += @data_descriptor.compressed_size
541
+ if io.seekable? then
542
+ saved_position = io.pos
543
+ io.pos = @local_file_record_position + 14
544
+ @data_descriptor.dump(io)
545
+ io.pos = saved_position
546
+ else
547
+ bytes_written += io.write(DD_SIGNATURE)
548
+ bytes_written += @data_descriptor.dump(io)
549
+ end
550
+
551
+ bytes_written
552
+ end
553
+
554
+ # Writes the central file record for this entry to _io_, a writable, IO-like
555
+ # object which provides a _write_ method. Returns the number of bytes
556
+ # written.
557
+ #
558
+ # <b>NOTE:</b> This method should only be called by Archive::Zip.
559
+ def dump_central_file_record(io)
560
+ bytes_written = 0
561
+
562
+ general_purpose_flags = codec.general_purpose_flags
563
+ # Flag that the data descriptor record will follow the compressed file
564
+ # data of this entry unless the IO object can be access randomly.
565
+ general_purpose_flags |= FLAG_DATA_DESCRIPTOR_FOLLOWS unless io.seekable?
566
+
567
+ bytes_written += io.write(CFH_SIGNATURE)
568
+ bytes_written += io.write(
569
+ [
570
+ version_made_by,
571
+ codec.version_needed_to_extract,
572
+ general_purpose_flags,
573
+ codec.compression_method,
574
+ mtime.to_dos_time.to_i
575
+ ].pack('vvvvV')
576
+ )
577
+ bytes_written += @data_descriptor.dump(io)
578
+ bytes_written += io.write(
579
+ [
580
+ zip_path.length,
581
+ extra_field_data.length,
582
+ comment.length,
583
+ 0,
584
+ internal_file_attributes,
585
+ external_file_attributes,
586
+ @local_file_record_position
587
+ ].pack('vvvvvVV')
588
+ )
589
+ bytes_written += io.write(zip_path)
590
+ bytes_written += io.write(extra_field_data)
591
+ bytes_written += io.write(comment)
592
+
593
+ bytes_written
594
+ end
595
+
596
+ private
597
+
598
+ def version_made_by
599
+ 0x0314
600
+ end
601
+
602
+ def extra_field_data
603
+ return @extra_field_data unless @extra_field_data.nil?
604
+
605
+ @extra_field_data = @extra_fields.collect do |extra_field|
606
+ unless extra_field.kind_of?(ExtraField::ExtendedTimestamp) ||
607
+ extra_field.kind_of?(ExtraField::Unix) then
608
+ extra_field.dump
609
+ else
610
+ ''
611
+ end
612
+ end.join +
613
+ ExtraField::ExtendedTimestamp.new(mtime, atime, nil).dump +
614
+ ExtraField::Unix.new(mtime, atime, uid, gid).dump
615
+ end
616
+
617
+ def internal_file_attributes
618
+ 0x0000
619
+ end
620
+
621
+ def external_file_attributes
622
+ # Put Unix attributes into the high word and DOS attributes into the low
623
+ # word.
624
+ (mode << 16) + (directory? ? 0x10 : 0)
625
+ end
626
+ end
627
+ end; end
628
+
629
+ module Archive; class Zip; module Entry
630
+ # Archive::Zip::Entry::Directory represents a directory entry within a Zip
631
+ # archive.
632
+ class Directory
633
+ include Archive::Zip::Entry
634
+
635
+ # Inherits the behavior of Archive::Zip::Entry#zip_path= but ensures that
636
+ # there is a trailing slash (<tt>/</tt>) on the end of the path.
637
+ def zip_path=(zip_path)
638
+ super(zip_path)
639
+ @zip_path += '/'
640
+ end
641
+
642
+ # Returns the file type of this entry as the symbol <tt>:directory</tt>.
643
+ def ftype
644
+ :directory
645
+ end
646
+
647
+ # Returns +true+.
648
+ def directory?
649
+ true
650
+ end
651
+
652
+ # Overridden in order to ensure that the proper mode bits are set for a
653
+ # directory.
654
+ def mode=(mode)
655
+ super(040000 | (mode & 07777))
656
+ end
657
+
658
+ # Extracts this entry.
659
+ #
660
+ # _options_ is a Hash optionally containing the following:
661
+ # <b>:file_path</b>::
662
+ # Specifies the path to which this entry will be extracted. Defaults to
663
+ # the zip path of this entry.
664
+ # <b>:permissions</b>::
665
+ # When set to +false+ (the default), POSIX mode/permission bits will be
666
+ # ignored. Otherwise, they will be restored if possible.
667
+ # <b>:ownerships</b>::
668
+ # When set to +false+ (the default), user and group ownerships will be
669
+ # ignored. On most systems, only a superuser is able to change
670
+ # ownerships, so setting this option to +true+ as a regular user may have
671
+ # no effect.
672
+ # <b>:times</b>::
673
+ # When set to +false+ (the default), last accessed and last modified times
674
+ # will be ignored. Otherwise, they will be restored if possible.
675
+ def extract(options = {})
676
+ # Ensure that unspecified options have default values.
677
+ file_path = options.has_key?(:file_path) ?
678
+ options[:file_path].to_s :
679
+ @zip_path
680
+ restore_permissions = options.has_key?(:permissions) ?
681
+ options[:permissions] :
682
+ false
683
+ restore_ownerships = options.has_key?(:ownerships) ?
684
+ options[:ownerships] :
685
+ false
686
+ restore_times = options.has_key?(:times) ?
687
+ options[:times] :
688
+ false
689
+
690
+ # Make the directory.
691
+ FileUtils.mkdir_p(file_path)
692
+
693
+ # Restore the metadata.
694
+ ::File.chmod(mode, file_path) if restore_permissions
695
+ ::File.chown(uid, gid, file_path) if restore_ownerships
696
+ ::File.utime(atime, mtime, file_path) if restore_times
697
+
698
+ nil
699
+ end
700
+
701
+ private
702
+
703
+ # Directory entries do not have compressed data to write, so do nothing.
704
+ def dump_compressed_data(io)
705
+ end
706
+ end
707
+ end; end; end
708
+
709
+ module Archive; class Zip; module Entry
710
+ # Archive::Zip::Entry::Symlink represents a symlink entry withing a Zip
711
+ # archive.
712
+ class Symlink
713
+ include Archive::Zip::Entry
714
+
715
+ # A string indicating the target of a symlink.
716
+ attr_accessor :link_target
717
+
718
+ # Returns the file type of this entry as the symbol <tt>:symlink</tt>.
719
+ def ftype
720
+ :symlink
721
+ end
722
+
723
+ # Returns +true+.
724
+ def symlink?
725
+ true
726
+ end
727
+
728
+ # Overridden in order to ensure that the proper mode bits are set for a
729
+ # symlink.
730
+ def mode=(mode)
731
+ super(0120000 | (mode & 07777))
732
+ end
733
+
734
+ # Extracts this entry.
735
+ #
736
+ # _options_ is a Hash optionally containing the following:
737
+ # <b>:file_path</b>::
738
+ # Specifies the path to which this entry will be extracted. Defaults to
739
+ # the zip path of this entry.
740
+ # <b>:permissions</b>::
741
+ # When set to +false+ (the default), POSIX mode/permission bits will be
742
+ # ignored. Otherwise, they will be restored if possible.
743
+ # <b>:ownerships</b>::
744
+ # When set to +false+ (the default), user and group ownerships will be
745
+ # ignored. On most systems, only a superuser is able to change
746
+ # ownerships, so setting this option to +true+ as a regular user may have
747
+ # no effect.
748
+ #
749
+ # Raises Archive::Zip::ExtractError if the link_target attribute is not
750
+ # specified.
751
+ def extract(options = {})
752
+ raise Zip::ExtractError, 'link_target is nil' if link_target.nil?
753
+
754
+ # Ensure that unspecified options have default values.
755
+ file_path = options.has_key?(:file_path) ?
756
+ options[:file_path].to_s :
757
+ @zip_path
758
+ restore_permissions = options.has_key?(:permissions) ?
759
+ options[:permissions] :
760
+ false
761
+ restore_ownerships = options.has_key?(:ownerships) ?
762
+ options[:ownerships] :
763
+ false
764
+
765
+ # Create the containing directory tree if necessary.
766
+ parent_dir = ::File.dirname(file_path)
767
+ FileUtils.mkdir_p(parent_dir) unless ::File.exist?(parent_dir)
768
+
769
+ # Create the symlink.
770
+ ::File.symlink(link_target, file_path)
771
+
772
+ # Restore the metadata.
773
+ # NOTE: Ruby does not have the ability to restore atime and mtime on
774
+ # symlinks at this time (version 1.8.6).
775
+ begin
776
+ ::File.lchmod(mode, file_path) if restore_permissions
777
+ rescue NotImplementedError
778
+ # Ignore on platforms that do not support lchmod.
779
+ end
780
+ begin
781
+ ::File.lchown(uid, gid, file_path) if restore_ownerships
782
+ rescue NotImplementedError
783
+ # Ignore on platforms that do not support lchown.
784
+ end
785
+
786
+ nil
787
+ end
788
+
789
+ private
790
+
791
+ # Write the link target to _io_ as the file data for the entry.
792
+ def dump_compressed_data(io)
793
+ io.write(@link_target)
794
+ end
795
+ end
796
+ end; end; end
797
+
798
+ module Archive; class Zip; module Entry
799
+ # Archive::Zip::Entry::File represents a file entry within a Zip archive.
800
+ class File
801
+ include Archive::Zip::Entry
802
+
803
+ # Creates a new file entry where _zip_path_ is the path to the entry in the
804
+ # ZIP archive. The Archive::Zip::Codec::Deflate codec with the default
805
+ # compression level set (NORMAL) is used by default for compression.
806
+ def initialize(zip_path)
807
+ super(zip_path)
808
+ @file_path = nil
809
+ @file_data = nil
810
+ @expected_data_descriptor = nil
811
+ @codec = Zip::Codec::Deflate.new
812
+ end
813
+
814
+ # An Archive::Zip::Entry::DataDescriptor instance which should contain the
815
+ # expected CRC32 checksum, compressed size, and uncompressed size for the
816
+ # file data. When not +nil+, this is used by #extract to confirm that the
817
+ # data extraction was successful.
818
+ attr_accessor :expected_data_descriptor
819
+
820
+ # Returns a readable, IO-like object containing uncompressed file data. If
821
+ # the file data has not been explicitly set previously, this will return a
822
+ # Archive::Zip::Codec::Store::Unstore instance wrapping either a File
823
+ # instance based on the +file_path+ attribute, if set, or an empty StringIO
824
+ # instance otherwise.
825
+ #
826
+ # <b>NOTE:</b> It is the responsibility of the user of this attribute to
827
+ # ensure that the #close method of the returned IO-like object is called
828
+ # when the object is no longer needed.
829
+ def file_data
830
+ if @file_data.nil? || @file_data.closed? then
831
+ if @file_path.nil? then
832
+ @file_data = StringIO.new
833
+ else
834
+ @file_data = ::File.new(@file_path, 'rb')
835
+ end
836
+ # Ensure that the IO-like object can return CRC32 and data size
837
+ # information so that it's possible to verify extraction later if
838
+ # desired.
839
+ @file_data = Zip::Codec::Store.new.decompressor(@file_data)
840
+ end
841
+ @file_data
842
+ end
843
+
844
+ # Sets the +file_data+ attribute of this object to _file_data_. If
845
+ # _file_data_ is a String, it will be wrapped in a StringIO instance;
846
+ # otherwise, _file_data_ must be a readable, IO-like object. _file_data_ is
847
+ # then wrapped inside an Archive::Zip::Codec::Store::Unstore instance before
848
+ # finally setting the +file_data+ attribute.
849
+ #
850
+ # <b>NOTE:</b> As a side effect, the +file_path+ attribute for this object
851
+ # will be set to +nil+.
852
+ def file_data=(file_data)
853
+ @file_path = nil
854
+ if file_data.kind_of?(String)
855
+ @file_data = StringIO.new(file_data)
856
+ else
857
+ @file_data = file_data
858
+ end
859
+ # Ensure that the IO-like object can return CRC32 and data size
860
+ # information so that it's possible to verify extraction later if desired.
861
+ unless @file_data.respond_to?(:data_descriptor) then
862
+ @file_data = Zip::Codec::Store.new.decompressor(@file_data)
863
+ end
864
+ @file_data
865
+ end
866
+
867
+ # The path to a file whose contents are to be used for uncompressed file
868
+ # data. This will be +nil+ if the +file_data+ attribute is set directly.
869
+ attr_reader :file_path
870
+
871
+ # Sets the +file_path+ attribute to _file_path_ which should be a String
872
+ # usable with File#new to open a file for reading which will provide the
873
+ # IO-like object for the +file_data+ attribute.
874
+ def file_path=(file_path)
875
+ @file_data = nil
876
+ @file_path = file_path
877
+ end
878
+
879
+ # Returns the file type of this entry as the symbol <tt>:file</tt>.
880
+ def ftype
881
+ :file
882
+ end
883
+
884
+ # Returns +true+.
885
+ def file?
886
+ true
887
+ end
888
+
889
+ # Overridden in order to ensure that the proper mode bits are set for a
890
+ # file.
891
+ def mode=(mode)
892
+ super(0100000 | (mode & 07777))
893
+ end
894
+
895
+ # Extracts this entry.
896
+ #
897
+ # _options_ is a Hash optionally containing the following:
898
+ # <b>:file_path</b>::
899
+ # Specifies the path to which this entry will be extracted. Defaults to
900
+ # the zip path of this entry.
901
+ # <b>:permissions</b>::
902
+ # When set to +false+ (the default), POSIX mode/permission bits will be
903
+ # ignored. Otherwise, they will be restored if possible.
904
+ # <b>:ownerships</b>::
905
+ # When set to +false+ (the default), user and group ownerships will be
906
+ # ignored. On most systems, only a superuser is able to change
907
+ # ownerships, so setting this option to +true+ as a regular user may have
908
+ # no effect.
909
+ # <b>:times</b>::
910
+ # When set to +false+ (the default), last accessed and last modified times
911
+ # will be ignored. Otherwise, they will be restored if possible.
912
+ #
913
+ # Raises Archive::Zip::ExtractError if the extracted file data appears
914
+ # corrupt.
915
+ def extract(options = {})
916
+ # Ensure that unspecified options have default values.
917
+ file_path = options.has_key?(:file_path) ?
918
+ options[:file_path].to_s :
919
+ @zip_path
920
+ restore_permissions = options.has_key?(:permissions) ?
921
+ options[:permissions] :
922
+ false
923
+ restore_ownerships = options.has_key?(:ownerships) ?
924
+ options[:ownerships] :
925
+ false
926
+ restore_times = options.has_key?(:times) ?
927
+ options[:times] :
928
+ false
929
+
930
+ # Create the containing directory tree if necessary.
931
+ parent_dir = ::File.dirname(file_path)
932
+ FileUtils.mkdir_p(parent_dir) unless ::File.exist?(parent_dir)
933
+
934
+ # Dump the file contents.
935
+ ::File.open(file_path, 'wb') do |f|
936
+ while buffer = file_data.read(4096) do
937
+ f.write(buffer)
938
+ end
939
+ end
940
+
941
+ # Verify that the extracted data is good.
942
+ begin
943
+ unless expected_data_descriptor.nil? then
944
+ expected_data_descriptor.verify(file_data.data_descriptor)
945
+ end
946
+ rescue => e
947
+ raise Zip::ExtractError, "`#{zip_path}': #{e.message}"
948
+ end
949
+
950
+ # Restore the metadata.
951
+ ::File.chmod(mode, file_path) if restore_permissions
952
+ ::File.chown(uid, gid, file_path) if restore_ownerships
953
+ ::File.utime(atime, mtime, file_path) if restore_times
954
+
955
+ # Attempt to rewind the file data back to the beginning, but ignore
956
+ # errors.
957
+ begin
958
+ file_data.rewind
959
+ rescue
960
+ # Ignore.
961
+ end
962
+
963
+ nil
964
+ end
965
+
966
+ private
967
+
968
+ # Write the file data to _io_.
969
+ def dump_compressed_data(io)
970
+ while buffer = file_data.read(4096) do io.write(buffer) end
971
+
972
+ # Attempt to ensure that the file data will still be in a readable state
973
+ # at the beginning of the data for the next user, but close it if possible
974
+ # in order to conserve resources.
975
+ if file_path.nil? then
976
+ # When the file_path attribute is not set, the file_data method cannot
977
+ # reinitialize the IO object it returns, so attempt to rewind the file
978
+ # data back to the beginning, but ignore errors.
979
+ begin
980
+ file_data.rewind
981
+ rescue
982
+ # Ignore.
983
+ end
984
+ else
985
+ # Since the file_path attribute is set, the file_data method will
986
+ # reinitialize the IO object it returns if we close the object here.
987
+ file_data.close
988
+ end
989
+ end
990
+ end
991
+ end; end; end