roo-oxide 0.0.2

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.
@@ -0,0 +1,19 @@
1
+ [package]
2
+ name = "roo_oxide"
3
+ version = "0.0.0"
4
+ edition = "2021"
5
+ license = "MIT"
6
+ publish = false
7
+
8
+ [lib]
9
+ crate-type = ["cdylib", "rlib"]
10
+
11
+ [dependencies]
12
+ flate2 = "1.1.11"
13
+ magnus = { version = "0.9.3", features = ["rb-sys"] }
14
+ quick-xml = "0.42.0"
15
+ rb-sys = "0.9.130"
16
+
17
+ [dev-dependencies]
18
+ # Links libruby so `cargo test` can build this extension crate.
19
+ magnus = { version = "0.9.3", features = ["embed", "rb-sys"] }
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mkmf"
4
+
5
+ # JRuby cannot load a native extension. Write a no-op Makefile so the gem
6
+ # installs, and lib/roo/oxide.rb stays inert.
7
+ if RUBY_ENGINE != "ruby"
8
+ File.write("Makefile", "all install clean:\n\t@:\n")
9
+ return
10
+ end
11
+
12
+ require "rb_sys/mkmf"
13
+
14
+ create_rust_makefile("roo/oxide/roo_oxide")
@@ -0,0 +1,546 @@
1
+ //! Worksheet XML read straight from the workbook's zip, with large entries
2
+ //! inflated on a thread of their own, without extracting them to a file first.
3
+ //!
4
+ //! rubyzip has already read the central directory: Ruby passes each entry's
5
+ //! local header offset, sizes, and compression method, so this reads the
6
+ //! same entry rubyzip would extract, and only the zip format's local header
7
+ //! and the entry's data are parsed here.
8
+
9
+ use std::fs::File;
10
+ use std::io::{self, BufRead, BufReader, Read, Seek, SeekFrom, Write};
11
+ use std::path::PathBuf;
12
+ use std::sync::atomic::Ordering;
13
+ use std::sync::mpsc::{self, Receiver, Sender};
14
+ use std::sync::Arc;
15
+
16
+ use flate2::read::DeflateDecoder;
17
+ use magnus::{function, method, prelude::*, Error, RModule, RString, Ruby, TryConvert, Value};
18
+
19
+ use crate::reader::{Mode, SheetReader, Unsupported};
20
+ use crate::{unsupported, without_gvl};
21
+
22
+ const LOCAL_HEADER_SIGNATURE: u32 = 0x0403_4b50;
23
+ const LOCAL_HEADER_LENGTH: usize = 30;
24
+ const STORED: u16 = 0;
25
+ const DEFLATED: u16 = 8;
26
+ /// The general purpose flag of an encrypted entry.
27
+ const ENCRYPTED: u16 = 1;
28
+ const ZIP64_EXTRA: u16 = 1;
29
+ /// Bytes per inflated chunk handed from the inflating thread to the parser.
30
+ const CHUNK: usize = 256 * 1024;
31
+ /// Chunks the inflating thread may run ahead of the parser.
32
+ const CHUNKS_AHEAD: usize = 4;
33
+
34
+ #[derive(Clone)]
35
+ #[magnus::wrap(class = "Roo::Oxide::Native::ZipBuffer", free_immediately, size)]
36
+ struct ZipBuffer(Arc<[u8]>);
37
+
38
+ impl ZipBuffer {
39
+ fn new(bytes: RString) -> Self {
40
+ Self(unsafe { bytes.as_slice() }.into())
41
+ }
42
+ }
43
+
44
+ #[derive(Clone)]
45
+ enum ZipSource {
46
+ Path(PathBuf),
47
+ Buffer(ZipBuffer),
48
+ }
49
+
50
+ impl TryConvert for ZipSource {
51
+ fn try_convert(value: Value) -> Result<Self, Error> {
52
+ match <&ZipBuffer>::try_convert(value) {
53
+ Ok(buffer) => Ok(Self::Buffer(buffer.clone())),
54
+ Err(_) => Ok(Self::Path(PathBuf::try_convert(value)?)),
55
+ }
56
+ }
57
+ }
58
+
59
+ /// One entry in a ZIP snapshot, held in a file or a shared byte buffer.
60
+ #[derive(Clone)]
61
+ #[magnus::wrap(class = "Roo::Oxide::Native::ZipEntry", free_immediately, size)]
62
+ pub struct ZipEntry {
63
+ zip: ZipSource,
64
+ offset: u64,
65
+ compressed_size: u64,
66
+ size: u64,
67
+ deflated: bool,
68
+ }
69
+
70
+ impl ZipEntry {
71
+ /// `Native::ZipEntry.new(zip, offset, compressed_size, size, method)`:
72
+ /// the entry rubyzip describes as `local_header_offset`,
73
+ /// `compressed_size`, `size`, and `compression_method`.
74
+ /// `zip` is a path or a `Native::ZipBuffer`.
75
+ fn new(
76
+ ruby: &Ruby,
77
+ zip: ZipSource,
78
+ offset: u64,
79
+ compressed_size: u64,
80
+ size: u64,
81
+ method: u16,
82
+ ) -> Result<Self, Error> {
83
+ if method != STORED && method != DEFLATED {
84
+ return Err(Error::new(
85
+ ruby.exception_arg_error(),
86
+ format!("unsupported compression method {method}"),
87
+ ));
88
+ }
89
+ Ok(Self {
90
+ zip,
91
+ offset,
92
+ compressed_size,
93
+ size,
94
+ deflated: method == DEFLATED,
95
+ })
96
+ }
97
+
98
+ /// The entry's data as stored, after its local header. Fails where the
99
+ /// local header disagrees with the central directory on what rubyzip
100
+ /// reads from it: the encryption flag, the compression method, and a
101
+ /// stored entry's size.
102
+ fn data(&self) -> io::Result<Box<dyn Read + Send>> {
103
+ match &self.zip {
104
+ ZipSource::Path(path) => Ok(Box::new(self.entry_data(File::open(path)?)?)),
105
+ ZipSource::Buffer(buffer) => Ok(Box::new(
106
+ self.entry_data(io::Cursor::new(buffer.0.clone()))?,
107
+ )),
108
+ }
109
+ }
110
+
111
+ fn entry_data<R: Read + Seek>(&self, mut file: R) -> io::Result<io::Take<R>> {
112
+ file.seek(SeekFrom::Start(self.offset))?;
113
+ let mut header = [0; LOCAL_HEADER_LENGTH];
114
+ file.read_exact(&mut header)?;
115
+ let field = |at: usize| u16::from_le_bytes([header[at], header[at + 1]]);
116
+ let long = |at: usize| {
117
+ u32::from_le_bytes([header[at], header[at + 1], header[at + 2], header[at + 3]])
118
+ };
119
+ if long(0) != LOCAL_HEADER_SIGNATURE {
120
+ return Err(io::Error::new(
121
+ io::ErrorKind::InvalidData,
122
+ "no local header at the entry's offset",
123
+ ));
124
+ }
125
+ file.seek(SeekFrom::Current(i64::from(field(26))))?;
126
+ let mut extra = vec![0; usize::from(field(28))];
127
+ file.read_exact(&mut extra)?;
128
+ let method = if self.deflated { DEFLATED } else { STORED };
129
+ if field(6) & ENCRYPTED != 0
130
+ || field(8) != method
131
+ || (!self.deflated && local_size(long(22), &extra) != Some(self.size))
132
+ {
133
+ return Err(io::Error::new(
134
+ io::ErrorKind::InvalidData,
135
+ "the local header disagrees with the central directory",
136
+ ));
137
+ }
138
+ Ok(file.take(self.compressed_size))
139
+ }
140
+
141
+ /// The entry's inflated bytes. Fails once more than `size` bytes come
142
+ /// out, where rubyzip raises or warns (`Zip.validate_entry_sizes`).
143
+ pub(crate) fn inflated(&self) -> io::Result<impl Read> {
144
+ let data = self.data()?;
145
+ let bytes: Box<dyn Read + Send> = if self.deflated {
146
+ Box::new(DeflateDecoder::new(data))
147
+ } else {
148
+ data
149
+ };
150
+ Ok(SizeLimited {
151
+ inner: bytes,
152
+ left: self.size,
153
+ })
154
+ }
155
+
156
+ #[cold]
157
+ fn read_all(&self) -> io::Result<Vec<u8>> {
158
+ let mut bytes = Vec::with_capacity(self.size as usize);
159
+ self.inflated()?.read_to_end(&mut bytes)?;
160
+ Ok(bytes)
161
+ }
162
+
163
+ fn input(&self) -> Input {
164
+ // The parser reads off Ruby's GVL, but its input is constructed under it.
165
+ if self.size < CHUNK as u64 {
166
+ Input::SmallZip {
167
+ entry: Some(Box::new(self.clone())),
168
+ bytes: io::Cursor::new(Vec::new()),
169
+ }
170
+ } else {
171
+ Input::Zip(self.inflating())
172
+ }
173
+ }
174
+
175
+ /// The inflated bytes, inflated on a thread of its own ahead of the
176
+ /// reader.
177
+ fn inflating(&self) -> Inflating {
178
+ let (sender, chunks) = mpsc::sync_channel(CHUNKS_AHEAD);
179
+ let (spent, reusable) = mpsc::channel::<Vec<u8>>();
180
+ let entry = self.clone();
181
+ let spawned = std::thread::Builder::new()
182
+ .name("roo-oxide-inflate".into())
183
+ .spawn(move || {
184
+ let mut bytes = match entry.inflated() {
185
+ Ok(bytes) => bytes,
186
+ Err(e) => return drop(sender.send(Err(e))),
187
+ };
188
+ loop {
189
+ let mut chunk = reusable.try_recv().unwrap_or_default();
190
+ chunk.resize(CHUNK, 0);
191
+ let read = read_full(&mut bytes, &mut chunk).map(|n| {
192
+ chunk.truncate(n);
193
+ chunk
194
+ });
195
+ let last = !matches!(&read, Ok(chunk) if chunk.len() == CHUNK);
196
+ let reader_dropped = sender.send(read).is_err();
197
+ if reader_dropped || last {
198
+ return;
199
+ }
200
+ }
201
+ });
202
+ Inflating {
203
+ chunks: spawned.ok().map(|_| chunks),
204
+ spent,
205
+ chunk: Vec::new(),
206
+ at: 0,
207
+ ended: false,
208
+ }
209
+ }
210
+
211
+ /// Writes the inflated entry to `path`, as rubyzip's `Zip::Entry#extract`
212
+ /// does, with the GVL released. Fails without touching a file already at
213
+ /// `path`; a file that fails part way is removed, so rubyzip can extract
214
+ /// it instead.
215
+ fn extract(ruby: &Ruby, rb_self: &Self, path: PathBuf) -> Result<(), Error> {
216
+ let entry = rb_self.clone();
217
+ let written = without_gvl(move |interrupted| -> io::Result<()> {
218
+ let mut bytes = entry.inflated()?;
219
+ let mut file = File::create_new(&path)?;
220
+ let written = (|| {
221
+ let mut chunk = vec![0; CHUNK];
222
+ loop {
223
+ if interrupted.load(Ordering::Acquire) {
224
+ return Err(io::Error::from(io::ErrorKind::Interrupted));
225
+ }
226
+ let n = read_full(&mut bytes, &mut chunk)?;
227
+ file.write_all(&chunk[..n])?;
228
+ if n < CHUNK {
229
+ return Ok(());
230
+ }
231
+ }
232
+ })();
233
+ if written.is_err() {
234
+ let _ = std::fs::remove_file(&path);
235
+ }
236
+ written
237
+ })?;
238
+ written.map_err(|e| Error::new(ruby.exception_io_error(), e.to_string()))
239
+ }
240
+ }
241
+
242
+ /// The uncompressed size a local header gives: its 32-bit field, or, when
243
+ /// that is `u32::MAX`, the first value of its Zip64 extra field. `None`
244
+ /// when the extra fields are malformed or hold more than one Zip64 field,
245
+ /// which rubyzip reads its own way.
246
+ fn local_size(field: u32, extra: &[u8]) -> Option<u64> {
247
+ if field != u32::MAX {
248
+ return Some(u64::from(field));
249
+ }
250
+ let mut zip64 = None;
251
+ let mut rest = extra;
252
+ while !rest.is_empty() {
253
+ let [a, b, c, d, after @ ..] = rest else {
254
+ return None;
255
+ };
256
+ let length = usize::from(u16::from_le_bytes([*c, *d]));
257
+ let data = after.get(..length)?;
258
+ if u16::from_le_bytes([*a, *b]) == ZIP64_EXTRA {
259
+ if zip64.is_some() {
260
+ return None;
261
+ }
262
+ zip64 = Some(u64::from_le_bytes(data.get(..8)?.try_into().ok()?));
263
+ }
264
+ rest = &after[length..];
265
+ }
266
+ zip64
267
+ }
268
+
269
+ /// Reads until `buf` is full or the input ends.
270
+ fn read_full(input: &mut impl Read, buf: &mut [u8]) -> io::Result<usize> {
271
+ let mut filled = 0;
272
+ while filled < buf.len() {
273
+ match input.read(&mut buf[filled..]) {
274
+ Ok(0) => break,
275
+ Ok(n) => filled += n,
276
+ Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
277
+ Err(e) => return Err(e),
278
+ }
279
+ }
280
+ Ok(filled)
281
+ }
282
+
283
+ struct SizeLimited<R> {
284
+ inner: R,
285
+ left: u64,
286
+ }
287
+
288
+ impl<R: Read> Read for SizeLimited<R> {
289
+ fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
290
+ let n = self.inner.read(buf)?;
291
+ self.left = self.left.checked_sub(n as u64).ok_or_else(|| {
292
+ io::Error::new(
293
+ io::ErrorKind::InvalidData,
294
+ "the entry inflates to more bytes than its size",
295
+ )
296
+ })?;
297
+ Ok(n)
298
+ }
299
+ }
300
+
301
+ /// The chunks [`ZipEntry::inflating`] inflates, as one input.
302
+ pub struct Inflating {
303
+ /// `None` when the thread could not start.
304
+ chunks: Option<Receiver<io::Result<Vec<u8>>>>,
305
+ /// Read chunks, back to the thread to fill again.
306
+ spent: Sender<Vec<u8>>,
307
+ chunk: Vec<u8>,
308
+ at: usize,
309
+ ended: bool,
310
+ }
311
+
312
+ impl Read for Inflating {
313
+ fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
314
+ let available = self.fill_buf()?;
315
+ let n = available.len().min(buf.len());
316
+ buf[..n].copy_from_slice(&available[..n]);
317
+ self.consume(n);
318
+ Ok(n)
319
+ }
320
+ }
321
+
322
+ impl BufRead for Inflating {
323
+ fn fill_buf(&mut self) -> io::Result<&[u8]> {
324
+ if self.at == self.chunk.len() && !self.ended {
325
+ let chunks = self
326
+ .chunks
327
+ .as_ref()
328
+ .ok_or_else(|| io::Error::other("the inflating thread did not start"))?;
329
+ let chunk = chunks
330
+ .recv()
331
+ .map_err(|_| io::Error::other("the inflating thread stopped"))??;
332
+ self.ended = chunk.len() < CHUNK;
333
+ let _thread_done = self.spent.send(std::mem::replace(&mut self.chunk, chunk));
334
+ self.at = 0;
335
+ }
336
+ Ok(&self.chunk[self.at..])
337
+ }
338
+
339
+ fn consume(&mut self, amount: usize) {
340
+ self.at += amount;
341
+ }
342
+ }
343
+
344
+ /// What a worksheet's XML is read from.
345
+ pub enum Input {
346
+ File(BufReader<File>),
347
+ Zip(Inflating),
348
+ SmallZip {
349
+ entry: Option<Box<ZipEntry>>,
350
+ bytes: io::Cursor<Vec<u8>>,
351
+ },
352
+ }
353
+
354
+ impl Read for Input {
355
+ fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
356
+ match self {
357
+ Self::File(file) => file.read(buf),
358
+ Self::Zip(zip) => zip.read(buf),
359
+ Self::SmallZip { .. } => {
360
+ let available = self.fill_buf()?;
361
+ let n = available.len().min(buf.len());
362
+ buf[..n].copy_from_slice(&available[..n]);
363
+ self.consume(n);
364
+ Ok(n)
365
+ }
366
+ }
367
+ }
368
+ }
369
+
370
+ impl BufRead for Input {
371
+ #[inline(always)]
372
+ fn fill_buf(&mut self) -> io::Result<&[u8]> {
373
+ match self {
374
+ Self::File(file) => file.fill_buf(),
375
+ Self::Zip(zip) => zip.fill_buf(),
376
+ Self::SmallZip { entry, bytes } => {
377
+ if let Some(source) = entry {
378
+ *bytes = io::Cursor::new(source.read_all()?);
379
+ *entry = None;
380
+ }
381
+ bytes.fill_buf()
382
+ }
383
+ }
384
+ }
385
+
386
+ fn consume(&mut self, amount: usize) {
387
+ match self {
388
+ Self::File(file) => file.consume(amount),
389
+ Self::Zip(zip) => zip.consume(amount),
390
+ Self::SmallZip { bytes, .. } => bytes.consume(amount),
391
+ }
392
+ }
393
+ }
394
+
395
+ /// A worksheet: the path of its extracted file, or a `Native::ZipEntry`.
396
+ pub enum Source {
397
+ Path(PathBuf),
398
+ Entry(ZipEntry),
399
+ }
400
+
401
+ impl TryConvert for Source {
402
+ fn try_convert(value: Value) -> Result<Self, Error> {
403
+ match <&ZipEntry>::try_convert(value) {
404
+ Ok(entry) => Ok(Self::Entry(entry.clone())),
405
+ Err(_) => Ok(Self::Path(PathBuf::try_convert(value)?)),
406
+ }
407
+ }
408
+ }
409
+
410
+ impl Source {
411
+ pub fn small(&self) -> bool {
412
+ matches!(self, Self::Entry(entry) if entry.size < CHUNK as u64)
413
+ }
414
+
415
+ pub fn input(&self, ruby: &Ruby) -> Result<Input, Error> {
416
+ Ok(match self {
417
+ Self::Path(path) => Input::File(BufReader::with_capacity(
418
+ 64 * 1024,
419
+ File::open(path).map_err(|e| unsupported(ruby, Unsupported(e.to_string())))?,
420
+ )),
421
+ Self::Entry(entry) => entry.input(),
422
+ })
423
+ }
424
+
425
+ pub fn open(&self, ruby: &Ruby, mode: Mode) -> Result<SheetReader<Input>, Error> {
426
+ Ok(SheetReader::new(self.input(ruby)?, mode))
427
+ }
428
+ }
429
+
430
+ pub fn define(ruby: &Ruby, native: RModule) -> Result<(), Error> {
431
+ let buffer = native.define_class("ZipBuffer", ruby.class_object())?;
432
+ buffer.define_singleton_method("new", function!(ZipBuffer::new, 1))?;
433
+ let entry = native.define_class("ZipEntry", ruby.class_object())?;
434
+ entry.define_singleton_method("new", function!(ZipEntry::new, 5))?;
435
+ entry.define_method("extract", method!(ZipEntry::extract, 1))?;
436
+ Ok(())
437
+ }
438
+
439
+ #[cfg(test)]
440
+ mod tests {
441
+ use super::*;
442
+ use std::sync::atomic::AtomicUsize;
443
+
444
+ static NEXT_ENTRY: AtomicUsize = AtomicUsize::new(0);
445
+
446
+ fn entry(bytes: &[u8], deflated: bool, size: u64) -> ZipEntry {
447
+ let data = if deflated {
448
+ let mut encoder = flate2::write::DeflateEncoder::new(Vec::new(), Default::default());
449
+ encoder.write_all(bytes).unwrap();
450
+ encoder.finish().unwrap()
451
+ } else {
452
+ bytes.to_vec()
453
+ };
454
+ let mut header = [0; LOCAL_HEADER_LENGTH];
455
+ header[..4].copy_from_slice(&LOCAL_HEADER_SIGNATURE.to_le_bytes());
456
+ let method = if deflated { DEFLATED } else { STORED };
457
+ header[8..10].copy_from_slice(&method.to_le_bytes());
458
+ header[22..26].copy_from_slice(&(size as u32).to_le_bytes());
459
+ let timestamp = std::time::SystemTime::now()
460
+ .duration_since(std::time::UNIX_EPOCH)
461
+ .unwrap()
462
+ .as_nanos();
463
+ let zip = std::env::temp_dir().join(format!(
464
+ "roo-oxide-input-{}-{timestamp}-{}.zip",
465
+ std::process::id(),
466
+ NEXT_ENTRY.fetch_add(1, Ordering::Relaxed)
467
+ ));
468
+ let mut file = File::create_new(&zip).unwrap();
469
+ file.write_all(&header).unwrap();
470
+ file.write_all(&data).unwrap();
471
+ ZipEntry {
472
+ zip: ZipSource::Path(zip),
473
+ offset: 0,
474
+ compressed_size: data.len() as u64,
475
+ size,
476
+ deflated,
477
+ }
478
+ }
479
+
480
+ #[test]
481
+ fn opens_small_entries_on_first_read() {
482
+ let entry = entry(b"x", true, 1);
483
+ let mut input = entry.input();
484
+ let ZipSource::Path(path) = &entry.zip else {
485
+ unreachable!()
486
+ };
487
+ std::fs::remove_file(path).unwrap();
488
+ assert_eq!(
489
+ input.fill_buf().unwrap_err().kind(),
490
+ io::ErrorKind::NotFound
491
+ );
492
+ assert_eq!(
493
+ input.fill_buf().unwrap_err().kind(),
494
+ io::ErrorKind::NotFound
495
+ );
496
+ }
497
+
498
+ #[test]
499
+ fn reads_entry_bytes_and_checks_sizes_across_the_chunk_boundary() {
500
+ for size in [0, 1, CHUNK - 1, CHUNK, CHUNK + 1] {
501
+ let bytes: Vec<_> = (0..size).map(|i| (i % 251) as u8).collect();
502
+ for deflated in [false, true] {
503
+ for declared in [size, size.saturating_sub(1)] {
504
+ let entry = entry(&bytes, deflated, declared as u64);
505
+ let mut actual = Vec::new();
506
+ let result = entry.input().read_to_end(&mut actual);
507
+ let ZipSource::Path(path) = &entry.zip else {
508
+ unreachable!()
509
+ };
510
+ std::fs::remove_file(path).unwrap();
511
+ if declared == size {
512
+ result.unwrap();
513
+ assert_eq!(actual, bytes);
514
+ } else {
515
+ assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidData);
516
+ }
517
+ }
518
+ }
519
+ }
520
+ }
521
+
522
+ fn zip64(size: u64) -> Vec<u8> {
523
+ let mut field = vec![1, 0, 16, 0];
524
+ field.extend(size.to_le_bytes());
525
+ field.extend(7u64.to_le_bytes());
526
+ field
527
+ }
528
+
529
+ #[test]
530
+ fn local_size_reads_the_zip64_field_rubyzip_reads() {
531
+ let other = [0x55, 0x54, 1, 0, 9];
532
+ assert_eq!(local_size(284, &[]), Some(284));
533
+ assert_eq!(local_size(u32::MAX, &zip64(284)), Some(284));
534
+ assert_eq!(
535
+ local_size(u32::MAX, &[&other[..], &zip64(284)].concat()),
536
+ Some(284)
537
+ );
538
+ assert_eq!(local_size(u32::MAX, &[]), None);
539
+ assert_eq!(local_size(u32::MAX, &[zip64(284), zip64(0)].concat()), None);
540
+ assert_eq!(local_size(u32::MAX, &zip64(284)[..10]), None);
541
+ assert_eq!(
542
+ local_size(u32::MAX, &[&zip64(284)[..], &[1, 0]].concat()),
543
+ None
544
+ );
545
+ }
546
+ }