require "./file_info" # Writes (streams) zip entries to an `IO`. # # ### Example # # ``` # require "compress/zip" # # File.open("./file.zip", "w") do |file| # Compress::Zip::Writer.open(file) do |zip| # # Add a file with a String content # zip.add "foo.txt", "contents of foo" # # # Add a file and write data to it through an IO # zip.add("bar.txt") do |io| # io << "contents of bar" # end # # # Add a file by referencing a file in the filesystem # # (the file is automatically closed after this call) # zip.add("baz.txt", File.open("./some_file.txt")) # end # end # ``` class Compress::Zip::Writer # Whether to close the enclosed `IO` when closing this writer. property? sync_close = false # Returns `true` if this writer is closed. getter? closed = false # Sets the zip file comment setter comment = "" # Creates a new writer to the given *io*. def initialize(@io : IO, @sync_close = false) @entries = [] of Entry @compressed_size_counter = ChecksumWriter.new @uncompressed_size_counter = ChecksumWriter.new(compute_crc32: true) # Keep track of how many bytes we write, because we need # that to write the offset of each local file header and some other info @written = 0_u32 end # Creates a new writer to the given *filename*. def self.new(filename : Path | String) new(::File.new(filename, "w"), sync_close: true) end # Creates a new writer to the given *io*, yields it to the given block, # and closes it at the end. def self.open(io : IO, sync_close = false, &) writer = new(io, sync_close: sync_close) yield writer ensure writer.close end # Creates a new writer to the given *filename*, yields it to the given block, # and closes it at the end. def self.open(filename : Path | String, &) writer = new(filename) yield writer ensure writer.close end # Adds an entry that will have the given *filename* and current # time (`Time.utc`) and yields an `IO` to write that entry's # contents. def add(filename : Path | String, &) add(Entry.new(filename.to_s)) do |io| yield io end end # Adds an entry and yields `IO` to write that entry's contents. # # You can choose the Entry's compression method before adding it. # # * If the STORED compression method is used, its crc32, compressed # size and uncompressed size **must** be set and be correct with # respect to the data that will be written to the yielded `IO`. # * If the DEFLATED compression method is used, crc32, compressed # size and uncompressed size will be computed from the data # written to the yielded IO. # # You can also set the Entry's time (which is `Time.utc` by default) # and extra data before adding it to the zip stream. def add(entry : Entry, &) # bit 3: unknown compression size (not needed for STORED, by if left out it doesn't work...) entry.general_purpose_bit_flag |= (1 << 3) # bit 11: require UTF-8 set entry.general_purpose_bit_flag |= (1 << 11) entry.offset = @written @written += entry.to_io(@io) case entry.compression_method when .stored? if entry.compressed_size != entry.uncompressed_size raise Error.new "Entry compressed size (#{entry.compressed_size}) is not equal to its uncompressed size (#{entry.uncompressed_size})" end @uncompressed_size_counter.io = @io yield @uncompressed_size_counter when .deflated? @compressed_size_counter.io = @io io = Compress::Deflate::Writer.new(@compressed_size_counter) @uncompressed_size_counter.io = io yield @uncompressed_size_counter io.close else raise "Unsupported compression method: #{entry.compression_method}" end if entry.compression_method.stored? @written += @uncompressed_size_counter.count else @written += @compressed_size_counter.count end crc32 = @uncompressed_size_counter.crc32.to_u32 uncompressed_size = @uncompressed_size_counter.count if entry.compression_method.stored? compressed_size = uncompressed_size else compressed_size = @compressed_size_counter.count end if entry.compression_method.stored? if entry.crc32 != crc32 raise Error.new("Entry CRC32 mismatch (#{entry.crc32} given but was #{crc32})") end if entry.uncompressed_size != uncompressed_size raise Error.new("Entry uncompressed size mismatch (#{entry.uncompressed_size} given but was #{uncompressed_size})") end else entry.crc32 = crc32 entry.compressed_size = compressed_size entry.uncompressed_size = uncompressed_size end # A data descriptor is not needed for the STORED method # (because we know how many bytes we need to read) unless entry.compression_method.stored? @written += entry.write_data_descriptor(@io) end @entries << entry end # Adds an entry that will have *string* as its contents. def add(filename_or_entry : Path | String | Entry, string : String) : Nil add(filename_or_entry) do |io| io << string end end # Adds an entry that will have *bytes* as its contents. def add(filename_or_entry : Path | String | Entry, bytes : Bytes) : Nil add(filename_or_entry) do |io| io.write(bytes) end end # Adds an entry that will have its data copied from the given *data*. # If the given *data* is a `::File`, it is automatically closed # after data is copied from it. def add(filename_or_entry : Path | String | Entry, data : IO) : Nil add(filename_or_entry) do |io| IO.copy(data, io) data.close if data.is_a?(::File) end end # Adds a directory entry that will have the given *name*. def add_dir(name) : Nil name = name + '/' unless name.ends_with?('/') add(Entry.new(name)) { } end # Closes this zip writer. def close : Nil return if @closed @closed = true start_offset = @written write_central_directory write_end_of_central_directory(start_offset, @written - start_offset) @io.close if @sync_close end private def write_central_directory @entries.each do |entry| write Zip::CENTRAL_DIRECTORY_HEADER_SIGNATURE # 4 write Zip::VERSION # version made by (2) write Zip::VERSION # version needed to extract (2) @written += 8 # the 8 bytes we just wrote @written += entry.meta_to_io(@io) write entry.comment.bytesize.to_u16 # file comment length (2) write 0_u16 # disk number start (2) write 0_u16 # internal file attribute (2) write 0_u32 # external file attribute (4) write entry.offset # relative offset of local header (4) @written += 14 # the 14 bytes we just wrote @io << entry.filename @written += entry.filename.bytesize @io.write(entry.extra) @written += entry.extra.size @io << entry.comment @written += entry.comment.bytesize end end private def write_end_of_central_directory(offset, size) write Zip::END_OF_CENTRAL_DIRECTORY_HEADER_SIGNATURE write 0_u16 # number of this disk write 0_u16 # disk start write @entries.size.to_u16 # number of entries in disk write @entries.size.to_u16 # number of total entries write size.to_u32 # size of the central directory write offset.to_u32 # offset of central directory write @comment.bytesize.to_u16 # comment length @io << @comment # comment end private def write(value) @io.write_bytes(value, IO::ByteFormat::LittleEndian) end # An entry to write into a `Zip::Writer`. class Entry include FileInfo end end