Skip to main content

vivacity_core/
extract.rs

1//! Extraction of a zip dist into a directory, stripping the single root
2//! directory of GitHub/Packagist zipballs (ArchiveDownloader's rule: strip iff
3//! the archive has exactly one top-level entry and it is a directory;
4//! otherwise everything is extracted as is), and a DISTRUSTFUL extraction:
5//! - paths: `enclosed_name()` (rejects `..` and absolute paths);
6//! - symlinks (unix mode S_IFLNK): relative target only, and the lexically
7//!   resolved path must stay inside the package root;
8//! - executable bits preserved (binaries depend on them);
9//! - refusal of absurd decompressed sizes (crude zip bomb).
10
11use crate::error::{Error, Result};
12use std::io::Read;
13use std::path::{Component, Path, PathBuf};
14
15/// Maximum decompressed size of a dist (512 MB); beyond that, something is
16/// wrong (the largest real packages weigh a few tens of MB).
17const MAX_UNCOMPRESSED: u64 = 512 * 1024 * 1024;
18
19const S_IFMT: u32 = 0o170000;
20const S_IFLNK: u32 = 0o120000;
21
22/// Path of an entry, or an error if it is absolute or contains `..`:
23/// enclosed_name accepts an inner `..` (`r/../x` stays inside the root) that
24/// stripping the first component would turn into an escape, and no
25/// legitimate dist contains one.
26fn 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
39/// Number of leading components to strip from each entry: 1 if the archive
40/// has exactly one top-level entry and it is a directory
41/// (`ArchiveDownloader::install`, `$singleDirAtTopLevel`; a top-level
42/// `.DS_Store` is not counted, and then disappears along with the root
43/// directory), 0 otherwise, in which case everything is moved, `.DS_Store`
44/// included (`rename($temporaryDir, $path)` onto an empty target).
45fn 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
66/// Memoized `create_dir_all`: avoids one `create_dir_all` (hence one stat +
67/// N syscalls) per zip entry when hundreds of files share the same parent
68/// directory. Inserts the path and its newly created ancestors.
69fn 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; // ancestor already known: so is the rest
78        }
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            // Windows: Composer extracts with `unzip` or `7z` when one is on
128            // the PATH (`ZipDownloader::$unzipCommands`), which recreate the
129            // link when the process may create links (the GitHub runner);
130            // with PHP's ZipArchive the entry becomes an ordinary file whose
131            // content is the target. Same rule here: a link when such a tool
132            // is present and the link can be made, the file otherwise.
133            #[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
167/// A symlink target must be relative and stay lexically inside the extracted
168/// root (the classic attack: `link -> ../../../../etc/passwd`).
169fn 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; // depth of the link's directory
181    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        // Single directory without an explicit directory entry: strip.
259        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        // File at the root + directory: nothing is stripped, nothing is lost.
265        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        // Two top-level directories: nothing is stripped.
275        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        // A single file at the root: not a directory, no strip.
281        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        // Top-level .DS_Store: ignored for the count (the single directory is
287        // stripped, it disappears); without a single directory, extracted like
288        // the rest.
289        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            // A link when unzip/7z is on the PATH and links may be created
323            // (the GitHub runner), else — like PHP ZipArchive — an ordinary
324            // file containing the target.
325            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        // The writer sanitises names: we forge the `..` by byte-patching
352        // (same length), as a crafted archive would.
353        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        // The bzip2 backend is libbz2-rs-sys (workspace Cargo.toml) so an
378        // embedder's link stays free of BZ2_* symbols; no fixture carries a
379        // bzip2-compressed entry, so the decoder is exercised here.
380        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}