ziperu 0.1.1

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.
Files changed (114) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/Cargo.lock +879 -0
  4. data/Cargo.toml +8 -0
  5. data/LICENSE-rubyzip.txt +24 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +276 -0
  8. data/ext/ziperu/Cargo.toml +35 -0
  9. data/ext/ziperu/build.rs +4 -0
  10. data/ext/ziperu/extconf.rb +6 -0
  11. data/ext/ziperu/src/archive.rs +713 -0
  12. data/ext/ziperu/src/errors.rs +143 -0
  13. data/ext/ziperu/src/extract.rs +505 -0
  14. data/ext/ziperu/src/gvl.rs +64 -0
  15. data/ext/ziperu/src/lib.rs +78 -0
  16. data/ext/ziperu/src/reader.rs +351 -0
  17. data/ext/ziperu/src/source.rs +296 -0
  18. data/ext/ziperu/src/writer.rs +916 -0
  19. data/ext/ziperu/src/zipcrypto.rs +126 -0
  20. data/lib/rubyzip.rb +4 -0
  21. data/lib/zip/central_directory.rb +5 -0
  22. data/lib/zip/compressor.rb +5 -0
  23. data/lib/zip/constants.rb +5 -0
  24. data/lib/zip/crypto/aes_encryption.rb +5 -0
  25. data/lib/zip/crypto/decrypted_io.rb +5 -0
  26. data/lib/zip/crypto/encryption.rb +5 -0
  27. data/lib/zip/crypto/null_encryption.rb +5 -0
  28. data/lib/zip/crypto/traditional_encryption.rb +5 -0
  29. data/lib/zip/crypto.rb +5 -0
  30. data/lib/zip/decompressor.rb +5 -0
  31. data/lib/zip/deflater.rb +5 -0
  32. data/lib/zip/dirtyable.rb +5 -0
  33. data/lib/zip/dos_time.rb +5 -0
  34. data/lib/zip/entry.rb +5 -0
  35. data/lib/zip/entry_set.rb +5 -0
  36. data/lib/zip/errors.rb +5 -0
  37. data/lib/zip/extra_field/aes.rb +5 -0
  38. data/lib/zip/extra_field/generic.rb +5 -0
  39. data/lib/zip/extra_field/ntfs.rb +5 -0
  40. data/lib/zip/extra_field/old_unix.rb +5 -0
  41. data/lib/zip/extra_field/universal_time.rb +5 -0
  42. data/lib/zip/extra_field/unix.rb +5 -0
  43. data/lib/zip/extra_field/unknown.rb +5 -0
  44. data/lib/zip/extra_field/zip64.rb +5 -0
  45. data/lib/zip/extra_field.rb +5 -0
  46. data/lib/zip/file.rb +5 -0
  47. data/lib/zip/file_split.rb +5 -0
  48. data/lib/zip/filesystem/dir.rb +5 -0
  49. data/lib/zip/filesystem/directory_iterator.rb +5 -0
  50. data/lib/zip/filesystem/file.rb +5 -0
  51. data/lib/zip/filesystem/file_stat.rb +5 -0
  52. data/lib/zip/filesystem/zip_file_name_mapper.rb +5 -0
  53. data/lib/zip/filesystem.rb +5 -0
  54. data/lib/zip/inflater.rb +5 -0
  55. data/lib/zip/input_stream.rb +5 -0
  56. data/lib/zip/ioextras/abstract_input_stream.rb +5 -0
  57. data/lib/zip/ioextras/abstract_output_stream.rb +5 -0
  58. data/lib/zip/ioextras/fake_io.rb +5 -0
  59. data/lib/zip/ioextras.rb +5 -0
  60. data/lib/zip/null_compressor.rb +5 -0
  61. data/lib/zip/null_decompressor.rb +5 -0
  62. data/lib/zip/null_input_stream.rb +5 -0
  63. data/lib/zip/output_stream.rb +5 -0
  64. data/lib/zip/pass_thru_compressor.rb +5 -0
  65. data/lib/zip/pass_thru_decompressor.rb +5 -0
  66. data/lib/zip/streamable_directory.rb +5 -0
  67. data/lib/zip/streamable_stream.rb +5 -0
  68. data/lib/zip/version.rb +5 -0
  69. data/lib/zip.rb +19 -0
  70. data/lib/ziperu/central_directory.rb +136 -0
  71. data/lib/ziperu/compressor.rb +14 -0
  72. data/lib/ziperu/constants.rb +148 -0
  73. data/lib/ziperu/crypto.rb +72 -0
  74. data/lib/ziperu/decompressor.rb +35 -0
  75. data/lib/ziperu/deflater.rb +41 -0
  76. data/lib/ziperu/dirtyable.rb +48 -0
  77. data/lib/ziperu/dos_time.rb +87 -0
  78. data/lib/ziperu/entry.rb +760 -0
  79. data/lib/ziperu/entry_set.rb +147 -0
  80. data/lib/ziperu/errors.rb +154 -0
  81. data/lib/ziperu/extra_field/aes.rb +52 -0
  82. data/lib/ziperu/extra_field/generic.rb +45 -0
  83. data/lib/ziperu/extra_field/ntfs.rb +98 -0
  84. data/lib/ziperu/extra_field/old_unix.rb +52 -0
  85. data/lib/ziperu/extra_field/universal_time.rb +81 -0
  86. data/lib/ziperu/extra_field/unix.rb +45 -0
  87. data/lib/ziperu/extra_field/unknown.rb +37 -0
  88. data/lib/ziperu/extra_field/zip64.rb +86 -0
  89. data/lib/ziperu/extra_field.rb +109 -0
  90. data/lib/ziperu/file.rb +614 -0
  91. data/lib/ziperu/file_split.rb +93 -0
  92. data/lib/ziperu/filesystem/dir.rb +88 -0
  93. data/lib/ziperu/filesystem/directory_iterator.rb +50 -0
  94. data/lib/ziperu/filesystem/file.rb +265 -0
  95. data/lib/ziperu/filesystem/file_stat.rb +112 -0
  96. data/lib/ziperu/filesystem/zip_file_name_mapper.rb +83 -0
  97. data/lib/ziperu/filesystem.rb +76 -0
  98. data/lib/ziperu/inflater.rb +60 -0
  99. data/lib/ziperu/input_stream.rb +296 -0
  100. data/lib/ziperu/ioextras/abstract_input_stream.rb +137 -0
  101. data/lib/ziperu/ioextras/abstract_output_stream.rb +57 -0
  102. data/lib/ziperu/ioextras/fake_io.rb +41 -0
  103. data/lib/ziperu/ioextras.rb +42 -0
  104. data/lib/ziperu/null_compressor.rb +20 -0
  105. data/lib/ziperu/null_decompressor.rb +25 -0
  106. data/lib/ziperu/null_input_stream.rb +55 -0
  107. data/lib/ziperu/output_stream.rb +223 -0
  108. data/lib/ziperu/pass_thru_compressor.rb +29 -0
  109. data/lib/ziperu/pass_thru_decompressor.rb +37 -0
  110. data/lib/ziperu/streamable_directory.rb +15 -0
  111. data/lib/ziperu/streamable_stream.rb +59 -0
  112. data/lib/ziperu/version.rb +7 -0
  113. data/lib/ziperu.rb +116 -0
  114. metadata +174 -0
