libzip-ruby 0.1.0-arm64-darwin

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md ADDED
@@ -0,0 +1,521 @@
1
+ # libzip-ruby
2
+
3
+ Ruby bindings for [libzip](https://libzip.org), the C library for reading and
4
+ writing zip archives.
5
+
6
+ - **No compile step.** libzip and zlib are statically linked into the
7
+ extension and published precompiled per platform, so `gem install` calls no
8
+ C toolchain.
9
+ - **No runtime dependencies.** No system libzip, no zlib, no OpenSSL needed. .
10
+ - **Familiar surface.** `LibZip::File.open` / `#add` / `#get_output_stream` /
11
+ `#read`.
12
+ - **Encryption included.** AES-128/192/256 reading and writing, plus read-only
13
+ ZipCrypto, with the crypto performed by a statically linked mbedTLS.
14
+
15
+ Requires Ruby 3.3 or newer.
16
+
17
+ Full API documentation: <https://tipuch.github.io/libzip-ruby/>, or `ri
18
+ LibZip::File` once the gem is installed.
19
+
20
+ ## Installation
21
+
22
+ ### Bundler
23
+
24
+ ```ruby
25
+ # Gemfile
26
+ gem "libzip-ruby"
27
+ ```
28
+
29
+ ```sh
30
+ bundle install
31
+ ```
32
+
33
+ ### RubyGems
34
+
35
+ ```sh
36
+ gem install libzip-ruby
37
+ ```
38
+
39
+ Precompiled extensions are published for these platforms:
40
+
41
+ | platform | notes |
42
+ | --- | --- |
43
+ | `x86_64-linux`, `aarch64-linux` | glibc |
44
+ | `x86_64-linux-musl`, `aarch64-linux-musl` | Alpine and other musl systems |
45
+ | `x86_64-darwin`, `arm64-darwin` | macOS, Intel and Apple silicon |
46
+
47
+ ## Quick start
48
+
49
+ Write an archive from a file on disk:
50
+
51
+ ```ruby
52
+ require "libzip"
53
+
54
+ LibZip::File.open("archive.zip", create: true) do |zip|
55
+ zip.add("hello.txt", "hello.txt") # entry name, path on disk
56
+ end
57
+ ```
58
+
59
+ Write an entry from memory:
60
+
61
+ ```ruby
62
+ require "libzip"
63
+
64
+ LibZip::File.open("archive.zip", create: true) do |zip|
65
+ zip.get_output_stream("greeting.txt") do |out|
66
+ out.write("Hello, ")
67
+ out << "world!\n"
68
+ out << "written by libzip"
69
+ end
70
+ end
71
+ ```
72
+
73
+ Read an entry back:
74
+
75
+ ```ruby
76
+ require "libzip"
77
+
78
+ zip = LibZip::File.open("archive.zip")
79
+ zip.read("greeting.txt") #=> "Hello, world!\nwritten by libzip"
80
+ zip.close
81
+ ```
82
+
83
+ No `unzip` installation is needed here. The archives are plain zip
84
+ files, so `unzip -l archive.zip` shows the exact bytes you wrote.
85
+
86
+ ## Working with `LibZip::File`
87
+
88
+ ### Opening
89
+
90
+ ```ruby
91
+ LibZip::File.open("archive.zip") # read an existing archive
92
+ LibZip::File.open("archive.zip", create: true) # create it if it does not exist
93
+ ```
94
+
95
+ `create: true` has no effect when the file already exists (it doesn't truncate),
96
+ so it's safe to use whenever you intend to write. Opening a path that doesn't
97
+ exist without it gives `LibZip::NotFoundError`.
98
+
99
+ ### Block form
100
+
101
+ Both `File.open` and `get_output_stream` take a block, and both have the same
102
+ 2 guarantees:
103
+
104
+ ```ruby
105
+ result = LibZip::File.open("archive.zip", create: true) do |zip|
106
+ zip.add("a.txt", "a.txt")
107
+ :whatever_the_block_returns
108
+ end
109
+ result #=> :whatever_the_block_returns
110
+ ```
111
+
112
+ - On normal exit the archive is closed and **committed**; the block's value is
113
+ returned from `open`.
114
+ - If the block fails, the archive is **thrown away**: the half-written file is
115
+ removed and the exception propagates. No truncated zip is left behind.
116
+
117
+ The same contract applies per entry with `get_output_stream`: if the block
118
+ fails, that entry isn't added, while entries written earlier (and already
119
+ committed) remain.
120
+
121
+ ```ruby
122
+ LibZip::File.open("archive.zip", create: true) do |zip|
123
+ zip.get_output_stream("kept.txt") { |out| out.write("kept") }
124
+
125
+ begin
126
+ # partial.txt never lands in the archive; kept.txt still does
127
+ zip.get_output_stream("dropped.txt") do |out|
128
+ out.write("partial")
129
+ raise "boom"
130
+ end
131
+ rescue RuntimeError
132
+ # carry on
133
+ end
134
+ end
135
+ ```
136
+
137
+ ### Manual `close`
138
+
139
+ Without a block you control the lifecycle:
140
+
141
+ ```ruby
142
+ zip = LibZip::File.open("archive.zip", create: true)
143
+ zip.add("a.txt", "a.txt")
144
+ zip.closed? #=> false
145
+ zip.close # writes the central directory, flushes to disk
146
+ zip.closed? #=> true
147
+ ```
148
+
149
+ `close` on an already closed archive gives `LibZip::EntryError`, and using a
150
+ closed archive gives the same thing, so a double `close` is a visible bug, not
151
+ a quiet one.
152
+
153
+ ### Adding entries
154
+
155
+ ```ruby
156
+ zip.add(entry_name, source_path)
157
+ ```
158
+
159
+ `add` loads `source_path` from disk when the archive is written, and returns the
160
+ archive, so calls chain:
161
+
162
+ ```ruby
163
+ LibZip::File.open("archive.zip", create: true) do |zip|
164
+ zip.add("a.txt", "a.txt").add("docs/b.txt", "b.txt")
165
+ end
166
+ ```
167
+
168
+ The source must be a regular file: a missing path gives
169
+ `LibZip::NotFoundError`, a directory gives `LibZip::InvalidArgumentError`.
170
+
171
+ ### Writing entries from memory
172
+
173
+ ```ruby
174
+ zip = LibZip::File.open("archive.zip", create: true)
175
+ out = zip.get_output_stream("data.csv")
176
+ out.write("a,b,c\n") #=> 6, the number of bytes written
177
+ out << "1,2,3\n" #=> the stream, so it chains
178
+ out.close #=> commits the entry
179
+ zip.close
180
+ ```
181
+
182
+ A stream buffers in memory and is only added to the archive when it's closed
183
+ (or when the block returns), so a stream you leave open, including one passed to
184
+ the garbage collector, is thrown away.
185
+
186
+ ### Reading entries
187
+
188
+ ```ruby
189
+ zip.read("greeting.txt")
190
+ ```
191
+
192
+ Returns the entry's bytes as an `ASCII-8BIT` string. Reading an entry that isn't
193
+ in the archive gives `LibZip::NotFoundError`.
194
+
195
+ ### Listing entries
196
+
197
+ ```ruby
198
+ LibZip::File.open("archive.zip") do |zip|
199
+ zip.names # => ["docs/", "docs/a.txt", "greeting.txt"]
200
+ zip.size # => 3 (alias: #length)
201
+
202
+ zip.entries # => [LibZip::Entry, ...]; fresh snapshot per call
203
+ zip["docs/a.txt"] # => the Entry, or nil (alias: #find_entry)
204
+ zip.get_entry("docs/a.txt") # => the Entry, or raises LibZip::NotFoundError
205
+ zip.include?("docs/a.txt") # => true
206
+
207
+ zip.each { |entry| ... } # File includes Enumerable
208
+ zip.map(&:name)
209
+ zip.select(&:directory?)
210
+ end
211
+ ```
212
+
213
+ What an entry records:
214
+
215
+ ```ruby
216
+ entry = zip["docs/a.txt"]
217
+ entry.name # => "docs/a.txt" (UTF-8)
218
+ entry.size # => 1234, uncompressed
219
+ entry.compressed_size # => 456
220
+ entry.crc # => 4023222625
221
+ entry.time # => 2026-09-16 17:06:40 +0700 (alias: #mtime)
222
+ entry.compression_method # => LibZip::Entry::DEFLATED (8) or #STORED (0)
223
+ entry.directory? # => false
224
+ entry.encrypted? # => false
225
+ entry.index # => 1, the entry's position in the archive
226
+ entry.to_s # => "docs/a.txt"
227
+ ```
228
+
229
+ Entries are snapshots, so their metadata remains readable after the archive is
230
+ closed; using the `File` after that gives `LibZip::EntryError`.
231
+
232
+ `#glob` matches entry names with `File.fnmatch?` semantics; `*` doesn't cross
233
+ a `/`, and directory entries match without the trailing slash they're stored
234
+ with:
235
+
236
+ ```ruby
237
+ zip.glob("*.txt") # => ["greeting.txt"] (not docs/notes.txt)
238
+ zip.glob("**/*.txt") # => ["greeting.txt", "docs/notes.txt"]
239
+ zip.glob("docs") # => the "docs/" entry itself
240
+ zip.glob("**/*.rb") { |entry| entry.name } # also yields each match
241
+ ```
242
+
243
+ Matching is case-sensitive on each platform, unlike `Dir.glob` on macOS.
244
+ `*`, `?`, `[...]` and `{a,b}` all work; `**` matches zero or more directories.
245
+
246
+ ### Deleting entries
247
+
248
+ ```ruby
249
+ removed = zip.remove("old.txt")
250
+ removed = zip.remove(entry) # an Entry works too
251
+ removed.name # => "old.txt"
252
+ removed.size # still readable after zip.close
253
+ ```
254
+
255
+ `remove` returns the entry it deleted, a snapshot taken before the
256
+ deletion, so the metadata remains readable after that. If the name isn't in the
257
+ archive you get `LibZip::NotFoundError`, and so does a second `remove` of the
258
+ same name. There is no other outcome: you get the entry back, or you get an
259
+ exception.
260
+
261
+ Like `add`, removal is marked immediately and written when the archive is
262
+ closed; closing commits, and there's no `discard`. Inside the block form, a block
263
+ that fails keeps the archive on disk as it was. Removing the only remaining
264
+ entry would produce an empty archive, and libzip won't write those: the file is
265
+ removed from disk instead of being written again with an empty directory.
266
+
267
+ ### Renaming entries
268
+
269
+ ```ruby
270
+ renamed = zip.rename("draft.txt", "final.txt")
271
+ renamed = zip.rename(entry, "final.txt") # an Entry works too
272
+ renamed.name # => "final.txt"
273
+ renamed.size # metadata comes along
274
+ ```
275
+
276
+ `rename` returns the entry as it is after the rename, a snapshot like the one
277
+ `remove` gives you, so it remains readable once the archive is closed. Size,
278
+ CRC, time and compression are preserved; only the name changes.
279
+
280
+ Renaming moves 1 record, not a subtree: the children of a directory entry keep
281
+ the names they were stored with, whatever the directory is called after that.
282
+
283
+ A missing `old_name` gives `LibZip::NotFoundError`, and a `new_name` that's
284
+ already taken gives `LibZip::AlreadyExistsError`. Directory has to match at
285
+ both ends (a directory entry keeps its trailing `/`).
286
+
287
+ ### Overwriting
288
+
289
+ `add` and `get_output_stream` both pass `ZIP_FL_OVERWRITE`, so writing the same
290
+ entry name a second time replaces the first copy instead of raising
291
+ `LibZip::AlreadyExistsError`.
292
+
293
+ ## Comments
294
+
295
+ A zip file has room for 1 comment on the archive and 1 on each entry.
296
+
297
+ ```ruby
298
+ LibZip::File.open("archive.zip", create: true) do |zip|
299
+ zip.add("a.txt", "a.txt")
300
+
301
+ zip.comment = "built by the nightly job"
302
+ zip.set_comment("a.txt", "the important one")
303
+ end
304
+
305
+ LibZip::File.open("archive.zip") do |zip|
306
+ zip.comment # => "built by the nightly job"
307
+ zip["a.txt"].comment # => "the important one"
308
+ end
309
+ ```
310
+
311
+ Both are written when the archive is closed. `nil` clears a comment, and a
312
+ missing comment comes back as `nil`, not as `""`.
313
+
314
+ The zip format gives the field 16 bits, so a comment longer than 65535 bytes
315
+ gives `LibZip::InvalidArgumentError`. Entry comments come back through
316
+ `Entry#comment`, and like the rest of an entry's metadata they're a snapshot:
317
+ still readable after the archive is closed.
318
+
319
+ ## Reading entries as a stream
320
+
321
+ `read` loads the entire entry into a String. `get_input_stream` gives you the
322
+ entry as a stream instead, so memory remains flat no matter how big the entry is:
323
+
324
+ ```ruby
325
+ LibZip::File.open("archive.zip") do |zip|
326
+ stream = zip.get_input_stream("big.csv")
327
+
328
+ stream.read # => the rest of the entry, as a String
329
+ stream.read(1024) # => at most 1024 bytes
330
+ stream.eof? # => true once the entry is exhausted
331
+ stream.closed? # => true after #close, or after the archive closed
332
+ stream.close # frees the entry handle; idempotent
333
+ end
334
+ ```
335
+
336
+ The block form closes the stream on the way out, including when the block fails:
337
+
338
+ ```ruby
339
+ LibZip::File.open("archive.zip") do |zip|
340
+ zip.get_input_stream("big.csv") do |stream|
341
+ stream.read(64 * 1024) until stream.eof?
342
+ end
343
+ end
344
+ ```
345
+
346
+ ## Encryption
347
+
348
+ libzip takes care of the cryptography. AES-128, AES-192 and AES-256 are supported for reading and writing.
349
+ Traditional ZipCrypto is supported for reading, and reading only.
350
+
351
+ **Writing.** Pass `encryption:` (and optionally `password:`) to `add` or
352
+ `get_output_stream`:
353
+
354
+ ```ruby
355
+ LibZip::File.open("secrets.zip", create: true) do |zip|
356
+ zip.add("notes.txt", "notes.txt", encryption: :aes256, password: "hunter2")
357
+
358
+ zip.get_output_stream("todo.txt", encryption: :aes128) do |out|
359
+ out.write("buy milk")
360
+ end
361
+ end
362
+ ```
363
+
364
+ Accepted values are `:aes128`, `:aes192`, `:aes256`, `:none` and `nil`.
365
+ `:pkware` is turned off for writing: libzip documents ZipCrypto as deprecated.
366
+
367
+ **Reading.** The password can come from the archive, from the call, or from
368
+ `File#password=`:
369
+
370
+ ```ruby
371
+ # archive default: every read falls back to it
372
+ LibZip::File.open("secrets.zip", password: "hunter2") do |zip|
373
+ zip.read("notes.txt")
374
+ zip.read("todo.txt")
375
+ end
376
+
377
+ # per call, overriding the default
378
+ LibZip::File.open("secrets.zip") do |zip|
379
+ zip.read("notes.txt", password: "hunter2")
380
+ zip.get_input_stream("notes.txt", password: "hunter2") { |s| s.read }
381
+ end
382
+
383
+ # set it after opening
384
+ LibZip::File.open("secrets.zip") do |zip|
385
+ zip.password = "hunter2" # no getter: passwords are not readable back
386
+ zip.read("notes.txt")
387
+ end
388
+ ```
389
+
390
+ An explicit `password:` overrides the archive default, so a bad password on
391
+ the call fails even when the archive was opened with the right one.
392
+
393
+ - Encryption is **per entry**. One archive can mix plain, AES-128 and ZipCrypto
394
+ entries, each with a separate password; `File.open(password:)` only sets a
395
+ default that each read falls back to.
396
+ - `nil` or `""` means "use the archive default"; it doesn't mean "empty
397
+ password".
398
+ - A password without `encryption:` on a write gives
399
+ `LibZip::InvalidArgumentError`, as does encrypting with no password at all
400
+ (not per entry, not as an archive default).
401
+ - Missing or incorrect passwords give `LibZip::PasswordError` when the entry is
402
+ read, not when the archive is opened.
403
+ - **Names, comments and the central directory remain unencrypted.** Zip
404
+ encryption protects entry contents only; anyone can list what is inside.
405
+ - AES entries use AE-2, which stores no CRC, so the HMAC is the only integrity
406
+ check for them and `Entry#crc` may be `0`. Tampering surfaces as
407
+ `LibZip::DecompressionError` when the entry is read to the end.
408
+ - Crypto is using mbedTLS 3.6, statically linked like libzip and zlib.
409
+
410
+ ## API reference
411
+
412
+ `LibZip::File`: the archive.
413
+
414
+ | method | notes |
415
+ | --- | --- |
416
+ | `File.open(path, create: false, password: nil) { \|zip\| }` | block form closes the archive |
417
+ | `#close`, `#closed?` | closes live input streams first |
418
+ | `#password=` | archive default password; `nil` clears it |
419
+ | `#read(name, password: nil)` | entire entry, as a String |
420
+ | `#get_input_stream(name, password: nil) { \|stream\| }` | streamed read |
421
+ | `#add(name, source_path, encryption: nil, password: nil)` | copies a file from disk |
422
+ | `#get_output_stream(name, encryption: nil, password: nil) { \|out\| }` | writes from memory |
423
+ | `#remove(name)` | deletes an entry |
424
+ | `#rename(old_name, new_name)` | renames an entry |
425
+ | `#comment`, `#comment=` | archive comment |
426
+ | `#set_comment(name, comment)` | per-entry comment |
427
+ | `#entries`, `#each`, `#each_entry` | snapshots of the central directory |
428
+ | `#names`, `#size`, `#length` | entry names, entry count |
429
+ | `#include?`, `#find_entry`, `#[]` | lookup, `nil` when missing |
430
+ | `#get_entry(name)` | lookup; a missing entry → `LibZip::NotFoundError` |
431
+ | `#glob(pattern)` | entries with a name matching a pattern |
432
+
433
+ `LibZip::Entry`: one central-directory record.
434
+
435
+ | method | notes |
436
+ | --- | --- |
437
+ | `#name`, `#index` | UTF-8 name, index in the archive |
438
+ | `#size`, `#compressed_size`, `#crc` | uncompressed size, compressed size, CRC-32 |
439
+ | `#time`, `#mtime` | modification time |
440
+ | `#compression_method` | `Entry::STORED` or `Entry::DEFLATED` |
441
+ | `#encryption_method` | libzip's numeric method (see constants below) |
442
+ | `#encrypted?` | `encryption_method != Entry::NONE` |
443
+ | `#directory?` | entry is a directory |
444
+ | `#comment`, `#to_s`, `#inspect` | per-entry comment and formatting |
445
+
446
+ Each accessor above except `#name`, `#index` and `#directory?` returns `nil`
447
+ when the archive did not record that field. `#encrypted?` returns `false` in that case.
448
+
449
+ `LibZip::Entry` also defines the numeric encryption constants, so metadata can
450
+ be checked without guesswork: `NONE`, `TRAD_PKWARE`, `AES_128`, `AES_192`,
451
+ `AES_256`.
452
+
453
+ `LibZip::InputStream`: `#read([length])`, `#eof?`, `#closed?`, `#close`.
454
+
455
+ `LibZip::OutputStream`: `#write(string)`, `#<<(string)`, `#close`.
456
+
457
+ ## Errors
458
+
459
+ Everything libzip reports is a `LibZip::Error` (a `StandardError`), mapped to a
460
+ subclass by the kind of failure, so you can rescue narrowly:
461
+
462
+ | class | raised for |
463
+ | --- | --- |
464
+ | `LibZip::Error` | base class; also used for codes with no better home |
465
+ | `LibZip::IoError` | read/write/seek/EOF/open failures on the underlying files |
466
+ | `LibZip::ReadError`, `LibZip::WriteError` | a failed read or write of archive data |
467
+ | `LibZip::NotFoundError` | no such archive, entry or source file |
468
+ | `LibZip::AlreadyExistsError` | an entry that exists where it must not |
469
+ | `LibZip::CorruptArchiveError` | not a zip, truncated, inconsistent, multi-disk |
470
+ | `LibZip::CompressionError`, `LibZip::DecompressionError` | unsupported method, CRC mismatch |
471
+ | `LibZip::InvalidArgumentError` | bad argument, e.g. a directory as a source |
472
+ | `LibZip::PermissionError` | read-only archive or disallowed operation |
473
+ | `LibZip::PasswordError` | password missing or incorrect |
474
+ | `LibZip::EntryError` | an entry used after the archive closed, or a stale entry |
475
+ | `LibZip::UnsupportedError` | operation or encryption method libzip can't do |
476
+ | `LibZip::InternalError` | libzip/zlib internal failure, out of memory |
477
+
478
+ ```ruby
479
+ begin
480
+ LibZip::File.open("archive.zip") { |zip| zip.read("nope.txt") }
481
+ rescue LibZip::NotFoundError => e
482
+ warn "missing entry: #{e.message}"
483
+ rescue LibZip::Error => e
484
+ warn "libzip said: #{e.message}"
485
+ end
486
+ ```
487
+
488
+ ## Under the hood
489
+
490
+ - libzip 1.11.4, zlib 1.3.2 and mbedTLS 3.6 are pinned in `build.zig.zon` and
491
+ compiled into a single shared object per platform; only libc (and libm)
492
+ remains a runtime dependency.
493
+ - The build is Zig only (`build.zig`), including the small host program that
494
+ regenerates libzip's error strings from the libzip headers.
495
+ - `script/package` builds the platform gems; `.github/workflows/package.yml`
496
+ runs it in CI.
497
+
498
+ To contribute to the project:
499
+
500
+ ```sh
501
+ mise install # Zig 0.17-dev and Ruby, per mise.toml
502
+ script/install-zig # the same Zig without mise, which is what CI runs
503
+ zig build # builds zig-out/lib/libzip_ruby.so
504
+ zig build test # Zig unit tests + the minitest suite
505
+ script/doc # API docs into doc/html
506
+ script/doc --check # doc/api.rb vs. the built extension
507
+ script/package list # platform matrix, and what this host can build
508
+ ```
509
+
510
+ The API reference is written out by hand in `doc/api.rb`.
511
+
512
+ ## License
513
+
514
+ Apache License 2.0. The full text is in `LICENSE`.
515
+
516
+ The extension links 3 libraries statically, so their terms travel with the
517
+ gem:
518
+
519
+ - libzip 1.11.4 is BSD 3-Clause.
520
+ - zlib 1.3.2 is the zlib license.
521
+ - mbedTLS 3.6.6 is Apache-2.0.