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,143 @@
1
+ //! Map `zip::result::ZipError` onto the `Ziperu::*` exception hierarchy that
2
+ //! `lib/ziperu/errors.rb` defines.
3
+ //!
4
+ //! rubyzip's exception constructors take domain objects (an entry, a Zlib
5
+ //! error) rather than messages, so where Rust lacks the object it raises a
6
+ //! `Ziperu::Native::*` subclass of `Ziperu::Error` and the Ruby layer re-raises
7
+ //! the rubyzip-shaped one.
8
+
9
+ use magnus::{prelude::*, value::Lazy, Error, ExceptionClass, Ruby};
10
+ use std::io;
11
+ use zip::result::ZipError;
12
+
13
+ use crate::source::RubyError;
14
+
15
+ macro_rules! exception {
16
+ ($vis:vis $name:ident, $path:literal) => {
17
+ $vis static $name: Lazy<ExceptionClass> = Lazy::new(|ruby| {
18
+ ruby.eval::<ExceptionClass>($path)
19
+ .expect(concat!($path, " must be defined in lib/ziperu/errors.rb"))
20
+ });
21
+ };
22
+ }
23
+
24
+ exception!(pub ERROR, "Ziperu::Error");
25
+ exception!(DECOMPRESSION_ERROR, "Ziperu::DecompressionError");
26
+ exception!(NATIVE_SIZE_LIMIT_ERROR, "Ziperu::Native::SizeLimitError");
27
+ exception!(NATIVE_PASSWORD_ERROR, "Ziperu::Native::PasswordError");
28
+ exception!(NATIVE_UNSUPPORTED_ERROR, "Ziperu::Native::UnsupportedError");
29
+ exception!(ENOENT, "Errno::ENOENT");
30
+ exception!(EACCES, "Errno::EACCES");
31
+ exception!(EEXIST, "Errno::EEXIST");
32
+ exception!(EISDIR, "Errno::EISDIR");
33
+ exception!(ENOTDIR, "Errno::ENOTDIR");
34
+
35
+ pub fn zip_error(ruby: &Ruby, err: ZipError) -> Error {
36
+ match err {
37
+ ZipError::Io(e) => io_error(ruby, e),
38
+ ZipError::FileNotFound => Error::new(ruby.get_inner(&ERROR), "entry not found in archive"),
39
+ ZipError::InvalidPassword => {
40
+ Error::new(ruby.get_inner(&NATIVE_PASSWORD_ERROR), "invalid password")
41
+ }
42
+ ZipError::DecompressedSizeLimitExceeded { .. } => size_limit_error(ruby),
43
+ ZipError::UnsupportedArchive(msg) => {
44
+ Error::new(ruby.get_inner(&NATIVE_UNSUPPORTED_ERROR), msg)
45
+ }
46
+ ZipError::InvalidArchive(msg) => {
47
+ Error::new(ruby.get_inner(&ERROR), invalid_archive_message(&msg))
48
+ }
49
+ other => Error::new(ruby.get_inner(&ERROR), other.to_string()),
50
+ }
51
+ }
52
+
53
+ /// The crate's wording, except where rubyzip's is what callers match on.
54
+ fn invalid_archive_message(msg: &str) -> String {
55
+ if msg.contains("EOCD") {
56
+ "Zip end of central directory signature not found".to_string()
57
+ } else {
58
+ msg.to_string()
59
+ }
60
+ }
61
+
62
+ /// Raised when a thread calls into a native object another thread is
63
+ /// using with the GVL released (an inflate or deflate in progress).
64
+ pub fn busy_error(ruby: &Ruby, what: &str) -> Error {
65
+ Error::new(
66
+ ruby.exception_thread_error(),
67
+ format!("{what} is in use by another thread"),
68
+ )
69
+ }
70
+
71
+ pub fn io_error(ruby: &Ruby, err: io::Error) -> Error {
72
+ // An exception from a Ruby IO source propagates unchanged.
73
+ let err = match err.downcast::<RubyError>() {
74
+ Ok(ruby_err) => return ruby_err.0,
75
+ Err(err) => err,
76
+ };
77
+ // The crate funnels its own errors through io::Error while reading.
78
+ if let Some(inner) = err.get_ref().and_then(|e| e.downcast_ref::<ZipError>()) {
79
+ return match inner {
80
+ ZipError::DecompressedSizeLimitExceeded { .. } => size_limit_error(ruby),
81
+ ZipError::InvalidPassword => {
82
+ Error::new(ruby.get_inner(&NATIVE_PASSWORD_ERROR), "invalid password")
83
+ }
84
+ ZipError::UnsupportedArchive(msg) => {
85
+ Error::new(ruby.get_inner(&NATIVE_UNSUPPORTED_ERROR), *msg)
86
+ }
87
+ other => Error::new(ruby.get_inner(&ERROR), other.to_string()),
88
+ };
89
+ }
90
+ match err.kind() {
91
+ io::ErrorKind::NotFound => Error::new(ruby.get_inner(&ENOENT), err.to_string()),
92
+ io::ErrorKind::PermissionDenied => Error::new(ruby.get_inner(&EACCES), err.to_string()),
93
+ io::ErrorKind::AlreadyExists => Error::new(ruby.get_inner(&EEXIST), err.to_string()),
94
+ io::ErrorKind::IsADirectory => Error::new(ruby.get_inner(&EISDIR), err.to_string()),
95
+ io::ErrorKind::NotADirectory => Error::new(ruby.get_inner(&ENOTDIR), err.to_string()),
96
+ // The crate's size check reports as plain InvalidData (crc32.rs).
97
+ io::ErrorKind::InvalidData
98
+ if err
99
+ .to_string()
100
+ .contains("larger than its declared uncompressed size") =>
101
+ {
102
+ size_limit_error(ruby)
103
+ }
104
+ io::ErrorKind::InvalidData => decompression_error(ruby, &err.to_string()),
105
+ _ => Error::new(ruby.exception_io_error(), err.to_string()),
106
+ }
107
+ }
108
+
109
+ /// `io_error` for a failure while reading an entry's data, where the
110
+ /// decoders report corrupt input as `InvalidInput` and a stream that ends
111
+ /// early as `UnexpectedEof`: both are rubyzip's `DecompressionError`, which
112
+ /// is what callers rescue around a read.
113
+ pub fn read_error(ruby: &Ruby, err: io::Error) -> Error {
114
+ let foreign = err
115
+ .get_ref()
116
+ .is_some_and(|e| e.is::<RubyError>() || e.is::<ZipError>());
117
+ match err.kind() {
118
+ io::ErrorKind::InvalidInput | io::ErrorKind::UnexpectedEof if !foreign => {
119
+ decompression_error(ruby, &err.to_string())
120
+ }
121
+ _ => io_error(ruby, err),
122
+ }
123
+ }
124
+
125
+ pub fn size_limit_error(ruby: &Ruby) -> Error {
126
+ Error::new(
127
+ ruby.get_inner(&NATIVE_SIZE_LIMIT_ERROR),
128
+ "entry is larger than its declared size",
129
+ )
130
+ }
131
+
132
+ /// `Ziperu::DecompressionError.new(zlib_error)` wants an error object; an
133
+ /// `IOError` carrying the crate's message plays that role.
134
+ fn decompression_error(ruby: &Ruby, msg: &str) -> Error {
135
+ let cause = match ruby.exception_io_error().new_instance((msg,)) {
136
+ Ok(c) => c,
137
+ Err(e) => return e,
138
+ };
139
+ match ruby.get_inner(&DECOMPRESSION_ERROR).new_instance((cause,)) {
140
+ Ok(exc) => Error::from(exc),
141
+ Err(e) => e,
142
+ }
143
+ }
@@ -0,0 +1,505 @@
1
+ //! The native fast path behind `Ziperu::File#extract_all`.
2
+ //!
3
+ //! Ruby decides which entries qualify (no overwrite decision to make, no
4
+ //! encryption, no symlinks, no ownership to restore) and hands over one
5
+ //! spec per entry; this side creates each parent directory once, writes
6
+ //! every file through a single handle and stamps times and permissions on
7
+ //! that handle, all with the GVL released when the source allows it.
8
+ //!
9
+ //! The path check is the one `Entry#extract` makes, re-done here on the
10
+ //! same terms: the entry name is joined onto the absolute destination and
11
+ //! normalised lexically (as `File.absolute_path` does), and the result must
12
+ //! start with the destination plus a separator. Nothing is created for an
13
+ //! entry that fails it; Ruby prints the same warning it would have.
14
+
15
+ use std::collections::HashSet;
16
+ use std::fs::{self, FileTimes, OpenOptions};
17
+ use std::io::{self, Read, Write};
18
+ use std::path::{Path, PathBuf};
19
+ use std::time::{Duration, SystemTime};
20
+
21
+ use magnus::{
22
+ prelude::*, value::Lazy, Error, ExceptionClass, RArray, RString, Ruby, Symbol, Value,
23
+ };
24
+ use zip::read::{read_zipfile_from_stream_with_options, ZipReadOptions};
25
+ use zip::result::ZipError;
26
+ use zip::ZipArchive;
27
+
28
+ use crate::errors::{read_error, zip_error};
29
+ use crate::gvl::maybe_without_gvl;
30
+ use crate::source::Source;
31
+
32
+ const CHUNK: usize = 64 * 1024;
33
+
34
+ static DESTINATION_EXISTS_ERROR: Lazy<ExceptionClass> = Lazy::new(|ruby| {
35
+ ruby.eval::<ExceptionClass>("Ziperu::DestinationExistsError")
36
+ .expect("Ziperu::DestinationExistsError must be defined in lib/ziperu/errors.rb")
37
+ });
38
+ static ENTRY_SIZE_ERROR: Lazy<ExceptionClass> = Lazy::new(|ruby| {
39
+ ruby.eval::<ExceptionClass>("Ziperu::EntrySizeError")
40
+ .expect("Ziperu::EntrySizeError must be defined in lib/ziperu/errors.rb")
41
+ });
42
+
43
+ /// One entry to extract, as Ruby describes it:
44
+ /// `[entry, index, name, mtime_sec, mtime_nsec, mode, directory?]`.
45
+ struct Spec {
46
+ index: usize,
47
+ name: String,
48
+ /// `None` when times are not restored.
49
+ mtime: Option<SystemTime>,
50
+ /// Unix mode to apply, already masked by Ruby; `None` to leave alone.
51
+ mode: Option<u32>,
52
+ directory: bool,
53
+ }
54
+
55
+ /// Why an entry could not be extracted; turned into a Ruby exception once
56
+ /// the GVL is held again.
57
+ enum Failure {
58
+ Exists(String),
59
+ SizeLimit,
60
+ Zip(ZipError),
61
+ Io(io::Error),
62
+ }
63
+
64
+ impl From<ZipError> for Failure {
65
+ fn from(e: ZipError) -> Self {
66
+ match e {
67
+ ZipError::DecompressedSizeLimitExceeded { .. } => Failure::SizeLimit,
68
+ other => Failure::Zip(other),
69
+ }
70
+ }
71
+ }
72
+
73
+ impl From<io::Error> for Failure {
74
+ fn from(e: io::Error) -> Self {
75
+ // The crate reports a header that understates the size either as
76
+ // its own error behind an io::Error, or (crc32.rs) as plain
77
+ // InvalidData with a message.
78
+ let limit = e
79
+ .get_ref()
80
+ .and_then(|inner| inner.downcast_ref::<ZipError>())
81
+ .is_some_and(|z| matches!(z, ZipError::DecompressedSizeLimitExceeded { .. }))
82
+ || (e.kind() == io::ErrorKind::InvalidData
83
+ && e.to_string()
84
+ .contains("larger than its declared uncompressed size"));
85
+ if limit {
86
+ Failure::SizeLimit
87
+ } else {
88
+ Failure::Io(e)
89
+ }
90
+ }
91
+ }
92
+
93
+ /// Something Ruby should warn about afterwards.
94
+ enum Notice {
95
+ /// The entry resolved outside the destination; nothing was written.
96
+ Unsafe(String),
97
+ /// Without size enforcement, more bytes came out than declared.
98
+ Oversize,
99
+ }
100
+
101
+ /// Extracts `specs` under `dest` (an absolute path) in order and returns
102
+ /// one `[index, kind, path]` triple per entry that was skipped as unsafe
103
+ /// (`kind` `:unsafe`) or came out larger than declared (`:oversize`, path
104
+ /// nil). An existing file raises `Ziperu::DestinationExistsError`, a header
105
+ /// that understates the size `Ziperu::EntrySizeError`; either leaves the
106
+ /// entries before it extracted, as the Ruby loop would.
107
+ pub fn extract_entries(
108
+ ruby: &Ruby,
109
+ archive: ZipArchive<Source>,
110
+ needs_gvl: bool,
111
+ dest: String,
112
+ specs: RArray,
113
+ enforce_size: bool,
114
+ ) -> Result<RArray, Error> {
115
+ let mut entries = Vec::with_capacity(specs.len());
116
+ let mut parsed = Vec::with_capacity(specs.len());
117
+ for spec in specs.into_iter() {
118
+ let spec = RArray::try_convert(spec)?;
119
+ let entry: Value = spec.entry(0)?;
120
+ let index: usize = spec.entry(1)?;
121
+ let name: RString = spec.entry(2)?;
122
+ let name = name
123
+ .to_string()
124
+ .map_err(|_| Error::new(ruby.exception_arg_error(), "entry name is not UTF-8"))?;
125
+ let mtime = match spec.entry::<Option<i64>>(3)? {
126
+ Some(sec) => Some(system_time(sec, spec.entry(4)?)),
127
+ None => None,
128
+ };
129
+ let mode: Option<u32> = spec.entry(5)?;
130
+ let directory: bool = spec.entry(6)?;
131
+ // Kept only to name the entry in an exception; `specs` keeps it
132
+ // alive, and nothing touches it without the GVL.
133
+ entries.push(entry);
134
+ parsed.push(Spec {
135
+ index,
136
+ name,
137
+ mtime,
138
+ mode,
139
+ directory,
140
+ });
141
+ }
142
+
143
+ let outcome = maybe_without_gvl(needs_gvl, || {
144
+ let mut extractor = Extractor {
145
+ archive,
146
+ dest: dest.as_str(),
147
+ enforce_size,
148
+ made: HashSet::new(),
149
+ buf: vec![0; CHUNK],
150
+ notices: Vec::new(),
151
+ };
152
+ for (i, spec) in parsed.iter().enumerate() {
153
+ if let Err(failure) = extractor.extract(spec) {
154
+ return Err((i, failure));
155
+ }
156
+ }
157
+ Ok(extractor.notices)
158
+ });
159
+
160
+ let notices = match outcome {
161
+ Ok(notices) => notices,
162
+ Err((i, failure)) => return Err(raise(ruby, entries[i], failure)),
163
+ };
164
+ let unsafe_kind = ruby.to_symbol("unsafe");
165
+ let oversize_kind = ruby.to_symbol("oversize");
166
+ let out = ruby.ary_new_capa(notices.len());
167
+ for (index, notice) in notices {
168
+ let triple: (usize, Symbol, Option<String>) = match notice {
169
+ Notice::Unsafe(path) => (index, unsafe_kind, Some(path)),
170
+ Notice::Oversize => (index, oversize_kind, None),
171
+ };
172
+ out.push(triple)?;
173
+ }
174
+ Ok(out)
175
+ }
176
+
177
+ fn system_time(sec: i64, nsec: u32) -> SystemTime {
178
+ let nanos = Duration::from_nanos(u64::from(nsec));
179
+ match u64::try_from(sec) {
180
+ Ok(s) => SystemTime::UNIX_EPOCH + Duration::from_secs(s) + nanos,
181
+ Err(_) => SystemTime::UNIX_EPOCH - Duration::from_secs(sec.unsigned_abs()) + nanos,
182
+ }
183
+ }
184
+
185
+ fn raise(ruby: &Ruby, entry: Value, failure: Failure) -> Error {
186
+ match failure {
187
+ Failure::Exists(path) => exception(ruby, &DESTINATION_EXISTS_ERROR, (path,)),
188
+ Failure::SizeLimit => exception(ruby, &ENTRY_SIZE_ERROR, (entry,)),
189
+ Failure::Zip(e) => zip_error(ruby, e),
190
+ Failure::Io(e) => read_error(ruby, e),
191
+ }
192
+ }
193
+
194
+ fn exception<A: magnus::ArgList>(ruby: &Ruby, class: &Lazy<ExceptionClass>, args: A) -> Error {
195
+ match ruby.get_inner(class).new_instance(args) {
196
+ Ok(exc) => Error::from(exc),
197
+ Err(e) => e,
198
+ }
199
+ }
200
+
201
+ struct Extractor<'a> {
202
+ archive: ZipArchive<Source>,
203
+ dest: &'a str,
204
+ enforce_size: bool,
205
+ /// Directories known to exist, so each parent is created once.
206
+ made: HashSet<PathBuf>,
207
+ buf: Vec<u8>,
208
+ notices: Vec<(usize, Notice)>,
209
+ }
210
+
211
+ impl Extractor<'_> {
212
+ fn extract(&mut self, spec: &Spec) -> Result<(), Failure> {
213
+ let Some(path) = safe_path(self.dest, &spec.name) else {
214
+ let shown = resolve(self.dest, &spec.name);
215
+ self.notices.push((spec.index, Notice::Unsafe(shown)));
216
+ return Ok(());
217
+ };
218
+ let path = PathBuf::from(path);
219
+ if spec.directory {
220
+ self.create_directory(&path, spec)
221
+ } else {
222
+ self.create_file(&path, spec)
223
+ }
224
+ }
225
+
226
+ fn create_directory(&mut self, path: &Path, spec: &Spec) -> Result<(), Failure> {
227
+ if self.made.contains(path) {
228
+ return Ok(());
229
+ }
230
+ if path.is_dir() {
231
+ self.made.insert(path.to_path_buf());
232
+ return Ok(());
233
+ }
234
+ if path.exists() {
235
+ return Err(Failure::Exists(path_string(path)));
236
+ }
237
+ fs::create_dir_all(path)?;
238
+ self.made.insert(path.to_path_buf());
239
+ if let Some(mtime) = spec.mtime {
240
+ let times = FileTimes::new().set_accessed(mtime).set_modified(mtime);
241
+ open_directory(path)?.set_times(times)?;
242
+ }
243
+ if let Some(mode) = spec.mode {
244
+ set_mode(path, mode)?;
245
+ }
246
+ Ok(())
247
+ }
248
+
249
+ fn create_file(&mut self, path: &Path, spec: &Spec) -> Result<(), Failure> {
250
+ if let Some(parent) = path.parent() {
251
+ if !self.made.contains(parent) {
252
+ fs::create_dir_all(parent)?;
253
+ self.made.insert(parent.to_path_buf());
254
+ }
255
+ }
256
+ let mut file = match OpenOptions::new().write(true).create_new(true).open(path) {
257
+ Ok(f) => f,
258
+ Err(e) if e.kind() == io::ErrorKind::AlreadyExists || path.exists() => {
259
+ return Err(Failure::Exists(path_string(path)));
260
+ }
261
+ Err(e) => return Err(e.into()),
262
+ };
263
+ let written = self.copy_entry(spec.index, &mut file)?;
264
+ if let Some(mtime) = spec.mtime {
265
+ file.set_times(FileTimes::new().set_accessed(mtime).set_modified(mtime))?;
266
+ }
267
+ if let Some(mode) = spec.mode {
268
+ set_file_mode(&file, mode)?;
269
+ }
270
+ drop(file);
271
+ if !self.enforce_size {
272
+ let declared = self.archive.metadata().entry(spec.index)?.size();
273
+ if written > declared {
274
+ self.notices.push((spec.index, Notice::Oversize));
275
+ }
276
+ }
277
+ Ok(())
278
+ }
279
+
280
+ /// Decompresses entry `index` into `file`, returning the byte count.
281
+ fn copy_entry(&mut self, index: usize, file: &mut fs::File) -> Result<u64, Failure> {
282
+ let buf = &mut self.buf;
283
+ let mut written = 0u64;
284
+ let mut copy = |reader: &mut dyn Read| -> Result<(), Failure> {
285
+ loop {
286
+ let n = reader.read(buf)?;
287
+ if n == 0 {
288
+ return Ok(());
289
+ }
290
+ file.write_all(&buf[..n])?;
291
+ written += n as u64;
292
+ }
293
+ };
294
+ if self.enforce_size {
295
+ // An entry declared empty is never read, as `EntryReader` has
296
+ // it: the file is created and left empty.
297
+ if self.archive.metadata().entry(index)?.size() == 0 {
298
+ return Ok(0);
299
+ }
300
+ let mut entry = self.archive.by_index(index)?;
301
+ copy(&mut entry)?;
302
+ } else {
303
+ // Past the declared size to the true end of the data, the way
304
+ // `EntryReader` does without enforcement.
305
+ let (header_start, compressed_size, crc32) = {
306
+ let meta = self.archive.metadata();
307
+ let raw = meta.entry(index)?;
308
+ (raw.header_start(), raw.compressed_size(), raw.crc32())
309
+ };
310
+ let mut source = self.archive.clone().into_inner();
311
+ source.seek_to(header_start)?;
312
+ let options = ZipReadOptions::new()
313
+ .override_compressed_size(compressed_size)
314
+ .override_uncompressed_size(u64::MAX)
315
+ .override_crc(crc32);
316
+ let mut entry = read_zipfile_from_stream_with_options(&mut source, options)?.ok_or(
317
+ ZipError::InvalidArchive("expected a local file header".into()),
318
+ )?;
319
+ copy(&mut entry)?;
320
+ }
321
+ Ok(written)
322
+ }
323
+ }
324
+
325
+ fn path_string(path: &Path) -> String {
326
+ path.to_string_lossy().replace('\\', "/")
327
+ }
328
+
329
+ /// The absolute path `name` lands on under `dest`, as `File.absolute_path`
330
+ /// resolves `File.join(dest, name)`, or `None` when it is not strictly
331
+ /// inside `dest`. The one exception, as in Ruby, is a destination that is
332
+ /// the root directory itself.
333
+ fn safe_path(dest: &str, name: &str) -> Option<String> {
334
+ let resolved = resolve(dest, name);
335
+ if dest == "/" {
336
+ return Some(resolved);
337
+ }
338
+ let dest = resolve(dest, "");
339
+ let prefix = if dest.ends_with('/') {
340
+ dest
341
+ } else {
342
+ format!("{dest}/")
343
+ };
344
+ resolved.starts_with(&prefix).then_some(resolved)
345
+ }
346
+
347
+ /// Lexical `File.absolute_path(File.join(dest, name))` for an absolute
348
+ /// `dest`: either separator, `.` and empty components dropped, `..`
349
+ /// popping the previous component but never the root (`/`, a drive or a
350
+ /// UNC share).
351
+ fn resolve(dest: &str, name: &str) -> String {
352
+ let mut parts: Vec<&str> = Vec::new();
353
+ let mut components = dest.split(['/', '\\']).peekable();
354
+ match components.peek().copied() {
355
+ Some("") => {
356
+ parts.push("");
357
+ components.next();
358
+ if components.peek() == Some(&"") && is_unc(dest) {
359
+ // "//server/share/...": the share is the root.
360
+ components.next();
361
+ parts.push("");
362
+ parts.extend(components.by_ref().take(2));
363
+ }
364
+ }
365
+ Some(c) if is_drive(c) => {
366
+ parts.push(c);
367
+ components.next();
368
+ }
369
+ _ => {}
370
+ }
371
+ let root_len = parts.len();
372
+ for c in components.chain(name.split(['/', '\\'])) {
373
+ match c {
374
+ "" | "." => {}
375
+ ".." => {
376
+ if parts.len() > root_len {
377
+ parts.pop();
378
+ }
379
+ }
380
+ c => parts.push(c),
381
+ }
382
+ }
383
+ let joined = parts.join("/");
384
+ if parts.len() == root_len {
385
+ // Only the root is left: "/" rather than "", "C:/" rather than "C:".
386
+ if joined.is_empty() {
387
+ "/".to_string()
388
+ } else {
389
+ format!("{joined}/")
390
+ }
391
+ } else {
392
+ joined
393
+ }
394
+ }
395
+
396
+ fn is_drive(c: &str) -> bool {
397
+ let b = c.as_bytes();
398
+ b.len() == 2 && b[0].is_ascii_alphabetic() && b[1] == b':'
399
+ }
400
+
401
+ /// "//server/..." (or with backslashes), as opposed to a bare "//".
402
+ fn is_unc(path: &str) -> bool {
403
+ let b = path.as_bytes();
404
+ b.len() > 2 && is_sep(b[0]) && is_sep(b[1]) && !is_sep(b[2])
405
+ }
406
+
407
+ fn is_sep(b: u8) -> bool {
408
+ b == b'/' || b == b'\\'
409
+ }
410
+
411
+ /// A directory handle that `set_times` accepts: on Windows that means
412
+ /// backup semantics and write-attributes access, which a plain `open`
413
+ /// does not ask for.
414
+ #[cfg(windows)]
415
+ fn open_directory(path: &Path) -> io::Result<fs::File> {
416
+ use std::os::windows::fs::OpenOptionsExt;
417
+ const FILE_WRITE_ATTRIBUTES: u32 = 0x0100;
418
+ const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
419
+ OpenOptions::new()
420
+ .access_mode(FILE_WRITE_ATTRIBUTES)
421
+ .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
422
+ .open(path)
423
+ }
424
+
425
+ #[cfg(not(windows))]
426
+ fn open_directory(path: &Path) -> io::Result<fs::File> {
427
+ fs::File::open(path)
428
+ }
429
+
430
+ #[cfg(unix)]
431
+ fn set_file_mode(file: &fs::File, mode: u32) -> io::Result<()> {
432
+ use std::os::unix::fs::PermissionsExt;
433
+ file.set_permissions(fs::Permissions::from_mode(mode))
434
+ }
435
+
436
+ #[cfg(unix)]
437
+ fn set_mode(path: &Path, mode: u32) -> io::Result<()> {
438
+ use std::os::unix::fs::PermissionsExt;
439
+ fs::set_permissions(path, fs::Permissions::from_mode(mode))
440
+ }
441
+
442
+ /// Ruby's `File.chmod` on Windows only knows the read-only attribute,
443
+ /// set when the owner-write bit is clear.
444
+ #[cfg(not(unix))]
445
+ fn set_file_mode(file: &fs::File, mode: u32) -> io::Result<()> {
446
+ if mode & 0o200 == 0 {
447
+ let mut perms = file.metadata()?.permissions();
448
+ perms.set_readonly(true);
449
+ file.set_permissions(perms)?;
450
+ }
451
+ Ok(())
452
+ }
453
+
454
+ #[cfg(not(unix))]
455
+ fn set_mode(path: &Path, mode: u32) -> io::Result<()> {
456
+ if mode & 0o200 == 0 {
457
+ let mut perms = fs::metadata(path)?.permissions();
458
+ perms.set_readonly(true);
459
+ fs::set_permissions(path, perms)?;
460
+ }
461
+ Ok(())
462
+ }
463
+
464
+ #[cfg(test)]
465
+ mod tests {
466
+ use super::*;
467
+
468
+ #[test]
469
+ fn resolves_like_absolute_path() {
470
+ assert_eq!(resolve("/p/dest", "a/b"), "/p/dest/a/b");
471
+ assert_eq!(resolve("/p/dest", "/tmp/moo"), "/p/dest/tmp/moo");
472
+ assert_eq!(resolve("/p/dest", "//tmp/moo"), "/p/dest/tmp/moo");
473
+ assert_eq!(resolve("/p/dest", "../moo"), "/p/moo");
474
+ assert_eq!(resolve("/p/dest", "tmp/../../moo"), "/p/moo");
475
+ assert_eq!(resolve("/p/dest", "a/../b"), "/p/dest/b");
476
+ assert_eq!(resolve("/p/dest", "../dest/y"), "/p/dest/y");
477
+ assert_eq!(resolve("/p/dest", "dir/"), "/p/dest/dir");
478
+ assert_eq!(resolve("/p/dest", "."), "/p/dest");
479
+ assert_eq!(resolve("/p/dest", "../../../../x"), "/x");
480
+ assert_eq!(resolve("/", "../x"), "/x");
481
+ assert_eq!(resolve("/", ""), "/");
482
+ assert_eq!(resolve("C:/dest", "a\\b"), "C:/dest/a/b");
483
+ assert_eq!(resolve("C:/dest", "../../x"), "C:/x");
484
+ assert_eq!(resolve("C:/", ".."), "C:/");
485
+ assert_eq!(resolve("//srv/share/d", "../../../x"), "//srv/share/x");
486
+ assert_eq!(resolve("//", "x"), "/x");
487
+ }
488
+
489
+ #[test]
490
+ fn checks_prefix() {
491
+ assert_eq!(safe_path("/p/dest", "a").as_deref(), Some("/p/dest/a"));
492
+ assert_eq!(safe_path("/p/dest/", "a").as_deref(), Some("/p/dest/a"));
493
+ assert!(safe_path("/p/dest", "../moo").is_none());
494
+ assert!(safe_path("/p/dest", "tmp/../../moo").is_none());
495
+ assert!(safe_path("/p/dest", ".").is_none());
496
+ assert!(safe_path("/p/dest", "").is_none());
497
+ assert!(safe_path("/p/dest", "../dest_evil/x").is_none());
498
+ assert_eq!(safe_path("/", "../x").as_deref(), Some("/x"));
499
+ assert_eq!(
500
+ safe_path("C:/dest", "/tmp/moo").as_deref(),
501
+ Some("C:/dest/tmp/moo")
502
+ );
503
+ assert_eq!(safe_path("C:/", "x").as_deref(), Some("C:/x"));
504
+ }
505
+ }