libzip-ruby ↑

Ruby bindings for libzip, the C library for reading and writing zip archives.

Requires Ruby 3.3 or newer.

Full API documentation: tipuch.github.io/libzip-ruby/, or ri LibZip::File once the gem is installed.

Installation ↑

Bundler ↑

# Gemfile
gem "libzip-ruby"
bundle install

RubyGems ↑

gem install libzip-ruby

Precompiled extensions are published for these platforms:

platform notes
‘x86_64-linux`, `aarch64-linux` glibc
‘x86_64-linux-musl`, `aarch64-linux-musl` Alpine and other musl systems
‘x86_64-darwin`, `arm64-darwin` macOS, Intel and Apple silicon

Quick start ↑

Write an archive from a file on disk:

require "libzip"

LibZip::File.open("archive.zip", create: true) do |zip|
  zip.add("hello.txt", "hello.txt")  # entry name, path on disk
end

Write an entry from memory:

require "libzip"

LibZip::File.open("archive.zip", create: true) do |zip|
  zip.get_output_stream("greeting.txt") do |out|
    out.write("Hello, ")
    out << "world!\n"
    out << "written by libzip"
  end
end

Read an entry back:

require "libzip"

zip = LibZip::File.open("archive.zip")
zip.read("greeting.txt")  #=> "Hello, world!\nwritten by libzip"
zip.close

No unzip installation is needed here. The archives are plain zip files, so unzip -l archive.zip shows the exact bytes you wrote.

Working with LibZip::File ↑

Opening ↑

LibZip::File.open("archive.zip")                  # read an existing archive
LibZip::File.open("archive.zip", create: true)    # create it if it does not exist

create: true has no effect when the file already exists (it doesn’t truncate), so it’s safe to use whenever you intend to write. Opening a path that doesn’t exist without it gives LibZip::NotFoundError.

Block form ↑

Both File.open and get_output_stream take a block, and both have the same 2 guarantees:

result = LibZip::File.open("archive.zip", create: true) do |zip|
  zip.add("a.txt", "a.txt")
  :whatever_the_block_returns
end
result  #=> :whatever_the_block_returns

The same contract applies per entry with get_output_stream: if the block fails, that entry isn’t added, while entries written earlier (and already committed) remain.

LibZip::File.open("archive.zip", create: true) do |zip|
  zip.get_output_stream("kept.txt") { |out| out.write("kept") }

  begin
    # partial.txt never lands in the archive; kept.txt still does
    zip.get_output_stream("dropped.txt") do |out|
      out.write("partial")
      raise "boom"
    end
  rescue RuntimeError
    # carry on
  end
end

Manual close ↑

Without a block you control the lifecycle:

zip = LibZip::File.open("archive.zip", create: true)
zip.add("a.txt", "a.txt")
zip.closed?  #=> false
zip.close    # writes the central directory, flushes to disk
zip.closed?  #=> true

close on an already closed archive gives LibZip::EntryError, and using a closed archive gives the same thing, so a double close is a visible bug, not a quiet one.

Adding entries ↑

zip.add(entry_name, source_path)

add loads source_path from disk when the archive is written, and returns the archive, so calls chain:

LibZip::File.open("archive.zip", create: true) do |zip|
  zip.add("a.txt", "a.txt").add("docs/b.txt", "b.txt")
end

The source must be a regular file: a missing path gives LibZip::NotFoundError, a directory gives LibZip::InvalidArgumentError.

Writing entries from memory ↑

zip = LibZip::File.open("archive.zip", create: true)
out = zip.get_output_stream("data.csv")
out.write("a,b,c\n")     #=> 6, the number of bytes written
out << "1,2,3\n"         #=> the stream, so it chains
out.close                #=> commits the entry
zip.close

A stream buffers in memory and is only added to the archive when it’s closed (or when the block returns), so a stream you leave open, including one passed to the garbage collector, is thrown away.

Reading entries ↑

zip.read("greeting.txt")

Returns the entry’s bytes as an ASCII-8BIT string. Reading an entry that isn’t in the archive gives LibZip::NotFoundError.

Listing entries ↑

LibZip::File.open("archive.zip") do |zip|
  zip.names                     # => ["docs/", "docs/a.txt", "greeting.txt"]
  zip.size                      # => 3   (alias: #length)

  zip.entries                   # => [LibZip::Entry, ...]; fresh snapshot per call
  zip["docs/a.txt"]             # => the Entry, or nil    (alias: #find_entry)
  zip.get_entry("docs/a.txt")   # => the Entry, or raises LibZip::NotFoundError
  zip.include?("docs/a.txt")    # => true

  zip.each { |entry| ... }      # File includes Enumerable
  zip.map(&:name)
  zip.select(&:directory?)
end

What an entry records:

entry = zip["docs/a.txt"]
entry.name                 # => "docs/a.txt" (UTF-8)
entry.size                 # => 1234, uncompressed
entry.compressed_size      # => 456
entry.crc                  # => 4023222625
entry.time                 # => 2026-09-16 17:06:40 +0700   (alias: #mtime)
entry.compression_method   # => LibZip::Entry::DEFLATED (8) or #STORED (0)
entry.directory?           # => false
entry.encrypted?           # => false
entry.index                # => 1, the entry's position in the archive
entry.to_s                 # => "docs/a.txt"

Entries are snapshots, so their metadata remains readable after the archive is closed; using the File after that gives LibZip::EntryError.

glob matches entry names with File.fnmatch? semantics; * doesn’t cross a /, and directory entries match without the trailing slash they’re stored with:

zip.glob("*.txt")     # => ["greeting.txt"]                 (not docs/notes.txt)
zip.glob("**/*.txt")  # => ["greeting.txt", "docs/notes.txt"]
zip.glob("docs")      # => the "docs/" entry itself
zip.glob("**/*.rb") { |entry| entry.name }   # also yields each match

Matching is case-sensitive on each platform, unlike Dir.glob on macOS. *, ?, [...] and {a,b} all work; ** matches zero or more directories.

Deleting entries ↑

removed = zip.remove("old.txt")
removed = zip.remove(entry)          # an Entry works too
removed.name                          # => "old.txt"
removed.size                          # still readable after zip.close

remove returns the entry it deleted, a snapshot taken before the deletion, so the metadata remains readable after that. If the name isn’t in the archive you get LibZip::NotFoundError, and so does a second remove of the same name. There is no other outcome: you get the entry back, or you get an exception.

Like add, removal is marked immediately and written when the archive is closed; closing commits, and there’s no discard. Inside the block form, a block that fails keeps the archive on disk as it was. Removing the only remaining entry would produce an empty archive, and libzip won’t write those: the file is removed from disk instead of being written again with an empty directory.

Renaming entries ↑

renamed = zip.rename("draft.txt", "final.txt")
renamed = zip.rename(entry, "final.txt")     # an Entry works too
renamed.name                                  # => "final.txt"
renamed.size                                  # metadata comes along

rename returns the entry as it is after the rename, a snapshot like the one remove gives you, so it remains readable once the archive is closed. Size, CRC, time and compression are preserved; only the name changes.

Renaming moves 1 record, not a subtree: the children of a directory entry keep the names they were stored with, whatever the directory is called after that.

A missing old_name gives LibZip::NotFoundError, and a new_name that’s already taken gives LibZip::AlreadyExistsError. Directory has to match at both ends (a directory entry keeps its trailing /).

Overwriting ↑

add and get_output_stream both pass ZIP_FL_OVERWRITE, so writing the same entry name a second time replaces the first copy instead of raising LibZip::AlreadyExistsError.

Comments ↑

A zip file has room for 1 comment on the archive and 1 on each entry.

LibZip::File.open("archive.zip", create: true) do |zip|
  zip.add("a.txt", "a.txt")

  zip.comment = "built by the nightly job"
  zip.set_comment("a.txt", "the important one")
end

LibZip::File.open("archive.zip") do |zip|
  zip.comment              # => "built by the nightly job"
  zip["a.txt"].comment     # => "the important one"
end

Both are written when the archive is closed. nil clears a comment, and a missing comment comes back as nil, not as "".

The zip format gives the field 16 bits, so a comment longer than 65535 bytes gives LibZip::InvalidArgumentError. Entry comments come back through Entry#comment, and like the rest of an entry’s metadata they’re a snapshot: still readable after the archive is closed.

Reading entries as a stream ↑

read loads the entire entry into a String. get_input_stream gives you the entry as a stream instead, so memory remains flat no matter how big the entry is:

LibZip::File.open("archive.zip") do |zip|
  stream = zip.get_input_stream("big.csv")

  stream.read          # => the rest of the entry, as a String
  stream.read(1024)    # => at most 1024 bytes
  stream.eof?          # => true once the entry is exhausted
  stream.closed?       # => true after #close, or after the archive closed
  stream.close         # frees the entry handle; idempotent
end

The block form closes the stream on the way out, including when the block fails:

LibZip::File.open("archive.zip") do |zip|
  zip.get_input_stream("big.csv") do |stream|
    stream.read(64 * 1024) until stream.eof?
  end
end

Encryption ↑

libzip takes care of the cryptography. AES-128, AES-192 and AES-256 are supported for reading and writing. Traditional ZipCrypto is supported for reading, and reading only.

Writing. Pass encryption: (and optionally password:) to add or get_output_stream. An entry written without its own password: falls back to the archive default:

LibZip::File.open("secrets.zip", create: true, password: "hunter2") do |zip|
  zip.add("notes.txt", "notes.txt", encryption: :aes256, password: "s3kr1t")

  # no password: of its own, so this entry gets the archive default
  zip.get_output_stream("todo.txt", encryption: :aes128) do |out|
    out.write("buy milk")
  end
end

Accepted values are :aes128, :aes192, :aes256, :none and nil. :pkware is turned off for writing: libzip documents ZipCrypto as deprecated.

Reading. The password can come from the archive, from the call, or from File#password=:

# archive default: every read falls back to it
LibZip::File.open("secrets.zip", password: "hunter2") do |zip|
  zip.read("notes.txt")
  zip.read("todo.txt")
end

# per call, overriding the default
LibZip::File.open("secrets.zip") do |zip|
  zip.read("notes.txt", password: "hunter2")
  zip.get_input_stream("notes.txt", password: "hunter2") { |s| s.read }
end

# set it after opening
LibZip::File.open("secrets.zip") do |zip|
  zip.password = "hunter2"     # no getter: passwords are not readable back
  zip.read("notes.txt")
end

An explicit password: overrides the archive default, so a bad password on the call fails even when the archive was opened with the right one.

API reference ↑

LibZip::File: the archive.

method notes
‘File.open(path, create: false, password: nil) { |zip| }` block form closes the archive
‘#close`, `#closed?` closes live input streams first
‘#password=` archive default password; ‘nil` clears it
‘#read(name, password: nil)` entire entry, as a String
‘#get_input_stream(name, password: nil) { |stream| }` streamed read
‘#add(name, source_path, encryption: nil, password: nil)` copies a file from disk
‘#get_output_stream(name, encryption: nil, password: nil) { |out| }` writes from memory
‘#remove(name)` deletes an entry
‘#rename(old_name, new_name)` renames an entry
‘#comment`, `#comment=` archive comment
‘#set_comment(name, comment)` per-entry comment
‘#entries`, `#each`, `#each_entry` snapshots of the central directory
‘#names`, `#size`, `#length` entry names, entry count
‘#include?`, `#find_entry`, `#[]` lookup, ‘nil` when missing
‘#get_entry(name)` lookup; a missing entry → ‘LibZip::NotFoundError`
‘#glob(pattern)` entries with a name matching a pattern

