1use crate::error::{Error, Result};
12use std::io::Read;
13use std::path::{Component, Path, PathBuf};
14
15const MAX_UNCOMPRESSED: u64 = 512 * 1024 * 1024;
18
19const S_IFMT: u32 = 0o170000;
20const S_IFLNK: u32 = 0o120000;
21
22fn entry_path(entry: &zip::read::ZipFile<'_>, dest: &Path) -> Result<PathBuf> {
27 entry
28 .enclosed_name()
29 .filter(|p| {
30 p.components()
31 .all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
32 })
33 .ok_or_else(|| Error::HostileArchive {
34 dest: dest.to_path_buf(),
35 reason: format!("invalid entry path: {:?}", entry.name()),
36 })
37}
38
39fn root_strip(archive: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>, dest: &Path) -> Result<usize> {
46 let mut top: std::collections::BTreeMap<std::ffi::OsString, bool> =
47 std::collections::BTreeMap::new();
48 for i in 0..archive.len() {
49 let entry = archive.by_index(i).map_err(Error::zip(dest))?;
50 let raw = entry_path(&entry, dest)?;
51 let mut comps = raw
52 .components()
53 .filter(|c| matches!(c, Component::Normal(_)));
54 let Some(Component::Normal(first)) = comps.next() else {
55 continue;
56 };
57 let is_dir = entry.is_dir() || comps.next().is_some();
58 if !is_dir && first == ".DS_Store" {
59 continue;
60 }
61 *top.entry(first.to_owned()).or_insert(false) |= is_dir;
62 }
63 Ok(usize::from(top.len() == 1 && top.values().all(|d| *d)))
64}
65
66fn ensure_dir(p: &Path, made: &mut std::collections::HashSet<PathBuf>) -> Result<()> {
70 if made.contains(p) {
71 return Ok(());
72 }
73 std::fs::create_dir_all(p).map_err(Error::io(p))?;
74 let mut cur = Some(p);
75 while let Some(c) = cur {
76 if !made.insert(c.to_path_buf()) {
77 break; }
79 cur = c.parent();
80 }
81 Ok(())
82}
83
84pub fn extract_zip(zip_bytes: &[u8], dest: &Path) -> Result<()> {
85 let mut archive =
86 zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).map_err(Error::zip(dest))?;
87 let mut made: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
88 ensure_dir(dest, &mut made)?;
89 let strip = root_strip(&mut archive, dest)?;
90
91 let mut total: u64 = 0;
92 for i in 0..archive.len() {
93 let mut entry = archive.by_index(i).map_err(Error::zip(dest))?;
94 total = total.saturating_add(entry.size());
95 if total > MAX_UNCOMPRESSED {
96 return Err(Error::HostileArchive {
97 dest: dest.to_path_buf(),
98 reason: format!("uncompressed size > {MAX_UNCOMPRESSED} bytes"),
99 });
100 }
101 let raw = entry_path(&entry, dest)?;
102 let stripped: PathBuf = raw
103 .components()
104 .filter(|c| matches!(c, Component::Normal(_)))
105 .skip(strip)
106 .collect();
107 if stripped.as_os_str().is_empty() {
108 continue;
109 }
110 let out = dest.join(&stripped);
111
112 let mode = entry.unix_mode();
113 let is_symlink = mode.is_some_and(|m| m & S_IFMT == S_IFLNK);
114
115 if entry.is_dir() {
116 ensure_dir(&out, &mut made)?;
117 } else if is_symlink {
118 let mut target = String::new();
119 entry.read_to_string(&mut target).map_err(Error::io(&out))?;
120 check_symlink_target(&stripped, &target, dest)?;
121 if let Some(p) = out.parent() {
122 ensure_dir(p, &mut made)?;
123 }
124 let _ = std::fs::remove_file(&out);
125 #[cfg(unix)]
126 crate::clone::symlink_like_unzip(std::path::Path::new(&target), &out)?;
127 #[cfg(windows)]
134 {
135 let tool_present = std::env::var_os("PATH").is_some_and(|path| {
136 std::env::split_paths(&path)
137 .any(|dir| dir.join("unzip.exe").exists() || dir.join("7z.exe").exists())
138 });
139 let linked =
140 tool_present && std::os::windows::fs::symlink_file(&target, &out).is_ok();
141 if !linked {
142 std::fs::write(&out, target.as_bytes()).map_err(Error::io(&out))?;
143 }
144 }
145 #[cfg(not(any(unix, windows)))]
146 std::fs::write(&out, target.as_bytes()).map_err(Error::io(&out))?;
147 } else {
148 if let Some(p) = out.parent() {
149 ensure_dir(p, &mut made)?;
150 }
151 let mut buf = Vec::with_capacity(entry.size().min(MAX_UNCOMPRESSED) as usize);
152 entry.read_to_end(&mut buf).map_err(Error::io(&out))?;
153 std::fs::write(&out, &buf).map_err(Error::io(&out))?;
154 #[cfg(unix)]
155 if let Some(m) = mode {
156 if m & 0o111 != 0 {
157 use std::os::unix::fs::PermissionsExt as _;
158 std::fs::set_permissions(&out, std::fs::Permissions::from_mode(0o755))
159 .map_err(Error::io(&out))?;
160 }
161 }
162 }
163 }
164 Ok(())
165}
166
167fn check_symlink_target(link_rel: &Path, target: &str, dest: &Path) -> Result<()> {
170 let hostile = |reason: String| Error::HostileArchive {
171 dest: dest.to_path_buf(),
172 reason,
173 };
174 let target_path = Path::new(target);
175 if target_path.is_absolute() {
176 return Err(hostile(format!(
177 "absolute symlink: {link_rel:?} -> {target}"
178 )));
179 }
180 let mut depth: i64 = link_rel.components().count() as i64 - 1; for c in target_path.components() {
182 match c {
183 Component::ParentDir => {
184 depth -= 1;
185 if depth < 0 {
186 return Err(hostile(format!(
187 "symlink escaping the archive: {link_rel:?} -> {target}"
188 )));
189 }
190 }
191 Component::Normal(_) => depth += 1,
192 Component::CurDir => {}
193 _ => {
194 return Err(hostile(format!(
195 "invalid symlink: {link_rel:?} -> {target}"
196 )))
197 }
198 }
199 }
200 Ok(())
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206 use std::io::Write as _;
207 use zip::write::SimpleFileOptions;
208
209 fn build_zip(entries: &[(&str, &[u8], Option<u32>)]) -> Vec<u8> {
210 let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
211 for (name, content, mode) in entries {
212 let mut opts = SimpleFileOptions::default();
213 if let Some(m) = mode {
214 opts = opts.unix_permissions(*m);
215 }
216 if name.ends_with('/') {
217 w.add_directory(name.trim_end_matches('/'), opts)
218 .expect("dir");
219 } else {
220 w.start_file(*name, opts).expect("start");
221 w.write_all(content).expect("write");
222 }
223 }
224 w.finish().expect("finish").into_inner()
225 }
226
227 fn tmpdir() -> tempfile::TempDir {
228 tempfile::tempdir().expect("tmpdir")
229 }
230
231 #[test]
232 fn strips_root_and_preserves_exec_bit() {
233 let zip = build_zip(&[
234 ("root-abc/", b"", None),
235 ("root-abc/src/a.php", b"<?php", None),
236 (
237 "root-abc/bin/tool",
238 b"#!/usr/bin/env php\n<?php",
239 Some(0o100755),
240 ),
241 ]);
242 let d = tmpdir();
243 extract_zip(&zip, d.path()).expect("extract");
244 assert!(d.path().join("src/a.php").is_file());
245 #[cfg(unix)]
246 {
247 use std::os::unix::fs::PermissionsExt as _;
248 let mode = std::fs::metadata(d.path().join("bin/tool"))
249 .expect("meta")
250 .permissions()
251 .mode();
252 assert_eq!(mode & 0o111, 0o111, "executable bit lost");
253 }
254 }
255
256 #[test]
257 fn strips_only_a_single_top_level_directory() {
258 let single = build_zip(&[("pkg/a.txt", b"a", None), ("pkg/sub/b.txt", b"b", None)]);
260 let d = tmpdir();
261 extract_zip(&single, d.path()).expect("extract");
262 assert!(d.path().join("a.txt").is_file() && d.path().join("sub/b.txt").is_file());
263
264 let mixed = build_zip(&[("README", b"r", None), ("src/a.php", b"<?php", None)]);
266 let d = tmpdir();
267 extract_zip(&mixed, d.path()).expect("extract");
268 assert!(d.path().join("README").is_file(), "root file lost");
269 assert!(
270 d.path().join("src/a.php").is_file(),
271 "directory wrongly flattened"
272 );
273
274 let two = build_zip(&[("a/x", b"x", None), ("b/y", b"y", None)]);
276 let d = tmpdir();
277 extract_zip(&two, d.path()).expect("extract");
278 assert!(d.path().join("a/x").is_file() && d.path().join("b/y").is_file());
279
280 let file = build_zip(&[("only.txt", b"o", None)]);
282 let d = tmpdir();
283 extract_zip(&file, d.path()).expect("extract");
284 assert!(d.path().join("only.txt").is_file(), "single file lost");
285
286 let ds = build_zip(&[(".DS_Store", b"junk", None), ("pkg/a.txt", b"a", None)]);
290 let d = tmpdir();
291 extract_zip(&ds, d.path()).expect("extract");
292 assert!(d.path().join("a.txt").is_file());
293 assert!(!d.path().join(".DS_Store").exists());
294 let ds2 = build_zip(&[(".DS_Store", b"junk", None), ("a.txt", b"a", None)]);
295 let d = tmpdir();
296 extract_zip(&ds2, d.path()).expect("extract");
297 assert!(d.path().join("a.txt").is_file() && d.path().join(".DS_Store").is_file());
298 }
299
300 fn build_zip_with_symlink(link_name: &str, target: &str) -> Vec<u8> {
301 let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
302 w.add_directory("r", SimpleFileOptions::default())
303 .expect("dir");
304 w.start_file("r/real.txt", SimpleFileOptions::default())
305 .expect("start");
306 w.write_all(b"x").expect("write");
307 w.add_symlink(link_name, target, SimpleFileOptions::default())
308 .expect("symlink");
309 w.finish().expect("finish").into_inner()
310 }
311
312 #[test]
313 fn valid_symlink_is_recreated_hostile_ones_rejected() {
314 let ok = build_zip_with_symlink("r/sub/link", "../real.txt");
315 let d = tmpdir();
316 extract_zip(&ok, d.path()).expect("extract");
317 let meta = d.path().join("sub/link").symlink_metadata().expect("meta");
318 #[cfg(unix)]
319 assert!(meta.file_type().is_symlink());
320 #[cfg(not(unix))]
321 {
322 if meta.file_type().is_symlink() {
326 assert_eq!(
327 std::fs::read_link(d.path().join("sub/link")).expect("target"),
328 std::path::PathBuf::from("../real.txt")
329 );
330 } else {
331 assert!(meta.file_type().is_file());
332 assert_eq!(
333 std::fs::read(d.path().join("sub/link")).expect("read"),
334 b"../real.txt"
335 );
336 }
337 }
338
339 for target in ["../../etc/passwd", "/etc/passwd", "../../../x"] {
340 let bad = build_zip_with_symlink("r/link", target);
341 let d = tmpdir();
342 assert!(
343 extract_zip(&bad, d.path()).is_err(),
344 "hostile symlink accepted: {target}"
345 );
346 }
347 }
348
349 #[test]
350 fn zip_slip_paths_are_rejected() {
351 let benign = build_zip(&[("r/", b"", None), ("r/AA/evil.txt", b"x", None)]);
354 let patched: Vec<u8> = {
355 let needle = b"r/AA/evil.txt";
356 let replacement = b"r/../evil.txt";
357 let mut bytes = benign.clone();
358 let mut i = 0;
359 while i + needle.len() <= bytes.len() {
360 if &bytes[i..i + needle.len()] == needle {
361 bytes[i..i + needle.len()].copy_from_slice(replacement);
362 }
363 i += 1;
364 }
365 bytes
366 };
367 assert_ne!(benign, patched, "the patch replaced nothing");
368 let d = tmpdir();
369 assert!(
370 extract_zip(&patched, d.path()).is_err(),
371 "zip-slip accepted"
372 );
373 }
374
375 #[test]
376 fn bzip2_entry_decompresses_through_the_pure_rust_backend() {
377 let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
381 let opts = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Bzip2);
382 w.start_file("pkg/src/a.php", opts).expect("start");
383 let body = "<?php // enough bytes for a real bzip2 block\n".repeat(200);
384 w.write_all(body.as_bytes()).expect("write");
385 let bytes = w.finish().expect("finish").into_inner();
386
387 let d = tmpdir();
388 extract_zip(&bytes, d.path()).expect("extract");
389 let out = std::fs::read_to_string(d.path().join("src/a.php")).expect("read");
390 assert_eq!(out, body);
391 }
392}