Skip to main content

npm_utils/
extract.rs

1//! Archive extraction, hardened against hostile archives.
2//!
3//! Both [`tar_gz`] and [`zip()`] iterate an archive in memory and write selected entries beneath
4//! `dest`. `strip_prefix` (e.g. `Some("package/")` for npm tarballs) is removed from each entry
5//! path before [`Select`] is applied.
6//!
7//! Archive contents are untrusted input, so extraction is defended in layers:
8//!
9//! - **Entry-type allowlist** — only regular files and directories are written; symlinks,
10//!   hardlinks, device nodes, FIFOs and sockets are skipped, so an archive can't plant a link or
11//!   special file.
12//! - **Structural path check** ([`crate::path_safety::safe_join`]) — reject `..`, absolute,
13//!   root/drive, and backslash segments before touching the filesystem.
14//! - **Symlink-resolved containment** ([`crate::path_safety::contained_target`]) — each write's
15//!   parent is canonicalized and required to stay within the canonicalized `dest`, so even a
16//!   symlink already on disk (pre-existing, or from a destination shared across calls) can't
17//!   redirect a write outside it.
18//! - **Size cap** — entries are streamed (never buffered whole) and the total is bounded, so a
19//!   decompression bomb can't exhaust memory or disk.
20
21use flate2::read::GzDecoder;
22use std::fs::{create_dir_all, File};
23use std::io::{Cursor, Read, Write};
24use std::path::Path;
25use tar::Archive;
26
27use crate::path_safety::{contained_target, safe_join};
28
29/// Which archive entries to extract, and where each lands (relative to `dest`).
30pub enum Select<'a> {
31    /// Every file, keeping its (prefix-stripped) path. Directory entries create
32    /// directories; non-regular entries (symlinks, hardlinks, devices) are skipped.
33    All,
34    /// Only entries whose (prefix-stripped) path equals a listed source; written
35    /// to the paired destination.
36    Files(&'a [(&'a str, &'a str)]),
37    /// Each entry's (prefix-stripped) path is handed to the closure, which
38    /// returns the destination path or `None` to skip the entry.
39    Matching(&'a dyn Fn(&str) -> Option<String>),
40}
41
42impl Select<'_> {
43    /// Resolve an entry's (prefix-stripped) archive path to a destination
44    /// relative path, or `None` to skip it.
45    fn dest_for(&self, rel: &str) -> Option<String> {
46        match self {
47            Select::All => Some(rel.to_string()),
48            Select::Files(files) => files
49                .iter()
50                .find(|(src, _)| *src == rel)
51                .map(|(_, dst)| dst.to_string()),
52            Select::Matching(f) => f(rel),
53        }
54    }
55}
56
57/// Extract a gzipped tarball into `dest`. Returns the number of files written.
58pub fn tar_gz(
59    bytes: &[u8],
60    dest: &Path,
61    strip_prefix: Option<&str>,
62    select: Select<'_>,
63) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
64    let mut archive = Archive::new(GzDecoder::new(Cursor::new(bytes)));
65    let mut count = 0;
66    let mut total: u64 = 0;
67    let mut entries: u64 = 0;
68    // The real (symlink-resolved) absolute path every write must stay under.
69    create_dir_all(dest)?;
70    let root = dest.canonicalize()?;
71    for entry in archive.entries()? {
72        let mut entry = entry?;
73        entries += 1;
74        if entries > MAX_ENTRIES {
75            return Err(too_many_entries());
76        }
77        let entry_type = entry.header().entry_type();
78        let is_dir = entry_type.is_dir();
79        // Materialize only regular files and (for `Select::All`) directories. Symlinks,
80        // hardlinks, device nodes, FIFOs and sockets are skipped — an archive must not create a
81        // link or special file that could redirect a later write or otherwise surprise the caller.
82        if !is_dir && !entry_type.is_file() {
83            continue;
84        }
85        // Take the entry path as UTF-8 or reject it: a lossy conversion would map invalid bytes
86        // to U+FFFD, which could alias a different name in `Select::Files` matching.
87        let path_str = {
88            let entry_path = entry.path()?;
89            match entry_path.to_str() {
90                Some(s) => s.to_owned(),
91                None => return Err(non_utf8_entry(&entry_path)),
92            }
93        };
94        let rel = strip(&path_str, strip_prefix);
95        // Skip the archive root itself (`.` or empty after the prefix strip): an entry naming
96        // the destination directory must never replace it or be written over it.
97        if is_root_entry(rel) {
98            continue;
99        }
100        if is_dir {
101            if matches!(select, Select::All) {
102                // Create the directory through the same symlink-resolved containment guard the file
103                // writes use, so a pre-existing symlink can't redirect dir creation out of `dest`.
104                let target = contained_target(&root, &safe_join(dest, rel)?)?;
105                create_dir_all(target)?;
106            }
107            continue;
108        }
109        let Some(dest_rel) = select.dest_for(rel) else {
110            continue;
111        };
112        let out = safe_join(dest, &dest_rel)?;
113        let target = contained_target(&root, &out)?;
114        let mut file = File::create(&target)?;
115        total += copy_capped(&mut entry, &mut file, MAX_TOTAL_BYTES.saturating_sub(total))?;
116        count += 1;
117    }
118    Ok(count)
119}
120
121/// Extract a zip archive into `dest`. Returns the number of files written.
122pub fn zip(
123    bytes: &[u8],
124    dest: &Path,
125    strip_prefix: Option<&str>,
126    select: Select<'_>,
127) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
128    let mut archive = zip::ZipArchive::new(Cursor::new(bytes))?;
129    if archive.len() as u64 > MAX_ENTRIES {
130        return Err(too_many_entries());
131    }
132    let mut count = 0;
133    let mut total: u64 = 0;
134    // The real (symlink-resolved) absolute path every write must stay under.
135    create_dir_all(dest)?;
136    let root = dest.canonicalize()?;
137    for i in 0..archive.len() {
138        let mut file = archive.by_index(i)?;
139        if file.is_dir() || file.is_symlink() {
140            continue;
141        }
142        let name = match file.enclosed_name() {
143            Some(n) => match n.to_str() {
144                Some(s) => s.to_owned(),
145                None => return Err(non_utf8_entry(&n)),
146            },
147            None => return Err("unsafe zip entry name (escapes destination)".into()),
148        };
149        let rel = strip(&name, strip_prefix);
150        // Skip the archive root itself (`.`/empty), as in `tar_gz`.
151        if is_root_entry(rel) {
152            continue;
153        }
154        let Some(dest_rel) = select.dest_for(rel) else {
155            continue;
156        };
157        let out = safe_join(dest, &dest_rel)?;
158        let target = contained_target(&root, &out)?;
159        let mut writer = File::create(&target)?;
160        total += copy_capped(
161            &mut file,
162            &mut writer,
163            MAX_TOTAL_BYTES.saturating_sub(total),
164        )?;
165        count += 1;
166    }
167    Ok(count)
168}
169
170fn strip<'a>(path: &'a str, prefix: Option<&str>) -> &'a str {
171    match prefix {
172        Some(p) => path.strip_prefix(p).unwrap_or(path),
173        None => path,
174    }
175}
176
177/// Whether a (prefix-stripped) entry path refers to the destination root itself — `.` or the
178/// empty string. Such an entry names the package directory, so it is skipped: the root must
179/// never be written or linked over.
180fn is_root_entry(rel: &str) -> bool {
181    rel.is_empty() || rel == "."
182}
183
184/// Ceiling on the total bytes one archive may expand to on disk. A compressed archive can
185/// inflate enormously (a "decompression bomb"); without a cap a small download could exhaust
186/// memory or disk. Generous for real packages — even a large `node_modules` is a few hundred
187/// MB — while a bomb is orders of magnitude bigger.
188const MAX_TOTAL_BYTES: u64 = 4 * 1024 * 1024 * 1024; // 4 GiB
189
190/// Ceiling on the number of entries one archive may contain. Bounds inode-exhaustion archives
191/// (millions of tiny files or directories) that the byte cap alone wouldn't catch. Far above
192/// any real single package, which has at most a few thousand files.
193const MAX_ENTRIES: u64 = 200_000;
194
195fn too_many_entries() -> Box<dyn std::error::Error + Send + Sync> {
196    format!("archive has more than {MAX_ENTRIES} entries (possible archive bomb)").into()
197}
198
199/// Reject an archive entry whose path is not valid UTF-8, rather than lossily mangling it.
200fn non_utf8_entry(path: &Path) -> Box<dyn std::error::Error + Send + Sync> {
201    format!("archive entry path is not valid UTF-8: {path:?}").into()
202}
203
204/// Stream `reader` into `writer`, writing at most `budget` bytes and erroring if the source
205/// has more — i.e. if the archive's running total would exceed [`MAX_TOTAL_BYTES`]. Streaming
206/// (rather than buffering the whole entry) means a single huge entry can't OOM the process,
207/// and the budget bounds total disk use. Returns the number of bytes written.
208fn copy_capped<R: Read, W: Write>(
209    reader: &mut R,
210    writer: &mut W,
211    budget: u64,
212) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
213    // Read one byte past the budget, so an over-budget entry is detected rather than silently
214    // truncated to the limit.
215    let written = std::io::copy(&mut reader.take(budget.saturating_add(1)), writer)?;
216    if written > budget {
217        return Err(
218            "archive exceeds the extraction size limit (possible decompression bomb)".into(),
219        );
220    }
221    Ok(written)
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use flate2::write::GzEncoder;
228    use flate2::Compression;
229    use std::io::Cursor as IoCursor;
230    use tempfile::tempdir;
231
232    /// Build an in-memory `.tar.gz` from `(path, contents)` pairs.
233    fn make_tar_gz(entries: &[(&str, &[u8])]) -> Vec<u8> {
234        let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::fast()));
235        for (path, contents) in entries {
236            let mut header = tar::Header::new_gnu();
237            header.set_size(contents.len() as u64);
238            header.set_mode(0o644);
239            header.set_entry_type(tar::EntryType::Regular);
240            builder
241                .append_data(&mut header, *path, IoCursor::new(*contents))
242                .unwrap();
243        }
244        builder.finish().unwrap();
245        builder.into_inner().unwrap().finish().unwrap()
246    }
247
248    /// Build an in-memory `.tar.gz` carrying a single directory entry at `path`.
249    fn make_tar_gz_dir(path: &str) -> Vec<u8> {
250        let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::fast()));
251        let mut header = tar::Header::new_gnu();
252        header.set_size(0);
253        header.set_mode(0o755);
254        header.set_entry_type(tar::EntryType::Directory);
255        builder
256            .append_data(&mut header, path, IoCursor::new(&b""[..]))
257            .unwrap();
258        builder.finish().unwrap();
259        builder.into_inner().unwrap().finish().unwrap()
260    }
261
262    #[test]
263    #[cfg(unix)]
264    fn rejects_creating_a_dir_through_a_preexisting_symlink() {
265        use std::os::unix::fs::symlink;
266        // A pre-existing symlink dir inside `dest`; a directory entry would otherwise create a
267        // subdir through it. The containment guard must refuse, and nothing may land outside `dest`.
268        let tmp = tempdir().unwrap();
269        let dest = tmp.path().join("dest");
270        let outside = tmp.path().join("outside");
271        std::fs::create_dir_all(&dest).unwrap();
272        std::fs::create_dir_all(&outside).unwrap();
273        symlink(&outside, dest.join("link")).unwrap();
274
275        let tgz = make_tar_gz_dir("package/link/sub");
276        let result = tar_gz(&tgz, &dest, Some("package/"), Select::All);
277        assert!(
278            result.is_err(),
279            "must refuse to create a dir through a symlink"
280        );
281        assert!(
282            !outside.join("sub").exists(),
283            "nothing created outside dest"
284        );
285    }
286
287    #[test]
288    #[cfg(unix)]
289    fn rejects_non_utf8_entry_names() {
290        use std::os::unix::ffi::OsStrExt;
291        // An entry whose path is not valid UTF-8 is rejected, not lossily mangled to U+FFFD.
292        let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::fast()));
293        let mut header = tar::Header::new_gnu();
294        header.set_size(3);
295        header.set_mode(0o644);
296        header.set_entry_type(tar::EntryType::Regular);
297        let bad = std::ffi::OsStr::from_bytes(b"bad\xff.txt");
298        builder
299            .append_data(&mut header, bad, IoCursor::new(&b"abc"[..]))
300            .unwrap();
301        builder.finish().unwrap();
302        let tgz = builder.into_inner().unwrap().finish().unwrap();
303
304        let tmp = tempdir().unwrap();
305        assert!(
306            tar_gz(&tgz, tmp.path(), None, Select::All).is_err(),
307            "a non-UTF-8 entry path must be rejected"
308        );
309    }
310
311    #[test]
312    fn tar_gz_all_strips_prefix() {
313        let tgz = make_tar_gz(&[("package/index.js", b"a"), ("package/sub/util.js", b"b")]);
314        let tmp = tempdir().unwrap();
315        let n = tar_gz(&tgz, tmp.path(), Some("package/"), Select::All).unwrap();
316        assert_eq!(n, 2);
317        assert!(tmp.path().join("index.js").exists());
318        assert!(tmp.path().join("sub/util.js").exists());
319    }
320
321    #[test]
322    fn tar_gz_files_picks_named_entries() {
323        let tgz = make_tar_gz(&[
324            ("package/dist/sprite.svg", b"<svg/>"),
325            ("package/readme.md", b"x"),
326        ]);
327        let tmp = tempdir().unwrap();
328        let n = tar_gz(
329            &tgz,
330            tmp.path(),
331            Some("package/"),
332            Select::Files(&[("dist/sprite.svg", "icons/sprite.svg")]),
333        )
334        .unwrap();
335        assert_eq!(n, 1);
336        assert!(tmp.path().join("icons/sprite.svg").exists());
337        assert!(!tmp.path().join("readme.md").exists());
338    }
339
340    #[test]
341    fn tar_gz_matching_predicate_and_prefix() {
342        let tgz = make_tar_gz(&[
343            ("package/a.js", b"x"),
344            ("package/b.css", b"y"),
345            ("package/c.mjs", b"z"),
346        ]);
347        let tmp = tempdir().unwrap();
348        let keep_js = |rel: &str| -> Option<String> {
349            (rel.ends_with(".js") || rel.ends_with(".mjs")).then(|| format!("lit/{rel}"))
350        };
351        let n = tar_gz(
352            &tgz,
353            tmp.path(),
354            Some("package/"),
355            Select::Matching(&keep_js),
356        )
357        .unwrap();
358        assert_eq!(n, 2);
359        assert!(tmp.path().join("lit/a.js").exists());
360        assert!(tmp.path().join("lit/c.mjs").exists());
361        assert!(!tmp.path().join("lit/b.css").exists());
362    }
363
364    #[test]
365    fn tar_gz_errors_when_selection_escapes_dest() {
366        // Benign archive, but the selection maps an entry to a path that escapes
367        // `dest` — extraction must abort, not silently skip.
368        let tgz = make_tar_gz(&[("package/x.js", b"x")]);
369        let tmp = tempdir().unwrap();
370        let escape = |_rel: &str| -> Option<String> { Some("../escape.js".to_string()) };
371        let result = tar_gz(
372            &tgz,
373            tmp.path(),
374            Some("package/"),
375            Select::Matching(&escape),
376        );
377        assert!(result.is_err(), "extraction must error when a dest escapes");
378    }
379
380    #[test]
381    #[cfg(unix)]
382    fn rejects_writing_through_a_preexisting_symlink() {
383        use std::os::unix::fs::symlink;
384        // The footgun: a symlink already inside `dest` points outside it, and an archive
385        // writes a file *through* it. The canonicalized-containment guard must refuse, and
386        // nothing may land outside `dest`.
387        let tmp = tempdir().unwrap();
388        let dest = tmp.path().join("dest");
389        let outside = tmp.path().join("outside");
390        std::fs::create_dir_all(&dest).unwrap();
391        std::fs::create_dir_all(&outside).unwrap();
392        symlink(&outside, dest.join("evil")).unwrap();
393
394        let tgz = make_tar_gz(&[("package/evil/pwned", b"owned")]);
395        let result = tar_gz(&tgz, &dest, Some("package/"), Select::All);
396
397        assert!(
398            result.is_err(),
399            "must refuse to write through an escaping symlink"
400        );
401        assert!(
402            !outside.join("pwned").exists(),
403            "nothing may be written outside the extract dir"
404        );
405    }
406
407    #[test]
408    #[cfg(unix)]
409    fn rejects_writing_through_a_preexisting_leaf_symlink() {
410        use std::os::unix::fs::symlink;
411        // Like the test above, but the symlink is the *leaf* being written, not a parent dir.
412        // `File::create` would follow it; the containment guard must refuse and leave the
413        // pointed-at file untouched.
414        let tmp = tempdir().unwrap();
415        let dest = tmp.path().join("dest");
416        let outside = tmp.path().join("outside.txt");
417        std::fs::create_dir_all(&dest).unwrap();
418        std::fs::write(&outside, b"original").unwrap();
419        symlink(&outside, dest.join("evil")).unwrap();
420
421        let tgz = make_tar_gz(&[("package/evil", b"owned")]);
422        let result = tar_gz(&tgz, &dest, Some("package/"), Select::All);
423
424        assert!(
425            result.is_err(),
426            "must refuse to write through a leaf symlink"
427        );
428        assert_eq!(
429            std::fs::read(&outside).unwrap(),
430            b"original",
431            "the symlink's target must be untouched"
432        );
433    }
434
435    #[test]
436    fn odd_but_legal_entry_names_stay_contained() {
437        // Scary-looking but non-traversal entry names must land *under* `dest`, never escape:
438        // `...` and `~` are ordinary directory names, and `file://` is just part of a filename
439        // (we never interpret it as a URL).
440        let tmp = tempdir().unwrap();
441        let dest = tmp.path().join("dest");
442        let tgz = make_tar_gz(&[
443            (".../flag.txt", b"a"),
444            ("~/flag.txt", b"b"),
445            ("file:///tmp/flag.txt", b"c"),
446        ]);
447        let n = tar_gz(&tgz, &dest, None, Select::All).unwrap();
448        assert_eq!(n, 3);
449        assert!(dest.join("...").join("flag.txt").is_file());
450        assert!(dest.join("~").join("flag.txt").is_file());
451        // "file:///tmp/flag.txt" → a dir named "file:", then tmp/flag.txt — all under dest.
452        assert!(dest.join("file:").join("tmp").join("flag.txt").is_file());
453        // Crucially, nothing escaped to dest's parent (no `/tmp` write, no parent-dir write).
454        assert!(!tmp.path().join("flag.txt").exists());
455    }
456
457    /// A tarball carrying a symlink entry, a hardlink entry, and one regular file.
458    fn tar_with_links() -> Vec<u8> {
459        let mut b = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::fast()));
460        let mut reg = tar::Header::new_gnu();
461        reg.set_size(4);
462        reg.set_mode(0o644);
463        reg.set_entry_type(tar::EntryType::Regular);
464        b.append_data(&mut reg, "real.txt", IoCursor::new(&b"data"[..]))
465            .unwrap();
466
467        let mut sym = tar::Header::new_gnu();
468        sym.set_size(0);
469        sym.set_mode(0o777);
470        sym.set_entry_type(tar::EntryType::Symlink);
471        b.append_link(&mut sym, "evil-symlink", "real.txt").unwrap();
472
473        let mut hard = tar::Header::new_gnu();
474        hard.set_size(0);
475        hard.set_mode(0o644);
476        hard.set_entry_type(tar::EntryType::Link);
477        b.append_link(&mut hard, "evil-hardlink", "real.txt")
478            .unwrap();
479
480        b.finish().unwrap();
481        b.into_inner().unwrap().finish().unwrap()
482    }
483
484    #[test]
485    fn skips_symlink_and_hardlink_entries() {
486        // Only regular files and directories are materialized; link entries (which could
487        // redirect a later write or point outside the tree) are never created.
488        let tmp = tempdir().unwrap();
489        let dest = tmp.path().join("dest");
490        let n = tar_gz(&tar_with_links(), &dest, None, Select::All).unwrap();
491        assert_eq!(n, 1, "only the regular file is written");
492        assert!(dest.join("real.txt").is_file());
493        assert!(!dest.join("evil-symlink").exists());
494        assert!(!dest.join("evil-hardlink").exists());
495    }
496
497    #[test]
498    fn copy_capped_streams_within_budget_and_rejects_a_bomb() {
499        let src = vec![7u8; 1000];
500        // Within budget: the whole stream is copied.
501        let mut ok = Vec::new();
502        assert_eq!(
503            copy_capped(&mut src.as_slice(), &mut ok, 2000).unwrap(),
504            1000
505        );
506        assert_eq!(ok, src);
507        // Over budget (the decompression-bomb case): errors rather than truncating silently.
508        let mut overflow = Vec::new();
509        assert!(copy_capped(&mut src.as_slice(), &mut overflow, 100).is_err());
510    }
511
512    #[test]
513    fn is_root_entry_flags_dot_and_empty() {
514        // `.` and "" name the destination root itself and are skipped, so no entry — least of
515        // all a symlink — can replace or be written over the package directory.
516        assert!(is_root_entry("."));
517        assert!(is_root_entry(""));
518        assert!(!is_root_entry("index.js"));
519        assert!(!is_root_entry("./index.js"));
520        assert!(!is_root_entry("..."));
521    }
522
523    #[test]
524    fn refuses_to_write_at_the_destination_root() {
525        // A `.`/empty *entry* is skipped (is_root_entry); a selection mapping straight onto the
526        // root is caught by the containment check (the root's parent is above it). Either way the
527        // destination directory itself is never overwritten.
528        let tmp = tempdir().unwrap();
529        let dest = tmp.path().join("dest");
530        let tgz = make_tar_gz(&[("package/x.js", b"x")]);
531        let onto_root = |_rel: &str| -> Option<String> { Some(".".to_string()) };
532        let result = tar_gz(&tgz, &dest, Some("package/"), Select::Matching(&onto_root));
533        assert!(result.is_err(), "writing onto the root must be refused");
534        assert!(
535            dest.is_dir(),
536            "the destination root remains a real directory"
537        );
538    }
539}