LibZip::Entry: one central-directory record.

method notes
‘#name`, `#index` UTF-8 name, index in the archive
‘#size`, `#compressed_size`, `#crc` uncompressed size, compressed size, CRC-32
‘#time`, `#mtime` modification time
‘#compression_method` ‘Entry::STORED` or `Entry::DEFLATED`
‘#encryption_method` libzip’s numeric method (see constants below)
‘#encrypted?` ‘encryption_method != Entry::NONE`
‘#directory?` entry is a directory
‘#comment`, `#to_s`, `#inspect` per-entry comment and formatting

Each accessor above except name, index and directory? returns nil when the archive did not record that field. encrypted? returns false in that case.

LibZip::Entry also defines the numeric encryption constants, so metadata can be checked without guesswork: NONE, TRAD_PKWARE, AES_128, AES_192, AES_256.

LibZip::InputStream: read([length]), eof?, closed?, close.

LibZip::OutputStream: write(string), <<(string), close.

Errors ↑

Everything libzip reports is a LibZip::Error (a StandardError), mapped to a subclass by the kind of failure, so you can rescue narrowly:

class raised for
‘LibZip::Error` base class; also used for codes with no better home
‘LibZip::IoError` read/write/seek/EOF/open failures on the underlying files
‘LibZip::ReadError`, `LibZip::WriteError` a failed read or write of archive data
‘LibZip::NotFoundError` no such archive, entry or source file
‘LibZip::AlreadyExistsError` an entry that exists where it must not
‘LibZip::CorruptArchiveError` not a zip, truncated, inconsistent, multi-disk
‘LibZip::CompressionError`, `LibZip::DecompressionError` unsupported method, CRC mismatch
‘LibZip::InvalidArgumentError` bad argument, e.g. a directory as a source
‘LibZip::PermissionError` read-only archive or disallowed operation
‘LibZip::PasswordError` password missing or incorrect
‘LibZip::EntryError` an entry used after the archive closed, or a stale entry
‘LibZip::UnsupportedError` operation or encryption method libzip can’t do
‘LibZip::InternalError` libzip/zlib internal failure, out of memory
begin
  LibZip::File.open("archive.zip") { |zip| zip.read("nope.txt") }
rescue LibZip::NotFoundError => e
  warn "missing entry: #{e.message}"
rescue LibZip::Error => e
  warn "libzip said: #{e.message}"
end

Under the hood ↑

To contribute to the project:

mise install           # Zig 0.17-dev and Ruby, per mise.toml
script/install-zig     # the same Zig without mise, which is what CI runs
zig build              # builds zig-out/lib/libzip_ruby.so
zig build test         # Zig unit tests + the minitest suite
script/doc             # API docs into doc/html
script/doc --check     # doc/api.rb vs. the built extension
script/package list    # platform matrix, and what this host can build

The API reference is written out by hand in doc/api.rb.

License ↑

Apache License 2.0. The full text is in LICENSE.

The extension links 3 libraries statically, so their terms travel with the gem: