require "./file_info" # Reads zip file entries sequentially from an `IO`. # # NOTE: Entries might not have correct values # for crc32, compressed_size, uncompressed_size and comment, # because when reading a zip file directly from a stream this # information might be stored later in the zip stream. # If you need this information, consider using `Zip::File`. # # ### Example # # ``` # require "compress/zip" # # Compress::Zip::Reader.open("./file.zip") do |zip| # zip.each_entry do |entry| # p entry.filename # p entry.file? # p entry.dir? # p entry.io.gets_to_end # end # end # ``` class Compress::Zip::Reader # Whether to close the enclosed `IO` when closing this reader. property? sync_close = false # Returns `true` if this reader is closed. getter? closed = false # Creates a new reader from the given *io*. def initialize(@io : IO, @sync_close = false) @reached_end = false @read_data_descriptor = true end # Creates a new reader from the given *filename*. def self.new(filename : Path | String) new(::File.new(filename), sync_close: true) end # Creates a new reader from the given *io*, yields it to the given block, # and closes it at the end. def self.open(io : IO, sync_close = false, &) reader = new(io, sync_close: sync_close) yield reader ensure reader.close end # Creates a new reader from the given *filename*, yields it to the given block, # and closes it at the end. def self.open(filename : Path | String, &) reader = new(filename) yield reader ensure reader.close end # Reads the next entry in the zip, or `nil` if there # are no more entries. # # After reading a next entry, previous entries can no # longer be read (their `IO` will be closed.) def next_entry : Entry? return nil if @reached_end if last_entry = @last_entry last_entry.close skip_data_descriptor(last_entry) end while true signature = read UInt32 case signature when FileInfo::SIGNATURE # Found file info signature break when FileInfo::DATA_DESCRIPTOR_SIGNATURE if last_entry && !@read_data_descriptor # Consider the case where a data descriptor comes after # a STORED entry: skip data descriptor and expect file signature next read_data_descriptor(last_entry) next else raise Error.new("Unexpected data descriptor when reading zip") end else # Other signature: we are done with entries (next comes metadata) @reached_end = true return nil end end @last_entry = Entry.new(@io) end # Yields each entry in the zip to the given block. def each_entry(&) while entry = next_entry yield entry end end # Closes this zip reader. def close : Nil return if @closed @closed = true @io.close if @sync_close end private def skip_data_descriptor(entry) if entry.compression_method.deflated? && entry.bit_3_set? # The data descriptor signature is optional: if we # find it, we read the data descriptor info normally; # otherwise, the first four bytes are the crc32 value. signature = read UInt32 if signature == FileInfo::DATA_DESCRIPTOR_SIGNATURE read_data_descriptor(entry) else read_data_descriptor(entry, crc32: signature) end @read_data_descriptor = true else @read_data_descriptor = false verify_checksum(entry) end end private def read_data_descriptor(entry, crc32 = nil) entry.crc32 = crc32 || (read UInt32) entry.compressed_size = read UInt32 entry.uncompressed_size = read UInt32 verify_checksum(entry) end private def verify_checksum(entry) if entry.crc32 != entry.checksum_io.crc32 raise Compress::Zip::Error.new("Checksum failed for entry #{entry.filename} (expected #{entry.crc32}, got #{entry.checksum_io.crc32}") end end private def read(type) @io.read_bytes(type, IO::ByteFormat::LittleEndian) end # A entry inside a `Zip::Reader`. # # Use the `io` method to read from it. class Entry include FileInfo # :nodoc: def initialize(io) super(at_file_header: io) @io = ChecksumReader.new(decompressor_for(io), @filename) @closed = false end # Returns an `IO` to the entry's data. def io : IO @io end protected def checksum_io @io end protected def close return if @closed @closed = true @io.skip_to_end @io.close end end end