Skip to content

Repository files navigation

libzip-ruby

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 install calls 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.

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
  • 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
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.

  • 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.
  • nil or "" means "use the archive default"; it doesn't mean "empty password".
  • A password without encryption: on a write gives LibZip::InvalidArgumentError, as does encrypting with no password at all (not per entry, not as an archive default).
  • Missing or incorrect passwords give LibZip::PasswordError when 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#crc may be 0. Tampering surfaces as LibZip::DecompressionError when the entry is read to the end.
  • Crypto is using mbedTLS 3.6, statically linked like libzip and zlib.

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

  • libzip 1.11.4, zlib 1.3.2 and mbedTLS 3.6 are pinned in build.zig.zon and 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/package builds the platform gems; .github/workflows/package.yml runs 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 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:

  • libzip 1.11.4 is BSD 3-Clause.
  • zlib 1.3.2 is the zlib license.
  • mbedTLS 3.6.6 is Apache-2.0.

About

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

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages