Ruby bindings for libzip, the C library for reading and writing zip archives.
- No compile step. libzip and zlib are statically linked into the
extension and published precompiled per platform, so
gem installcalls no C toolchain. - No runtime dependencies. No system libzip, no zlib, no OpenSSL needed. .
- Familiar surface.
LibZip::File.open/#add/#get_output_stream/#read. - Encryption included. AES-128/192/256 reading and writing, plus read-only ZipCrypto, with the crypto performed by a statically linked mbedTLS.
Requires Ruby 3.3 or newer.
Full API documentation: https://tipuch.github.io/libzip-ruby/, or ri LibZip::File once the gem is installed.
# Gemfile
gem "libzip-ruby"bundle installgem install libzip-rubyPrecompiled 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 |
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
endWrite 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
endRead an entry back:
require "libzip"
zip = LibZip::File.open("archive.zip")
zip.read("greeting.txt") #=> "Hello, world!\nwritten by libzip"
zip.closeNo unzip installation is needed here. The archives are plain zip
files, so unzip -l archive.zip shows the exact bytes you wrote.
LibZip::File.open("archive.zip") # read an existing archive
LibZip::File.open("archive.zip", create: true) # create it if it does not existcreate: 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.
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- On normal exit the archive is closed and committed; the block's value is
returned from
open. - If the block fails, the archive is thrown away: the half-written file is removed and the exception propagates. No truncated zip is left behind.
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
endWithout 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? #=> trueclose 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.
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")
endThe source must be a regular file: a missing path gives
LibZip::NotFoundError, a directory gives LibZip::InvalidArgumentError.
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.closeA 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.
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.
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?)
endWhat 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 matchMatching is case-sensitive on each platform, unlike Dir.glob on macOS.
*, ?, [...] and {a,b} all work; ** matches zero or more directories.
removed = zip.remove("old.txt")
removed = zip.remove(entry) # an Entry works too
removed.name # => "old.txt"
removed.size # still readable after zip.closeremove 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.
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 alongrename 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 /).
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.
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"
endBoth 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.
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
endThe 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
endlibzip 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
endAccepted 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")
endAn explicit password: overrides the archive default, so a bad password on
the call fails even when the archive was opened with the right one.
- Encryption is per entry. One archive can mix plain, AES-128 and ZipCrypto
entries, each with a separate password;
File.open(password:)only sets a default that each read falls back to. nilor""means "use the archive default"; it doesn't mean "empty password".- A password without
encryption:on a write givesLibZip::InvalidArgumentError, as does encrypting with no password at all (not per entry, not as an archive default). - Missing or incorrect passwords give
LibZip::PasswordErrorwhen the entry is read, not when the archive is opened. - Names, comments and the central directory remain unencrypted. Zip encryption protects entry contents only; anyone can list what is inside.
- AES entries use AE-2, which stores no CRC, so the HMAC is the only integrity
check for them and
Entry#crcmay be0. Tampering surfaces asLibZip::DecompressionErrorwhen the entry is read to the end. - Crypto is using mbedTLS 3.6, statically linked like libzip and zlib.
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.
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- libzip 1.11.4, zlib 1.3.2 and mbedTLS 3.6 are pinned in
build.zig.zonand compiled into a single shared object per platform; only libc (and libm) remains a runtime dependency. - The build is Zig only (
build.zig), including the small host program that regenerates libzip's error strings from the libzip headers. script/packagebuilds the platform gems;.github/workflows/package.ymlruns it in CI.
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 buildThe API reference is written out by hand in doc/api.rb.
Apache License 2.0. The full text is in LICENSE.
The extension links 3 libraries statically, so their terms travel with the gem:
- libzip 1.11.4 is BSD 3-Clause.
- zlib 1.3.2 is the zlib license.
- mbedTLS 3.6.6 is Apache-2.0.