@@ -0,0 +1,916 @@
1
+ //! `Ziperu::Native::Writer`: writes a new archive entry by entry.
2
+ //!
3
+ //! The Ruby side (`Ziperu::OutputStream`) owns entry objects, defaults and the
4
+ //! IO-like methods; this is the thin layer over `zip::ZipWriter`. The sink
5
+ //! is a file on disk, a seekable Ruby IO driven through method calls (how
6
+ //! `write_buffer(io)` streams entry by entry into a StringIO, File or
7
+ //! Tempfile), or an in-memory buffer whose bytes `finish` returns, for an
8
+ //! IO that cannot seek.
9
+
10
+ use std::cell::RefCell;
11
+ use std::fs;
12
+ use std::io::{self, BufWriter, Cursor, Read, Seek, SeekFrom, Write};
13
+ use std::sync::atomic::{AtomicBool, Ordering};
14
+ use std::sync::Arc;
15
+
16
+ use magnus::{
17
+ gc::Marker, prelude::*, typed_data::DataTypeFunctions, value::Opaque, Error, RArray, RHash,
18
+ RString, Ruby, TypedData, Value,
19
+ };
20
+ use zip::result::{ZipError, ZipResult};
21
+ use zip::write::FullFileOptions;
22
+ use zip::{AesMode, CompressionMethod, DateTime, System, ZipArchive, ZipWriter};
23
+
24
+ use crate::archive::Archive;
25
+ use crate::errors::{busy_error, io_error, zip_error, ERROR};
26
+ use crate::gvl::maybe_without_gvl;
27
+ use crate::source::ruby_io_error;
28
+ use crate::zipcrypto;
29
+
30
+ const BUF_SIZE: usize = 64 * 1024;
31
+
32
+ pub enum Sink {
33
+ File(BufWriter<fs::File>),
34
+ Memory(Cursor<Vec<u8>>),
35
+ Ruby(BufWriter<RubyOut>),
36
+ }
37
+
38
+ impl Sink {
39
+ /// Sets bit `bit` of the little-endian u16 at `pos`.
40
+ fn set_u16_bit(&mut self, pos: u64, bit: u16) -> io::Result<()> {
41
+ let mut field = [0u8; 2];
42
+ self.seek(SeekFrom::Start(pos))?;
43
+ self.read_exact(&mut field)?;
44
+ let value = u16::from_le_bytes(field) | bit;
45
+ self.seek(SeekFrom::Start(pos))?;
46
+ self.write_all(&value.to_le_bytes())
47
+ }
48
+
49
+ fn write_at(&mut self, pos: u64, bytes: &[u8]) -> io::Result<()> {
50
+ self.seek(SeekFrom::Start(pos))?;
51
+ self.write_all(bytes)
52
+ }
53
+
54
+ /// Applies a header patch to the local header at `local` and the
55
+ /// central record at `central`.
56
+ fn apply(&mut self, patch: &Patch, local: u64, central: u64) -> io::Result<()> {
57
+ self.set_u16_bit(local + 6, patch.flags)?;
58
+ self.set_u16_bit(central + 8, patch.flags)?;
59
+ if let Some((method, crc, size)) = patch.fields {
60
+ let size = u32::try_from(size).unwrap_or(u32::MAX);
61
+ self.write_at(local + 8, &method.to_le_bytes())?;
62
+ self.write_at(local + 14, &crc.to_le_bytes())?;
63
+ self.write_at(local + 22, &size.to_le_bytes())?;
64
+ self.write_at(central + 10, &method.to_le_bytes())?;
65
+ self.write_at(central + 16, &crc.to_le_bytes())?;
66
+ self.write_at(central + 24, &size.to_le_bytes())?;
67
+ }
68
+ Ok(())
69
+ }
70
+ }
71
+
72
+ // Reading back what was written (`finish` does, to collect per-entry
73
+ // facts and patch headers) goes through the file after a flush.
74
+ impl Read for Sink {
75
+ fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
76
+ match self {
77
+ Sink::File(f) => {
78
+ f.flush()?;
79
+ f.get_mut().read(buf)
80
+ }
81
+ Sink::Memory(c) => c.read(buf),
82
+ Sink::Ruby(w) => {
83
+ w.flush()?;
84
+ w.get_mut().read(buf)
85
+ }
86
+ }
87
+ }
88
+ }
89
+
90
+ impl Write for Sink {
91
+ fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
92
+ match self {
93
+ Sink::File(f) => f.write(buf),
94
+ Sink::Memory(c) => c.write(buf),
95
+ Sink::Ruby(w) => w.write(buf),
96
+ }
97
+ }
98
+
99
+ fn flush(&mut self) -> io::Result<()> {
100
+ match self {
101
+ Sink::File(f) => f.flush(),
102
+ Sink::Memory(c) => c.flush(),
103
+ Sink::Ruby(w) => w.flush(),
104
+ }
105
+ }
106
+ }
107
+
108
+ impl Seek for Sink {
109
+ fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
110
+ match self {
111
+ Sink::File(f) => f.seek(pos),
112
+ Sink::Memory(c) => c.seek(pos),
113
+ Sink::Ruby(w) => w.seek(pos),
114
+ }
115
+ }
116
+ }
117
+
118
+ /// A seekable Ruby IO-like object (`write`, `seek`, `tell`, `read` and
119
+ /// optionally `flush`) written through method calls under the GVL. The
120
+ /// writer owns the object while open, so unlike `source::RubyIo` it trusts
121
+ /// the object's own position and only seeks when the two have drifted
122
+ /// apart (every entry ends with a seek back to patch its local header).
123
+ ///
124
+ /// The object is kept alive by the `Writer`'s `mark`; nothing here holds a
125
+ /// GC root. `ZipWriter`'s own `Drop` finalizes an archive that was never
126
+ /// finished, which would call into Ruby from the GC's free (where no
127
+ /// object may be allocated): the `Writer` clears `attached` first, after
128
+ /// which the sink swallows everything and touches no Ruby object.
129
+ pub struct RubyOut {
130
+ io: Opaque<Value>,
131
+ attached: Arc<AtomicBool>,
132
+ /// Where the next write or read goes.
133
+ pos: u64,
134
+ /// Where the object's own position is believed to be.
135
+ io_pos: u64,
136
+ /// One past the furthest byte written: the archive's end.
137
+ end: u64,
138
+ flushable: bool,
139
+ }
140
+
141
+ const SEEK_SET: i64 = 0;
142
+ const SEEK_END: i64 = 2;
143
+
144
+ impl RubyOut {
145
+ fn new(io: Value, attached: Arc<AtomicBool>) -> Result<Self, Error> {
146
+ let io_pos: u64 = io.funcall("tell", ())?;
147
+ Ok(Self {
148
+ flushable: io.respond_to("flush", false)?,
149
+ io: Opaque::from(io),
150
+ attached,
151
+ pos: 0,
152
+ io_pos,
153
+ end: 0,
154
+ })
155
+ }
156
+
157
+ /// The IO, usable only on a Ruby thread holding the GVL, which every
158
+ /// operation on a Ruby-backed sink is; `None` once detached.
159
+ fn value(&self) -> io::Result<Option<Value>> {
160
+ if !self.attached.load(Ordering::Acquire) {
161
+ return Ok(None);
162
+ }
163
+ let ruby = Ruby::get().map_err(|e| io::Error::other(e.to_string()))?;
164
+ Ok(Some(ruby.get_inner(self.io)))
165
+ }
166
+
167
+ /// Moves the object to `pos` if it is not there already.
168
+ fn sync(&mut self) -> io::Result<Option<Value>> {
169
+ let Some(io) = self.value()? else {
170
+ return Ok(None);
171
+ };
172
+ if self.io_pos != self.pos {
173
+ io.funcall::<_, _, Value>("seek", (self.pos as i64, SEEK_SET))
174
+ .map_err(ruby_io_error)?;
175
+ self.io_pos = self.pos;
176
+ }
177
+ Ok(Some(io))
178
+ }
179
+
180
+ /// Leaves the object positioned at the end of the archive, where
181
+ /// rubyzip leaves it.
182
+ fn seek_to_end(&mut self) -> io::Result<()> {
183
+ self.pos = self.end;
184
+ self.sync().map(drop)
185
+ }
186
+ }
187
+
188
+ impl Write for RubyOut {
189
+ fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
190
+ if buf.is_empty() {
191
+ return Ok(0);
192
+ }
193
+ let n = match self.sync()? {
194
+ Some(io) => {
195
+ let ruby = Ruby::get().map_err(|e| io::Error::other(e.to_string()))?;
196
+ let chunk = ruby.str_from_slice(buf);
197
+ let written: Value = io.funcall("write", (chunk,)).map_err(ruby_io_error)?;
198
+ // `IO#write` returns the byte count; an IO-like object may
199
+ // return anything else, in which case the whole buffer
200
+ // counts as written.
201
+ u64::try_convert(written)
202
+ .ok()
203
+ .map_or(buf.len(), |n| usize::try_from(n).unwrap_or(usize::MAX))
204
+ .min(buf.len())
205
+ }
206
+ None => buf.len(),
207
+ };
208
+ if n == 0 {
209
+ return Err(io::Error::new(
210
+ io::ErrorKind::WriteZero,
211
+ "the IO's write accepted no bytes",
212
+ ));
213
+ }
214
+ self.pos += n as u64;
215
+ self.io_pos = self.pos;
216
+ self.end = self.end.max(self.pos);
217
+ Ok(n)
218
+ }
219
+
220
+ fn flush(&mut self) -> io::Result<()> {
221
+ if let (true, Some(io)) = (self.flushable, self.value()?) {
222
+ io.funcall::<_, _, Value>("flush", ())
223
+ .map_err(ruby_io_error)?;
224
+ }
225
+ Ok(())
226
+ }
227
+ }
228
+
229
+ impl Read for RubyOut {
230
+ fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
231
+ let Some(io) = self.sync()? else {
232
+ return Ok(0);
233
+ };
234
+ if buf.is_empty() {
235
+ return Ok(0);
236
+ }
237
+ let chunk: Option<RString> = io.funcall("read", (buf.len(),)).map_err(ruby_io_error)?;
238
+ let Some(chunk) = chunk else {
239
+ return Ok(0);
240
+ };
241
+ // SAFETY: the slice is copied out before any other Ruby call.
242
+ let bytes = unsafe { chunk.as_slice() };
243
+ let n = bytes.len().min(buf.len());
244
+ buf[..n].copy_from_slice(&bytes[..n]);
245
+ self.pos += n as u64;
246
+ self.io_pos = self.pos;
247
+ Ok(n)
248
+ }
249
+ }
250
+
251
+ impl Seek for RubyOut {
252
+ fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
253
+ self.pos = match pos {
254
+ SeekFrom::Start(p) => p,
255
+ SeekFrom::Current(o) => offset(self.pos, o)?,
256
+ SeekFrom::End(o) => {
257
+ let len = match self.value()? {
258
+ Some(io) => {
259
+ io.funcall::<_, _, Value>("seek", (0i64, SEEK_END))
260
+ .map_err(ruby_io_error)?;
261
+ let len: u64 = io.funcall("tell", ()).map_err(ruby_io_error)?;
262
+ self.io_pos = len;
263
+ len
264
+ }
265
+ None => self.end,
266
+ };
267
+ offset(len, o)?
268
+ }
269
+ };
270
+ Ok(self.pos)
271
+ }
272
+ }
273
+
274
+ fn offset(base: u64, delta: i64) -> io::Result<u64> {
275
+ (base as i64)
276
+ .checked_add(delta)
277
+ .filter(|t| *t >= 0)
278
+ .map(|t| t as u64)
279
+ .ok_or_else(|| {
280
+ io::Error::new(
281
+ io::ErrorKind::InvalidInput,
282
+ "invalid seek to a negative or overflowing position",
283
+ )
284
+ })
285
+ }
286
+
287
+ #[derive(TypedData)]
288
+ #[magnus(class = "Ziperu::Native::Writer", free_immediately, mark)]
289
+ pub struct Writer {
290
+ // A write releases the GVL while borrowed, so a failed borrow means
291
+ // another thread is writing through this object.
292
+ inner: RefCell<State>,
293
+ /// The Ruby IO behind a `Sink::Ruby`, kept alive by `mark`.
294
+ io: Option<Opaque<Value>>,
295
+ /// Cleared when this object is freed, before the `ZipWriter` inside
296
+ /// drops, so that its finalizer cannot reach the IO (see `RubyOut`).
297
+ attached: Option<Arc<AtomicBool>>,
298
+ /// True for a Ruby IO sink, whose every operation must hold the GVL.
299
+ needs_gvl: bool,
300
+ }
301
+
302
+ impl DataTypeFunctions for Writer {
303
+ fn mark(&self, marker: &Marker) {
304
+ if let Some(io) = self.io {
305
+ marker.mark(io);
306
+ }
307
+ }
308
+ }
309
+
310
+ impl Drop for Writer {
311
+ fn drop(&mut self) {
312
+ if let Some(attached) = &self.attached {
313
+ attached.store(false, Ordering::Release);
314
+ }
315
+ }
316
+ }
317
+
318
+ struct State {
319
+ /// `None` once finished.
320
+ writer: Option<ZipWriter<Sink>>,
321
+ /// Entries started so far; the next one gets this index.
322
+ count: usize,
323
+ /// Header fields to set once the archive is written; see `Patch`.
324
+ patches: Vec<Patch>,
325
+ /// The ZipCrypto entry being written, if the current one is.
326
+ pending: Option<Pending>,
327
+ }
328
+
329
+ /// What the crate wrote for an entry is not quite right in two cases, and
330
+ /// `finish` corrects it in both headers afterwards: a raw-copied ZipCrypto
331
+ /// entry loses its encryption flag (the crate cannot write that cipher),
332
+ /// and an entry this writer encrypts itself goes to the crate as a Stored
333
+ /// entry of ciphertext, so its method, CRC and size describe the
334
+ /// plaintext only after the patch.
335
+ struct Patch {
336
+ index: usize,
337
+ /// General purpose flag bits to set.
338
+ flags: u16,
339
+ /// Compression method, CRC-32 and uncompressed size to write.
340
+ fields: Option<(u16, u32, u64)>,
341
+ }
342
+
343
+ /// A ZipCrypto entry in progress: the data is deflated here (if at all),
344
+ /// encrypted, and handed to the crate as the content of a Stored entry.
345
+ struct Pending {
346
+ index: usize,
347
+ keys: zipcrypto::Keys,
348
+ method: u16,
349
+ deflate: Option<flate2::write::DeflateEncoder<Vec<u8>>>,
350
+ crc: crc32fast::Hasher,
351
+ size: u64,
352
+ }
353
+
354
+ impl Pending {
355
+ /// Takes in plaintext and returns the ciphertext to write.
356
+ fn feed(&mut self, mut bytes: Vec<u8>) -> io::Result<Vec<u8>> {
357
+ self.crc.update(&bytes);
358
+ self.size += bytes.len() as u64;
359
+ let mut out = match self.deflate.as_mut() {
360
+ Some(encoder) => {
361
+ encoder.write_all(&bytes)?;
362
+ std::mem::take(encoder.get_mut())
363
+ }
364
+ None => std::mem::take(&mut bytes),
365
+ };
366
+ self.keys.encrypt(&mut out);
367
+ Ok(out)
368
+ }
369
+
370
+ /// Flushes the compressor; returns the last ciphertext and the patch.
371
+ fn seal(mut self) -> io::Result<(Vec<u8>, Patch)> {
372
+ let mut out = match self.deflate.take() {
373
+ Some(encoder) => encoder.finish()?,
374
+ None => Vec::new(),
375
+ };
376
+ self.keys.encrypt(&mut out);
377
+ let patch = Patch {
378
+ index: self.index,
379
+ // Encrypted, and (bit 3) the check byte is the DOS time's.
380
+ flags: ENCRYPTED_FLAG | DATA_DESCRIPTOR_FLAG,
381
+ fields: Some((self.method, self.crc.finalize(), self.size)),
382
+ };
383
+ Ok((out, patch))
384
+ }
385
+ }
386
+
387
+ /// Finishes a ZipCrypto entry in progress, if any, before the writer
388
+ /// moves on to the next entry or the central directory.
389
+ fn seal_pending(state: &mut State, w: &mut ZipWriter<Sink>) -> io::Result<()> {
390
+ if let Some(pending) = state.pending.take() {
391
+ let (last, patch) = pending.seal()?;
392
+ w.write_all(&last)?;
393
+ state.patches.push(patch);
394
+ }
395
+ Ok(())
396
+ }
397
+
398
+ /// What `finish` reports about each written entry, in order: local header
399
+ /// offset, size, compressed size, CRC-32.
400
+ type EntryStats = Vec<[u64; 4]>;
401
+
402
+ /// General purpose flag bit 0: the entry is encrypted.
403
+ const ENCRYPTED_FLAG: u16 = 1;
404
+ /// General purpose flag bit 3: sizes may follow the data, and (what
405
+ /// matters here) a ZipCrypto check byte comes from the DOS time.
406
+ const DATA_DESCRIPTOR_FLAG: u16 = 8;
407
+
408
+ /// Per-entry settings, read from the Hash Ruby passes to `start_entry`.
409
+ struct EntryOptions {
410
+ method: u16,
411
+ level: Option<i64>,
412
+ dos_date: u16,
413
+ dos_time: u16,
414
+ unix_perms: Option<u32>,
415
+ /// The "made by" host system byte (rubyzip's fstype), kept from the
416
+ /// entry rather than defaulting to the platform the writer runs on.
417
+ fstype: Option<u8>,
418
+ large_file: bool,
419
+ comment: Option<String>,
420
+ /// Concatenated local-format extra fields: id, len, data.
421
+ extra: Vec<u8>,
422
+ /// The central-directory forms; fields present only here are written
423
+ /// only there (rubyzip keeps unknown fields it was handed that way).
424
+ central_extra: Vec<u8>,
425
+ password: Option<Vec<u8>>,
426
+ aes_mode: Option<u8>,
427
+ /// Encrypt with ZipCrypto (using `password`) rather than AES.
428
+ zipcrypto: bool,
429
+ }
430
+
431
+ impl EntryOptions {
432
+ fn from_hash(ruby: &Ruby, h: RHash) -> Result<Self, Error> {
433
+ let get = |key: &str| h.get(ruby.to_symbol(key));
434
+ let opt = |key: &str| -> Result<Option<magnus::Value>, Error> {
435
+ Ok(get(key).filter(|v| !v.is_nil()))
436
+ };
437
+ Ok(Self {
438
+ method: opt("method")?
439
+ .map(u16::try_convert)
440
+ .transpose()?
441
+ .unwrap_or(8),
442
+ level: opt("level")?.map(i64::try_convert).transpose()?,
443
+ dos_date: opt("dos_date")?
444
+ .map(u16::try_convert)
445
+ .transpose()?
446
+ .unwrap_or(0),
447
+ dos_time: opt("dos_time")?
448
+ .map(u16::try_convert)
449
+ .transpose()?
450
+ .unwrap_or(0),
451
+ unix_perms: opt("unix_perms")?.map(u32::try_convert).transpose()?,
452
+ fstype: opt("fstype")?.map(u8::try_convert).transpose()?,
453
+ large_file: opt("large_file")?
454
+ .map(bool::try_convert)
455
+ .transpose()?
456
+ .unwrap_or(false),
457
+ comment: opt("comment")?
458
+ .map(|v| utf8_text(ruby, "entry comment", RString::try_convert(v)?))
459
+ .transpose()?,
460
+ extra: opt("extra")?
461
+ .map(RString::try_convert)
462
+ .transpose()?
463
+ .map(|s| unsafe { s.as_slice() }.to_vec())
464
+ .unwrap_or_default(),
465
+ central_extra: opt("central_extra")?
466
+ .map(RString::try_convert)
467
+ .transpose()?
468
+ .map(|s| unsafe { s.as_slice() }.to_vec())
469
+ .unwrap_or_default(),
470
+ password: opt("password")?
471
+ .map(RString::try_convert)
472
+ .transpose()?
473
+ .map(|s| unsafe { s.as_slice() }.to_vec()),
474
+ aes_mode: opt("aes_mode")?.map(u8::try_convert).transpose()?,
475
+ zipcrypto: opt("zipcrypto")?
476
+ .map(bool::try_convert)
477
+ .transpose()?
478
+ .unwrap_or(false),
479
+ })
480
+ }
481
+
482
+ /// The deflate level for data this writer compresses itself.
483
+ fn flate_level(&self) -> flate2::Compression {
484
+ match self.level {
485
+ Some(level @ 0..=9) => flate2::Compression::new(level as u32),
486
+ _ => flate2::Compression::default(),
487
+ }
488
+ }
489
+
490
+ fn to_file_options(&self, ruby: &Ruby) -> Result<FullFileOptions<'_, 'static>, Error> {
491
+ let method = match self.method {
492
+ 0 => CompressionMethod::Stored,
493
+ 8 => CompressionMethod::Deflated,
494
+ 12 if !self.zipcrypto => CompressionMethod::Bzip2,
495
+ 93 if !self.zipcrypto => CompressionMethod::Zstd,
496
+ other => {
497
+ return Err(Error::new(
498
+ ruby.exception_arg_error(),
499
+ format!("unsupported compression method for writing: {other}"),
500
+ ))
501
+ }
502
+ };
503
+ // A ZipCrypto entry reaches the crate as Stored ciphertext whose
504
+ // 32-bit header fields are patched later, so no zip64 fields.
505
+ let (method, large_file) = if self.zipcrypto {
506
+ (CompressionMethod::Stored, false)
507
+ } else {
508
+ (method, self.large_file)
509
+ };
510
+ let mut options = FullFileOptions::default()
511
+ .compression_method(method)
512
+ .compression_level(self.level)
513
+ .large_file(large_file)
514
+ .last_modified_time(
515
+ DateTime::try_from_msdos(self.dos_date, self.dos_time)
516
+ .unwrap_or_else(|_| DateTime::default_for_write()),
517
+ );
518
+ if let Some(perms) = self.unix_perms {
519
+ options = options.unix_permissions(perms);
520
+ }
521
+ if let Some(fstype) = self.fstype {
522
+ options = options.system(System::from(fstype));
523
+ }
524
+ if let Some(comment) = &self.comment {
525
+ options = options.with_file_comment(comment.as_str());
526
+ }
527
+ if let (Some(password), false) = (&self.password, self.zipcrypto) {
528
+ let mode = match self.aes_mode {
529
+ Some(1) => AesMode::Aes128,
530
+ Some(2) => AesMode::Aes192,
531
+ _ => AesMode::Aes256,
532
+ };
533
+ options = options.with_aes_encryption_bytes(mode, password);
534
+ }
535
+ let mut local_ids = Vec::new();
536
+ for (id, data) in extra_fields(&self.extra) {
537
+ local_ids.push(id);
538
+ options
539
+ .add_extra_field(id, data, false)
540
+ .map_err(|e| zip_error(ruby, e))?;
541
+ }
542
+ for (id, data) in extra_fields(&self.central_extra) {
543
+ if !local_ids.contains(&id) {
544
+ options
545
+ .add_extra_field(id, data, true)
546
+ .map_err(|e| zip_error(ruby, e))?;
547
+ }
548
+ }
549
+ Ok(options)
550
+ }
551
+ }
552
+
553
+ impl Writer {
554
+ pub fn open_path(ruby: &Ruby, path: String) -> Result<Self, Error> {
555
+ // Readable too: `finish` reads the central directory back.
556
+ let file = fs::OpenOptions::new()
557
+ .read(true)
558
+ .write(true)
559
+ .create(true)
560
+ .truncate(true)
561
+ .open(&path)
562
+ .map_err(|e| io_error(ruby, e))?;
563
+ Ok(Self::new(Sink::File(BufWriter::new(file)), None, None))
564
+ }
565
+
566
+ pub fn open_memory() -> Self {
567
+ Self::new(Sink::Memory(Cursor::new(Vec::new())), None, None)
568
+ }
569
+
570
+ /// Any seekable IO-like Ruby object; the archive is written from its
571
+ /// start, entry by entry, as the stream takes shape.
572
+ pub fn open_io(io: Value) -> Result<Self, Error> {
573
+ let attached = Arc::new(AtomicBool::new(true));
574
+ let out = RubyOut::new(io, Arc::clone(&attached))?;
575
+ Ok(Self::new(
576
+ Sink::Ruby(BufWriter::with_capacity(BUF_SIZE, out)),
577
+ Some(Opaque::from(io)),
578
+ Some(attached),
579
+ ))
580
+ }
581
+
582
+ fn new(sink: Sink, io: Option<Opaque<Value>>, attached: Option<Arc<AtomicBool>>) -> Self {
583
+ Self {
584
+ inner: RefCell::new(State {
585
+ writer: Some(ZipWriter::new(sink)),
586
+ count: 0,
587
+ patches: Vec::new(),
588
+ pending: None,
589
+ }),
590
+ io,
591
+ attached,
592
+ needs_gvl: io.is_some(),
593
+ }
594
+ }
595
+
596
+ /// Finishes the previous entry and begins a new file entry.
597
+ pub fn start_entry(
598
+ ruby: &Ruby,
599
+ rb_self: &Self,
600
+ name: RString,
601
+ opts: RHash,
602
+ ) -> Result<(), Error> {
603
+ let name = unsafe { name.as_slice() }.to_vec();
604
+ let options = EntryOptions::from_hash(ruby, opts)?;
605
+ let file_options = options.to_file_options(ruby)?;
606
+ // Starting an entry flushes the previous one's compressor, which is
607
+ // real work for a large entry; nothing Ruby-owned is touched unless
608
+ // the sink is a Ruby IO.
609
+ rb_self.with_state(ruby, |state, w| {
610
+ maybe_without_gvl(rb_self.needs_gvl, || -> ZipResult<()> {
611
+ seal_pending(state, w)?;
612
+ w.start_file_with_options(file_options.name(&name))?;
613
+ if options.zipcrypto {
614
+ let password = options.password.as_deref().unwrap_or_default();
615
+ let mut keys = zipcrypto::Keys::new(password);
616
+ let header = keys.header((options.dos_time >> 8) as u8)?;
617
+ w.write_all(&header)?;
618
+ let deflate = (options.method == 8).then(|| {
619
+ flate2::write::DeflateEncoder::new(Vec::new(), options.flate_level())
620
+ });
621
+ state.pending = Some(Pending {
622
+ index: state.count,
623
+ keys,
624
+ method: options.method,
625
+ deflate,
626
+ crc: crc32fast::Hasher::new(),
627
+ size: 0,
628
+ });
629
+ }
630
+ Ok(())
631
+ })
632
+ .map_err(|e| entry_error(ruby, &name, e))?;
633
+ state.count += 1;
634
+ Ok(())
635
+ })
636
+ }
637
+
638
+ /// Finishes the previous entry and adds a directory entry.
639
+ pub fn add_directory(
640
+ ruby: &Ruby,
641
+ rb_self: &Self,
642
+ name: RString,
643
+ opts: RHash,
644
+ ) -> Result<(), Error> {
645
+ let name = utf8_name(ruby, name)?;
646
+ let options = EntryOptions::from_hash(ruby, opts)?;
647
+ let file_options = options.to_file_options(ruby)?;
648
+ rb_self.with_state(ruby, |state, w| {
649
+ maybe_without_gvl(rb_self.needs_gvl, || {
650
+ seal_pending(state, w)?;
651
+ w.add_directory(&name, file_options)
652
+ })
653
+ .map_err(|e| entry_error(ruby, name.as_bytes(), e))?;
654
+ state.count += 1;
655
+ Ok(())
656
+ })
657
+ }
658
+
659
+ /// Finishes the previous entry and adds a symlink entry pointing at
660
+ /// `target` (stored as the entry's content, with `S_IFLNK` in the
661
+ /// external attributes).
662
+ pub fn add_symlink(
663
+ ruby: &Ruby,
664
+ rb_self: &Self,
665
+ name: RString,
666
+ target: RString,
667
+ opts: RHash,
668
+ ) -> Result<(), Error> {
669
+ let name = utf8_name(ruby, name)?;
670
+ let target = utf8_text(ruby, "symlink target", target)?;
671
+ let options = EntryOptions::from_hash(ruby, opts)?;
672
+ let file_options = options.to_file_options(ruby)?;
673
+ rb_self.with_state(ruby, |state, w| {
674
+ maybe_without_gvl(rb_self.needs_gvl, || {
675
+ seal_pending(state, w)?;
676
+ w.add_symlink(&name, &target, file_options)
677
+ })
678
+ .map_err(|e| entry_error(ruby, name.as_bytes(), e))?;
679
+ state.count += 1;
680
+ Ok(())
681
+ })
682
+ }
683
+
684
+ pub fn write(ruby: &Ruby, rb_self: &Self, data: RString) -> Result<usize, Error> {
685
+ // Copied out of the Ruby heap so compression can run without the
686
+ // GVL (the string could be moved by compaction meanwhile).
687
+ let bytes = unsafe { data.as_slice() }.to_vec();
688
+ let len = bytes.len();
689
+ rb_self.with_state(ruby, |state, w| {
690
+ maybe_without_gvl(rb_self.needs_gvl, || match state.pending.as_mut() {
691
+ Some(pending) => {
692
+ let cipher = pending.feed(bytes)?;
693
+ w.write_all(&cipher)
694
+ }
695
+ None => w.write_all(&bytes),
696
+ })
697
+ .map_err(|e| io_error(ruby, e))?;
698
+ Ok(len)
699
+ })
700
+ }
701
+
702
+ /// Copies entry `index` of `archive` without decompressing it, under
703
+ /// `new_name` if given. `touch` is an optional Hash with `dos_date`,
704
+ /// `dos_time` and `unix_perms` to write instead of the source's; the
705
+ /// crate offers that or a rename, not both.
706
+ pub fn raw_copy(
707
+ ruby: &Ruby,
708
+ rb_self: &Self,
709
+ archive: &Archive,
710
+ index: usize,
711
+ new_name: Option<RString>,
712
+ touch: Option<RHash>,
713
+ ) -> Result<(), Error> {
714
+ let needs_gvl = archive.needs_gvl() || rb_self.needs_gvl;
715
+ let new_name = new_name.map(|n| utf8_name(ruby, n)).transpose()?;
716
+ // A clone, so the archive's borrow is released before the GVL is.
717
+ let mut source = archive.archive(ruby)?.clone();
718
+ let file = source.by_index_raw(index).map_err(|e| zip_error(ruby, e))?;
719
+ let zipcrypto = file.encrypted() && file.compression() != CompressionMethod::Aes;
720
+ let touch = touch
721
+ .map(|h| EntryOptions::from_hash(ruby, h))
722
+ .transpose()?;
723
+ rb_self.with_state(ruby, |state, w| {
724
+ maybe_without_gvl(needs_gvl, || {
725
+ seal_pending(state, w)?;
726
+ match (new_name, touch) {
727
+ (Some(name), _) => w.raw_copy_file_rename(file, name),
728
+ (None, Some(t)) => {
729
+ let mtime = DateTime::try_from_msdos(t.dos_date, t.dos_time)
730
+ .unwrap_or_else(|_| DateTime::default_for_write());
731
+ w.raw_copy_file_touch(file, mtime, t.unix_perms)
732
+ }
733
+ (None, None) => w.raw_copy_file(file),
734
+ }
735
+ })
736
+ .map_err(|e| zip_error(ruby, e))?;
737
+ if zipcrypto {
738
+ state.patches.push(Patch {
739
+ index: state.count,
740
+ flags: ENCRYPTED_FLAG,
741
+ fields: None,
742
+ });
743
+ }
744
+ state.count += 1;
745
+ Ok(())
746
+ })
747
+ }
748
+
749
+ pub fn set_comment(ruby: &Ruby, rb_self: &Self, comment: RString) -> Result<(), Error> {
750
+ let bytes = unsafe { comment.as_slice() }.to_vec().into_boxed_slice();
751
+ rb_self.with_writer(ruby, |w| {
752
+ w.set_raw_comment(bytes).map_err(|e| zip_error(ruby, e))
753
+ })
754
+ }
755
+
756
+ /// Writes the central directory. Returns `[bytes, stats]`: the archive
757
+ /// bytes for a memory sink (nil for a file or Ruby IO), and one
758
+ /// `[header_offset, size, compressed_size, crc]` per entry in the order
759
+ /// written, which is how Ruby learns what a streamed entry came to.
760
+ pub fn finish(ruby: &Ruby, rb_self: &Self) -> Result<(Option<RString>, RArray), Error> {
761
+ let mut state = rb_self
762
+ .inner
763
+ .try_borrow_mut()
764
+ .map_err(|_| busy_error(ruby, "writer"))?;
765
+ let mut writer = state
766
+ .writer
767
+ .take()
768
+ .ok_or_else(|| Error::new(ruby.exception_io_error(), "closed stream"))?;
769
+ let mut patches = std::mem::take(&mut state.patches);
770
+ let pending = state.pending.take();
771
+ drop(state);
772
+ // Finishes the last entry and writes the central directory, then
773
+ // reads that directory back for the stats and the header patches.
774
+ let needs_gvl = rb_self.needs_gvl;
775
+ let (sink, stats) = maybe_without_gvl(needs_gvl, || -> ZipResult<(Sink, EntryStats)> {
776
+ if let Some(pending) = pending {
777
+ let (last, patch) = pending.seal()?;
778
+ writer.write_all(&last)?;
779
+ patches.push(patch);
780
+ }
781
+ let archive = writer.finish_into_readable()?;
782
+ let meta = archive.metadata();
783
+ let mut stats = (0..meta.len())
784
+ .map(|i| {
785
+ let e = meta.entry(i)?;
786
+ Ok([
787
+ e.header_start(),
788
+ e.size(),
789
+ e.compressed_size(),
790
+ u64::from(e.crc32()),
791
+ ])
792
+ })
793
+ .collect::<ZipResult<EntryStats>>()?;
794
+ let mut sink = archive.into_inner();
795
+ if !patches.is_empty() {
796
+ // The writer's own view has no central record offsets (it
797
+ // never read them); parse what was just written to get them.
798
+ let reread = ZipArchive::new(sink)?;
799
+ let meta = reread.metadata();
800
+ let targets = patches
801
+ .iter()
802
+ .map(|p| {
803
+ let e = meta.entry(p.index)?;
804
+ Ok((e.header_start(), e.central_header_start()))
805
+ })
806
+ .collect::<ZipResult<Vec<_>>>()?;
807
+ sink = reread.into_inner();
808
+ for (patch, (local, central)) in patches.iter().zip(targets) {
809
+ sink.apply(patch, local, central)?;
810
+ if let Some((_, crc, size)) = patch.fields {
811
+ if let Some(stat) = stats.get_mut(patch.index) {
812
+ stat[1] = size;
813
+ stat[3] = u64::from(crc);
814
+ }
815
+ }
816
+ }
817
+ }
818
+ sink.flush()?;
819
+ if let Sink::Ruby(w) = &mut sink {
820
+ w.get_mut().seek_to_end()?;
821
+ }
822
+ Ok((sink, stats))
823
+ })
824
+ .map_err(|e| zip_error(ruby, e))?;
825
+ let bytes = match sink {
826
+ Sink::File(_) | Sink::Ruby(_) => None,
827
+ Sink::Memory(c) => Some(ruby.str_from_slice(&c.into_inner())),
828
+ };
829
+ let out = ruby.ary_new_capa(stats.len());
830
+ for entry in stats {
831
+ out.push(entry.to_vec())?;
832
+ }
833
+ Ok((bytes, out))
834
+ }
835
+
836
+ pub fn is_closed(&self) -> bool {
837
+ self.inner.try_borrow().is_ok_and(|s| s.writer.is_none())
838
+ }
839
+
840
+ fn with_writer<T>(
841
+ &self,
842
+ ruby: &Ruby,
843
+ f: impl FnOnce(&mut ZipWriter<Sink>) -> Result<T, Error>,
844
+ ) -> Result<T, Error> {
845
+ self.with_state(ruby, |_, w| f(w))
846
+ }
847
+
848
+ fn with_state<T>(
849
+ &self,
850
+ ruby: &Ruby,
851
+ f: impl FnOnce(&mut State, &mut ZipWriter<Sink>) -> Result<T, Error>,
852
+ ) -> Result<T, Error> {
853
+ let mut guard = self
854
+ .inner
855
+ .try_borrow_mut()
856
+ .map_err(|_| busy_error(ruby, "writer"))?;
857
+ let state = &mut *guard;
858
+ let mut writer = state
859
+ .writer
860
+ .take()
861
+ .ok_or_else(|| Error::new(ruby.exception_io_error(), "closed stream"))?;
862
+ let result = f(state, &mut writer);
863
+ state.writer = Some(writer);
864
+ result
865
+ }
866
+ }
867
+
868
+ /// Walks concatenated extra fields (id, length, data) as `(id, data)`.
869
+ fn extra_fields(bytes: &[u8]) -> impl Iterator<Item = (u16, &[u8])> {
870
+ let mut i = 0;
871
+ std::iter::from_fn(move || {
872
+ if i + 4 > bytes.len() {
873
+ return None;
874
+ }
875
+ let id = u16::from_le_bytes([bytes[i], bytes[i + 1]]);
876
+ let len = u16::from_le_bytes([bytes[i + 2], bytes[i + 3]]) as usize;
877
+ let data = bytes.get(i + 4..i + 4 + len).unwrap_or(&[]);
878
+ i += 4 + len;
879
+ Some((id, data))
880
+ })
881
+ }
882
+
883
+ /// The crate takes directory names, rename targets and entry comments as
884
+ /// `str` (file names are raw bytes), so this limit is stated plainly
885
+ /// rather than surfacing as a conversion error.
886
+ fn utf8_name(ruby: &Ruby, name: RString) -> Result<String, Error> {
887
+ utf8_text(ruby, "entry name", name)
888
+ }
889
+
890
+ fn utf8_text(ruby: &Ruby, what: &str, text: RString) -> Result<String, Error> {
891
+ let bytes = unsafe { text.as_slice() };
892
+ String::from_utf8(bytes.to_vec()).map_err(|_| {
893
+ Error::new(
894
+ ruby.get_inner(&ERROR),
895
+ format!(
896
+ "{what} {:?} is not valid UTF-8, which the native writer needs for directory names, renames and comments",
897
+ String::from_utf8_lossy(bytes)
898
+ ),
899
+ )
900
+ })
901
+ }
902
+
903
+ /// `zip_error`, except that the crate's duplicate-name message (which
904
+ /// shows the name as a Debug dump) is replaced with one naming the entry.
905
+ fn entry_error(ruby: &Ruby, name: &[u8], err: ZipError) -> Error {
906
+ match &err {
907
+ ZipError::InvalidArchive(msg) if msg.starts_with("Duplicate filename") => Error::new(
908
+ ruby.get_inner(&ERROR),
909
+ format!(
910
+ "entry {:?} was already written to this stream",
911
+ String::from_utf8_lossy(name)
912
+ ),
913
+ ),
914
+ _ => zip_error(ruby, err),
915
+ }
916
+ }