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(crate) use lockfile::from_lockfile_observed;
21pub use node_modules::node_modules;
22// Only the cli-gated `install` verb drives this seam directly (`from_lockfile_observed` is also
23// consumed by `project`, so it stays unconditional).
24#[cfg(feature = "cli")]
25pub(crate) use node_modules::node_modules_observed;
26
27/// One unit of install work, emitted immediately *before* a package's download/verify/extract —
28/// a renderer shows the package in flight. Installs run sequentially on the calling thread, so
29/// `FnMut` observers suffice.
30pub(crate) enum InstallEvent<'a> {
31    Fetch {
32        /// 1-based position — the walk's own numbering, pinned by the seam tests; renderers
33        /// count for themselves (events are ordered).
34        #[allow(dead_code)]
35        index: usize,
36        #[cfg_attr(not(feature = "cli"), allow(dead_code))]
37        total: usize,
38        #[cfg_attr(not(feature = "cli"), allow(dead_code))]
39        name: &'a str,
40        #[cfg_attr(not(feature = "cli"), allow(dead_code))]
41        version: &'a str,
42    },
43}
44
45/// The shared skip-if-unchanged install dance: under a cross-process lock, short-circuit when
46/// `node_modules/` is populated and `marker_input` is unchanged; otherwise wipe it, run
47/// `populate` (which downloads/verifies/extracts into the given `node_modules` dir), and record
48/// the marker. The lock/marker live *beside* `node_modules/` (a refresh wipes the dir itself, so
49/// they can't live inside it).
50fn run_install(
51    dest: &Path,
52    marker_input: &str,
53    populate: impl FnOnce(&Path) -> Result<(), Box<dyn std::error::Error + Send + Sync>>,
54) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
55    let node_modules = dest.join("node_modules");
56    let lock = dest.join(".node_modules.lock");
57    let marker = dest.join(".node_modules.marker");
58    cache::with_lock(&lock)(|| -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
59        if cache::dir_has_content(&node_modules) && cache::marker_matches(&marker, marker_input) {
60            return Ok(()); // already up to date
61        }
62        cache::clear_directory(&node_modules)?;
63        populate(&node_modules)?;
64        cache::write_marker(&marker, marker_input)?;
65        Ok(())
66    })
67}
68
69/// Download one package tarball, verify its sha512 integrity, and extract it into `dir`. A
70/// package whose metadata carries no sha512 is refused, not installed unverified.
71fn fetch_verify_extract(
72    name: &str,
73    tarball_url: &str,
74    integrity_sri: Option<&str>,
75    dir: &Path,
76) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
77    let bytes = download::fetch(tarball_url)?;
78    integrity::verify(name, &bytes, integrity_sri.unwrap_or(""))?;
79    // Strip the tarball's first path component whatever it's named: npm's own pack uses
80    // `package/`, but some published tarballs (e.g. `@types/react` → `react v18.3/`) don't, and
81    // npm strips the top dir by position, not by name.
82    extract::tar_gz(&bytes, dir, None, extract::Select::Matching(&strip_top_dir))?;
83    Ok(())
84}
85
86/// Drop a tarball entry's first path component (the package's top-level directory), whatever it
87/// is named. Entries with no directory component are skipped (`None`).
88fn strip_top_dir(rel: &str) -> Option<String> {
89    rel.split_once('/')
90        .map(|(_, rest)| rest.to_string())
91        .filter(|rest| !rest.is_empty())
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::path_safety::safe_join;
98    use flate2::write::GzEncoder;
99    use flate2::Compression;
100    use std::io::Cursor;
101    use tempfile::tempdir;
102
103    fn tiny_tgz(files: &[(&str, &[u8])]) -> Vec<u8> {
104        let mut b = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::fast()));
105        for (path, contents) in files {
106            let mut h = tar::Header::new_gnu();
107            h.set_size(contents.len() as u64);
108            h.set_mode(0o644);
109            h.set_entry_type(tar::EntryType::Regular);
110            b.append_data(&mut h, *path, Cursor::new(*contents))
111                .unwrap();
112        }
113        b.finish().unwrap();
114        b.into_inner().unwrap().finish().unwrap()
115    }
116
117    #[test]
118    fn strip_top_dir_drops_first_component_regardless_of_name() {
119        assert_eq!(
120            strip_top_dir("package/index.js").as_deref(),
121            Some("index.js")
122        );
123        // @types/react ships under "react v18.3/", not "package/".
124        assert_eq!(
125            strip_top_dir("react v18.3/index.d.ts").as_deref(),
126            Some("index.d.ts")
127        );
128        assert_eq!(
129            strip_top_dir("root/sub/file.d.ts").as_deref(),
130            Some("sub/file.d.ts")
131        );
132        assert_eq!(strip_top_dir("toplevel"), None); // no directory component → skipped
133    }
134
135    #[test]
136    fn extracts_a_package_into_the_node_modules_layout() {
137        // The per-package extraction step (offline): a scoped package lands under
138        // node_modules/@scope/pkg/ with the npm `package/` prefix stripped.
139        let tmp = tempdir().unwrap();
140        let nm = tmp.path().join("node_modules");
141        let tgz = tiny_tgz(&[
142            (
143                "package/package.json",
144                br#"{"name":"@scope/pkg","version":"1.0.0"}"#,
145            ),
146            ("package/index.js", b"export default 1;"),
147        ]);
148        let dir = safe_join(&nm, "@scope/pkg").unwrap();
149        extract::tar_gz(&tgz, &dir, Some("package/"), extract::Select::All).unwrap();
150        assert!(nm.join("@scope/pkg/package.json").is_file());
151        assert!(nm.join("@scope/pkg/index.js").is_file());
152    }
153
154    #[test]
155    fn extracts_tarballs_whose_root_is_not_named_package() {
156        // Regression for the dogfood-found bug: a package whose tarball root is not `package/`
157        // (e.g. `@types/react`'s `react v18.3/`) must still extract into the package dir, not a
158        // stray subdir — npm strips the top dir by position, not by name.
159        let tmp = tempdir().unwrap();
160        let dir = tmp.path().join("@types/react");
161        let tgz = tiny_tgz(&[
162            ("react v18.3/index.d.ts", b"export {};"),
163            ("react v18.3/package.json", br#"{"name":"@types/react"}"#),
164        ]);
165        extract::tar_gz(&tgz, &dir, None, extract::Select::Matching(&strip_top_dir)).unwrap();
166        assert!(
167            dir.join("index.d.ts").is_file(),
168            "top dir stripped by position"
169        );
170        assert!(dir.join("package.json").is_file());
171        assert!(
172            !dir.join("react v18.3").exists(),
173            "no stray top-level dir remains"
174        );
175    }
176}