Skip to main content

npm_utils/install/
mod.rs

1//! Install a dependency tree into a `node_modules/` directory — a pure-Rust "npm install"
2//! ([`node_modules`], from a `package.json`) and "npm ci" ([`from_lockfile`], from a
3//! `package-lock.json`). Each downloads, integrity-verifies, and extracts every package; the
4//! lockfile path also creates `node_modules/.bin/` shims. Both are skip-if-unchanged (a marker
5//! beside `node_modules/`) and concurrency-safe via a cross-process lock.
6//!
7//! The npm-format *parsing* lives in the [`crate::package_json`] module; this module is the *action* that
8//! orchestrates the primitives ([`crate::registry`], [`crate::download`], [`crate::integrity`],
9//! [`crate::extract`]) over those parsed structures — and owns the path-safety step that turns a
10//! package name or lockfile key into a contained install directory ([`crate::path_safety`]).
11
12use std::path::Path;
13
14use crate::{cache, download, extract, integrity};
15
16mod lockfile;
17mod node_modules;
18
19pub use lockfile::from_lockfile;
20pub use node_modules::node_modules;
21
22/// The shared skip-if-unchanged install dance: under a cross-process lock, short-circuit when
23/// `node_modules/` is populated and `marker_input` is unchanged; otherwise wipe it, run
24/// `populate` (which downloads/verifies/extracts into the given `node_modules` dir), and record
25/// the marker. The lock/marker live *beside* `node_modules/` (a refresh wipes the dir itself, so
26/// they can't live inside it).
27fn run_install(
28    dest: &Path,
29    marker_input: &str,
30    populate: impl FnOnce(&Path) -> Result<(), Box<dyn std::error::Error>>,
31) -> Result<(), Box<dyn std::error::Error>> {
32    let node_modules = dest.join("node_modules");
33    let lock = dest.join(".node_modules.lock");
34    let marker = dest.join(".node_modules.marker");
35    cache::with_lock(&lock)(|| -> Result<(), Box<dyn std::error::Error>> {
36        if cache::dir_has_content(&node_modules) && cache::marker_matches(&marker, marker_input) {
37            return Ok(()); // already up to date
38        }
39        cache::clear_directory(&node_modules)?;
40        populate(&node_modules)?;
41        cache::write_marker(&marker, marker_input)?;
42        Ok(())
43    })
44}
45
46/// Download one package tarball, verify its sha512 integrity, and extract it into `dir`. A
47/// package whose metadata carries no sha512 is refused, not installed unverified.
48fn fetch_verify_extract(
49    name: &str,
50    tarball_url: &str,
51    integrity_sri: Option<&str>,
52    dir: &Path,
53) -> Result<(), Box<dyn std::error::Error>> {
54    let bytes = download::fetch(tarball_url)?;
55    integrity::verify(name, &bytes, integrity_sri.unwrap_or(""))?;
56    // Strip the tarball's first path component whatever it's named: npm's own pack uses
57    // `package/`, but some published tarballs (e.g. `@types/react` → `react v18.3/`) don't, and
58    // npm strips the top dir by position, not by name.
59    extract::tar_gz(&bytes, dir, None, extract::Select::Matching(&strip_top_dir))?;
60    Ok(())
61}
62
63/// Drop a tarball entry's first path component (the package's top-level directory), whatever it
64/// is named. Entries with no directory component are skipped (`None`).
65fn strip_top_dir(rel: &str) -> Option<String> {
66    rel.split_once('/')
67        .map(|(_, rest)| rest.to_string())
68        .filter(|rest| !rest.is_empty())
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use crate::path_safety::safe_join;
75    use flate2::write::GzEncoder;
76    use flate2::Compression;
77    use std::io::Cursor;
78    use tempfile::tempdir;
79
80    fn tiny_tgz(files: &[(&str, &[u8])]) -> Vec<u8> {
81        let mut b = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::fast()));
82        for (path, contents) in files {
83            let mut h = tar::Header::new_gnu();
84            h.set_size(contents.len() as u64);
85            h.set_mode(0o644);
86            h.set_entry_type(tar::EntryType::Regular);
87            b.append_data(&mut h, *path, Cursor::new(*contents))
88                .unwrap();
89        }
90        b.finish().unwrap();
91        b.into_inner().unwrap().finish().unwrap()
92    }
93
94    #[test]
95    fn strip_top_dir_drops_first_component_regardless_of_name() {
96        assert_eq!(
97            strip_top_dir("package/index.js").as_deref(),
98            Some("index.js")
99        );
100        // @types/react ships under "react v18.3/", not "package/".
101        assert_eq!(
102            strip_top_dir("react v18.3/index.d.ts").as_deref(),
103            Some("index.d.ts")
104        );
105        assert_eq!(
106            strip_top_dir("root/sub/file.d.ts").as_deref(),
107            Some("sub/file.d.ts")
108        );
109        assert_eq!(strip_top_dir("toplevel"), None); // no directory component → skipped
110    }
111
112    #[test]
113    fn extracts_a_package_into_the_node_modules_layout() {
114        // The per-package extraction step (offline): a scoped package lands under
115        // node_modules/@scope/pkg/ with the npm `package/` prefix stripped.
116        let tmp = tempdir().unwrap();
117        let nm = tmp.path().join("node_modules");
118        let tgz = tiny_tgz(&[
119            (
120                "package/package.json",
121                br#"{"name":"@scope/pkg","version":"1.0.0"}"#,
122            ),
123            ("package/index.js", b"export default 1;"),
124        ]);
125        let dir = safe_join(&nm, "@scope/pkg").unwrap();
126        extract::tar_gz(&tgz, &dir, Some("package/"), extract::Select::All).unwrap();
127        assert!(nm.join("@scope/pkg/package.json").is_file());
128        assert!(nm.join("@scope/pkg/index.js").is_file());
129    }
130
131    #[test]
132    fn extracts_tarballs_whose_root_is_not_named_package() {
133        // Regression for the dogfood-found bug: a package whose tarball root is not `package/`
134        // (e.g. `@types/react`'s `react v18.3/`) must still extract into the package dir, not a
135        // stray subdir — npm strips the top dir by position, not by name.
136        let tmp = tempdir().unwrap();
137        let dir = tmp.path().join("@types/react");
138        let tgz = tiny_tgz(&[
139            ("react v18.3/index.d.ts", b"export {};"),
140            ("react v18.3/package.json", br#"{"name":"@types/react"}"#),
141        ]);
142        extract::tar_gz(&tgz, &dir, None, extract::Select::Matching(&strip_top_dir)).unwrap();
143        assert!(
144            dir.join("index.d.ts").is_file(),
145            "top dir stripped by position"
146        );
147        assert!(dir.join("package.json").is_file());
148        assert!(
149            !dir.join("react v18.3").exists(),
150            "no stray top-level dir remains"
151        );
152    }
153}