require "./file_info" # Provides random read access to zip file entries stores inside # a `File` or an `IO::Memory`. # # ### Example # # ``` # require "compress/zip" # # Compress::Zip::File.open("./file.zip") do |file| # # Iterate through all entries printing their filename and contents # file.entries.each do |entry| # p entry.filename # entry.open do |io| # p io.gets_to_end # end # end # # # Random access to entries by filename is also provided # entry = file["some_file.txt"] # entry.open do |io| # p io.gets_to_end # end # end # ``` class Compress::Zip::File # Returns all entries inside this zip file. getter entries : Array(Entry) # Returns `true` if this zip file is closed. getter? closed = false # Returns the zip file comment. getter comment = "" # Opens a `Zip::File` for reading from the given *io*. def initialize(@io : IO, @sync_close = false) directory_end_offset = find_directory_end_offset entries_size, directory_offset = read_directory_end(directory_end_offset) @entries = Array(Entry).new(entries_size) @entries_by_filename = {} of String => Entry read_entries(directory_offset, entries_size) end # Opens a `Zip::File` for reading from the given *filename*. def self.new(filename : Path | String) new(::File.new(filename), sync_close: true) end # Opens a `Zip::File` for reading from the given *io*, yields # it to the given block, and closes it at the end. def self.open(io : IO, sync_close = false, &) zip = new io, sync_close yield zip ensure zip.close end # Opens a `Zip::File` for reading from the given *filename*, yields # it to the given block, and closes it at the end. def self.open(filename : Path | String, &) zip = new filename yield zip ensure zip.close end # Returns the entry that has the given filename, or # raises `KeyError` if no such entry exists. def [](filename : Path | String) : Entry self[filename]? || raise(KeyError.new("Missing zip entry: #{filename}")) end # Returns the entry that has the given filename, or # `nil` if no such entry exists. def []?(filename : Path | String) : Entry? @entries_by_filename[filename.to_s]? end # Closes this zip file. def close : Nil return if @closed @closed = true if @sync_close @io.close end end # Tries to find the directory end offset (by searching its signature) # in the last 64, 1024 and 65K bytes (in that order) private def find_directory_end_offset find_directory_end_offset(64) || find_directory_end_offset(1024) || find_directory_end_offset(65 * 1024) || raise Compress::Zip::Error.new("Couldn't find directory end signature in the last 65KB") end private def find_directory_end_offset(buf_size) @io.seek(0, IO::Seek::End) size = @io.pos buf_size = Math.min(buf_size, size) @io.pos = size - buf_size buf = Bytes.new(buf_size) @io.read_fully(buf) i = buf_size - 1 - 4 while i >= 0 # These are the bytes the make up the directory end signature, # according to the spec break if buf[i] == 0x50 && buf[i + 1] == 0x4b && buf[i + 2] == 0x05 && buf[i + 3] == 0x06 i -= 1 end i == -1 ? nil : size - buf_size + i end private def read_directory_end(directory_end_offset) @io.pos = directory_end_offset signature = read UInt32 if signature != Zip::END_OF_CENTRAL_DIRECTORY_HEADER_SIGNATURE raise Error.new("Expected end of central directory header signature, not 0x#{signature.to_s(16)}") end read UInt16 # number of this disk read UInt16 # disk start read UInt16 # number of entries in disk entries_size = read UInt16 # number of total entries read UInt32 # size of the central directory directory_offset = read UInt32 # offset of central directory comment_length = read UInt16 # comment length if comment_length != 0 @comment = @io.read_string(comment_length) end {entries_size, directory_offset} end private def read_entries(directory_offset, entries_size) @io.pos = directory_offset entries_size.times do signature = read UInt32 if signature != Zip::CENTRAL_DIRECTORY_HEADER_SIGNATURE raise Error.new("Expected directory header signature, not 0x#{signature.to_s(16)}") end entry = Entry.new(@io) @entries << entry @entries_by_filename[entry.filename] = entry end end private def read(type) @io.read_bytes(type, IO::ByteFormat::LittleEndian) end # An entry inside a `Zip::File`. # # Use the `open` method to read from it. class Entry include FileInfo # :nodoc: def initialize(@io : IO) super(at_central_directory_header: io) end # Yields an `IO` to read this entry's contents. # Multiple entries can be opened and read concurrently. def open(&) @io.read_at(data_offset.to_i32, compressed_size.to_i32) do |io| io = decompressor_for(io, is_sized: true) checksum_reader = ChecksumReader.new(io, filename, verify: crc32) yield checksum_reader end end private getter(data_offset : UInt32) do # Apparently a zip entry might have different extra bytes # in the local file header and central directory header, # so to know the data offset we must read them again. # # The bytes inside a local file header, from the signature # and up to the extra length field, sum up 30 bytes. # # This 30 and 22 constants are burned inside the zip spec and # will never change. @io.read_at(offset.to_i32, 30) do |io| # at least check that the signature is OK (these are 4 bytes) signature = read(io, UInt32) if signature != FileInfo::SIGNATURE raise Compress::Zip::Error.new("Wrong local file header signature (expected 0x#{FileInfo::SIGNATURE.to_s(16)}, got 0x#{signature.to_s(16)})") end # Skip most of the headers except filename length and extra length # (skip 22, so we already read 26 bytes) io.skip(22) # With these two we read 4 bytes more, so we are at 30 bytes filename_length = read(io, UInt16) extra_length = read(io, UInt16) # The data of this entry comes at the local file header offset # plus 30 bytes (the ones we just skipped) plus skipping the # filename's bytes plus skipping the extra bytes. @offset + 30 + filename_length + extra_length end end end end