winbrew_core\fs\archive\extract/
zip.rs1use std::fs;
2use std::io::{Read, Write};
3use std::path::Path;
4
5use crate::fs::{FsError, Result};
6
7use super::super::context::ExtractionContext;
8use super::super::limits::ExtractionLimits;
9use super::super::platform::PlatformAdapter;
10
11pub(crate) fn extract_zip_archive_with_platform<P: PlatformAdapter>(
12 zip_path: &Path,
13 destination_dir: &Path,
14 limits: ExtractionLimits,
15) -> Result<()> {
16 let file = fs::File::open(zip_path).map_err(|err| FsError::open_zip_archive(zip_path, err))?;
17 let mut archive =
18 zip::ZipArchive::new(file).map_err(|err| FsError::open_zip_archive(zip_path, err))?;
19 const ZIP_COPY_BUFFER_SIZE: usize = 256 * 1024;
20 let mut extraction = ExtractionContext::<P>::new(limits);
21 let mut buffer = vec![0u8; ZIP_COPY_BUFFER_SIZE];
22
23 for index in 0..archive.len() {
24 let mut entry = archive
25 .by_index(index)
26 .map_err(|err| FsError::read_zip_entry(zip_path, err))?;
27 extract_entry(&mut entry, destination_dir, &mut extraction, &mut buffer)?;
28 }
29
30 extraction.commit();
31 Ok(())
32}
33
34fn extract_entry<P: PlatformAdapter, R: Read>(
35 entry: &mut zip::read::ZipFile<'_, R>,
36 destination_dir: &Path,
37 extraction: &mut ExtractionContext<P>,
38 buffer: &mut [u8],
39) -> Result<()> {
40 let enclosed_name = entry
41 .enclosed_name()
42 .ok_or_else(FsError::invalid_zip_entry_path)?;
43
44 if entry.is_symlink() {
45 return Err(FsError::symlink_entry(
46 &destination_dir.join(&enclosed_name),
47 ));
48 }
49
50 let outpath = destination_dir.join(&enclosed_name);
51
52 extraction.validate_target(&outpath, destination_dir)?;
53
54 extraction.check_limits(&enclosed_name, entry.size(), entry.compressed_size())?;
55
56 if entry.is_dir() {
57 extraction.ensure_directory_tree(&outpath)?;
58 return Ok(());
59 }
60
61 if let Some(parent) = outpath.parent() {
62 extraction.ensure_directory_tree(parent)?;
63 }
64
65 let mut outfile = P::create_extraction_target_file(&outpath)
66 .map_err(|err| FsError::create_extracted_file(&outpath, err))?;
67 extraction.record_file(&outpath);
68
69 loop {
70 let bytes_read = entry
71 .read(buffer)
72 .map_err(|err| FsError::read_entry(&outpath, err))?;
73 if bytes_read == 0 {
74 break;
75 }
76
77 outfile
78 .write_all(&buffer[..bytes_read])
79 .map_err(|err| FsError::write_entry(&outpath, err))?;
80 }
81
82 Ok(())
83}
84
85#[cfg(test)]
86mod tests {
87 use super::super::extract_zip_archive_with_limits;
88 use super::*;
89 use crate::fs::archive::extract_zip_archive;
90 use std::fs;
91 use std::io::Write;
92 use tempfile::tempdir;
93 use zip::ZipWriter;
94 use zip::write::SimpleFileOptions;
95
96 fn create_zip_archive(path: &std::path::Path, file_name: &str, contents: &[u8]) {
97 let file = fs::File::create(path).expect("create zip file");
98 let mut writer = ZipWriter::new(file);
99 writer
100 .start_file(file_name, SimpleFileOptions::default())
101 .expect("start zip entry");
102 writer.write_all(contents).expect("write zip contents");
103 writer.finish().expect("finish zip file");
104 }
105
106 fn create_symlink_archive(path: &std::path::Path, link_name: &str, target: &str) {
107 let file = fs::File::create(path).expect("create zip file");
108 let mut writer = ZipWriter::new(file);
109 writer
110 .add_symlink(link_name, target, SimpleFileOptions::default())
111 .expect("add zip symlink");
112 writer.finish().expect("finish zip file");
113 }
114
115 fn create_archive_with_entries(path: &std::path::Path, entries: &[(&str, &[u8])]) {
116 let file = fs::File::create(path).expect("create zip file");
117 let mut writer = ZipWriter::new(file);
118
119 for (name, contents) in entries {
120 writer
121 .start_file(name, SimpleFileOptions::default())
122 .expect("start zip entry");
123 writer.write_all(contents).expect("write zip contents");
124 }
125
126 writer.finish().expect("finish zip file");
127 }
128
129 #[test]
130 #[cfg(windows)]
131 fn extract_zip_archive_rejects_hardlinked_targets() {
132 let temp_dir = tempdir().expect("temp dir");
133 let destination_dir = temp_dir.path().join("dest");
134 let anchor_path = temp_dir.path().join("anchor.txt");
135 let target_path = destination_dir.join("payload.txt");
136 let zip_path = temp_dir.path().join("archive.zip");
137
138 fs::create_dir_all(&destination_dir).expect("destination dir");
139 fs::write(&anchor_path, b"anchor").expect("anchor file");
140 fs::hard_link(&anchor_path, &target_path).expect("hard link");
141 create_zip_archive(&zip_path, "payload.txt", b"zip payload");
142
143 let error = extract_zip_archive(&zip_path, &destination_dir)
144 .expect_err("expected hardlinked target rejection");
145
146 assert!(error.to_string().contains("hardlinked file"));
147 }
148
149 #[test]
150 fn extract_zip_archive_rejects_symlink_entries() {
151 let temp_dir = tempdir().expect("temp dir");
152 let destination_dir = temp_dir.path().join("dest");
153 let zip_path = temp_dir.path().join("archive.zip");
154
155 fs::create_dir_all(&destination_dir).expect("destination dir");
156 create_symlink_archive(&zip_path, "bin/tool.exe", "target.exe");
157
158 let error = extract_zip_archive(&zip_path, &destination_dir)
159 .expect_err("expected symlink rejection");
160
161 assert!(
162 error
163 .to_string()
164 .contains("refusing to extract symlink entry")
165 );
166 assert!(!destination_dir.join("bin").exists());
167 }
168
169 #[test]
170 fn extract_zip_archive_cleans_partial_output_on_failure() {
171 let temp_dir = tempdir().expect("temp dir");
172 let destination_dir = temp_dir.path().join("dest");
173 let zip_path = temp_dir.path().join("archive.zip");
174
175 let file = fs::File::create(&zip_path).expect("create zip file");
176 let mut writer = ZipWriter::new(file);
177 writer
178 .start_file("bin/ok.txt", SimpleFileOptions::default())
179 .expect("start ok entry");
180 writer.write_all(b"ok").expect("write ok entry");
181 writer
182 .add_symlink("bin/bad-link", "target.exe", SimpleFileOptions::default())
183 .expect("add symlink entry");
184 writer.finish().expect("finish zip file");
185
186 let error = extract_zip_archive(&zip_path, &destination_dir)
187 .expect_err("expected cleanup after partial extraction failure");
188
189 assert!(
190 error
191 .to_string()
192 .contains("refusing to extract symlink entry")
193 );
194 assert!(!destination_dir.exists());
195 }
196
197 #[test]
198 fn extract_zip_archive_extracts_files_correctly() {
199 let temp_dir = tempdir().expect("temp dir");
200 let destination_dir = temp_dir.path().join("dest");
201 let zip_path = temp_dir.path().join("archive.zip");
202
203 fs::create_dir_all(&destination_dir).expect("dest dir");
204 create_zip_archive(&zip_path, "bin/tool.exe", b"binary content");
205
206 extract_zip_archive(&zip_path, &destination_dir).expect("extraction");
207
208 assert_eq!(
209 fs::read(destination_dir.join("bin/tool.exe")).expect("read"),
210 b"binary content"
211 );
212 }
213
214 #[test]
215 fn extract_zip_archive_rejects_existing_target_files() {
216 let temp_dir = tempdir().expect("temp dir");
217 let destination_dir = temp_dir.path().join("dest");
218 let existing_target = destination_dir.join("bin/tool.exe");
219 let zip_path = temp_dir.path().join("archive.zip");
220
221 fs::create_dir_all(existing_target.parent().expect("parent dir")).expect("destination dir");
222 fs::write(&existing_target, b"existing content").expect("preexisting target");
223 create_zip_archive(&zip_path, "bin/tool.exe", b"new content");
224
225 let error = extract_zip_archive(&zip_path, &destination_dir)
226 .expect_err("expected overwrite protection");
227
228 assert!(
229 error
230 .to_string()
231 .contains("failed to create extracted file")
232 );
233 assert_eq!(
234 fs::read(&existing_target).expect("read preexisting target"),
235 b"existing content"
236 );
237 }
238
239 #[test]
240 fn extract_zip_archive_cleans_deeply_nested_partial_output() {
241 let temp_dir = tempdir().expect("temp dir");
242 let destination_dir = temp_dir.path().join("dest");
243 let zip_path = temp_dir.path().join("archive.zip");
244
245 fs::create_dir_all(&destination_dir).expect("destination dir");
246
247 let file = fs::File::create(&zip_path).expect("create zip file");
248 let mut writer = ZipWriter::new(file);
249 writer
250 .start_file("a/b/c/d/file.txt", SimpleFileOptions::default())
251 .expect("start file entry");
252 writer.write_all(b"payload").expect("write payload");
253 writer
254 .add_symlink(
255 "a/b/c/d/bad-link",
256 "target.exe",
257 SimpleFileOptions::default(),
258 )
259 .expect("add symlink entry");
260 writer.finish().expect("finish zip file");
261
262 let error = extract_zip_archive(&zip_path, &destination_dir)
263 .expect_err("expected cleanup after nested failure");
264
265 assert!(
266 error
267 .to_string()
268 .contains("refusing to extract symlink entry")
269 );
270 assert!(destination_dir.exists());
271 assert!(!destination_dir.join("a").exists());
272 }
273
274 #[test]
275 fn extract_zip_archive_rejects_suspicious_compression_ratio() {
276 let temp_dir = tempdir().expect("temp dir");
277 let destination_dir = temp_dir.path().join("dest");
278 let zip_path = temp_dir.path().join("archive.zip");
279
280 fs::create_dir_all(&destination_dir).expect("destination dir");
281 create_zip_archive(&zip_path, "payload.txt", b"compressible payload");
282
283 let error = extract_zip_archive_with_limits(
284 &zip_path,
285 &destination_dir,
286 ExtractionLimits {
287 max_total_size: 10 * 1024 * 1024 * 1024,
288 max_file_count: 100_000,
289 max_compression_ratio: 0,
290 max_path_depth: 255,
291 },
292 )
293 .expect_err("expected suspicious compression ratio rejection");
294
295 assert!(error.to_string().contains("suspicious compression ratio"));
296 assert!(!destination_dir.join("payload.txt").exists());
297 }
298
299 #[test]
300 fn extract_zip_archive_rejects_total_size_limit() {
301 let temp_dir = tempdir().expect("temp dir");
302 let destination_dir = temp_dir.path().join("dest");
303 let zip_path = temp_dir.path().join("archive.zip");
304
305 fs::create_dir_all(&destination_dir).expect("destination dir");
306 create_zip_archive(&zip_path, "payload.txt", b"abcd");
307
308 let error = extract_zip_archive_with_limits(
309 &zip_path,
310 &destination_dir,
311 ExtractionLimits {
312 max_total_size: 3,
313 max_file_count: 100_000,
314 max_compression_ratio: 100,
315 max_path_depth: 255,
316 },
317 )
318 .expect_err("expected quota rejection");
319
320 assert!(error.to_string().contains("quota exceeded"));
321 assert!(!destination_dir.join("payload.txt").exists());
322 }
323
324 #[test]
325 fn extract_zip_archive_rejects_file_count_limit() {
326 let temp_dir = tempdir().expect("temp dir");
327 let destination_dir = temp_dir.path().join("dest");
328 let zip_path = temp_dir.path().join("archive.zip");
329
330 fs::create_dir_all(&destination_dir).expect("destination dir");
331 create_archive_with_entries(&zip_path, &[("first.txt", b""), ("second.txt", b"")]);
332
333 let error = extract_zip_archive_with_limits(
334 &zip_path,
335 &destination_dir,
336 ExtractionLimits {
337 max_total_size: 10 * 1024 * 1024 * 1024,
338 max_file_count: 1,
339 max_compression_ratio: 100,
340 max_path_depth: 255,
341 },
342 )
343 .expect_err("expected file count rejection");
344
345 assert!(error.to_string().contains("entry count exceeded"));
346 assert!(!destination_dir.join("first.txt").exists());
347 }
348
349 #[test]
350 fn extract_zip_archive_rejects_path_depth_limit() {
351 let temp_dir = tempdir().expect("temp dir");
352 let destination_dir = temp_dir.path().join("dest");
353 let zip_path = temp_dir.path().join("archive.zip");
354
355 fs::create_dir_all(&destination_dir).expect("destination dir");
356 create_zip_archive(&zip_path, "a/b/c/file.txt", b"payload");
357
358 let error = extract_zip_archive_with_limits(
359 &zip_path,
360 &destination_dir,
361 ExtractionLimits {
362 max_total_size: 10 * 1024 * 1024 * 1024,
363 max_file_count: 100_000,
364 max_compression_ratio: 100,
365 max_path_depth: 2,
366 },
367 )
368 .expect_err("expected path depth rejection");
369
370 assert!(error.to_string().contains("too deep"));
371 assert!(!destination_dir.join("a").exists());
372 }
373}