Skip to main content

npm_utils/install/
lockfile.rs

1//! `from_lockfile()` — install the exact tree pinned by a `package-lock.json` (pure-Rust
2//! `npm ci`), plus `node_modules/.bin/` shims.
3
4use std::path::Path;
5
6use crate::package_json::lock::{LockedPackage, Lockfile};
7use semver::Version;
8
9use crate::path_safety::safe_join;
10use crate::registry::Resolved;
11
12/// Install the exact dependency tree pinned by a `package-lock.json` into `<dest>/node_modules/`
13/// — a pure-Rust, `npm ci`-faithful install.
14///
15/// The lockfile (v2/v3) is parsed by [`crate::package_json::lock`]; this installs every registry-tarball
16/// entry whose `os`/`cpu` match the host (skipping links and off-platform optional deps like
17/// darwin-only `fsevents` on Linux), verifies each `sha512` integrity, extracts it to the path the
18/// lockfile names, and creates `node_modules/.bin/` symlinks — so installed CLIs (`tsc`,
19/// `playwright`, …) run as under npm, with only the Node runtime, no `npm`. Skip-if-unchanged on
20/// the lockfile's content hash. Returns the installed set, sorted by install path.
21pub fn from_lockfile(
22    package_lock: &Path,
23    dest: &Path,
24) -> Result<Vec<Resolved>, Box<dyn std::error::Error>> {
25    let lockfile = Lockfile::parse(&std::fs::read_to_string(package_lock)?)?;
26    // What this host installs: platform-matching, non-link entries that are registry tarballs.
27    let installable: Vec<&LockedPackage> = lockfile
28        .installable(std::env::consts::OS, std::env::consts::ARCH)
29        .into_iter()
30        .filter(|p| p.is_registry_tarball())
31        .collect();
32    // The lockfile fully determines the tree, so its content hash is the cache key.
33    let want = crate::cache::file_hash(package_lock)?;
34
35    super::run_install(dest, &want, |node_modules| {
36        for pkg in &installable {
37            // The key (`node_modules/…`) is validated into a contained path under `dest`.
38            let dir = safe_join(dest, &pkg.key)?;
39            let url = pkg.resolved.as_deref().unwrap_or_default();
40            super::fetch_verify_extract(&pkg.name, url, pkg.integrity.as_deref(), &dir)?;
41        }
42        link_bins(node_modules, &installable)?;
43        Ok(())
44    })?;
45
46    installable
47        .iter()
48        .map(|pkg| {
49            let version = Version::parse(&pkg.version).map_err(|e| {
50                format!(
51                    "package `{}`: invalid version {:?}: {e}",
52                    pkg.name, pkg.version
53                )
54            })?;
55            Ok(Resolved {
56                name: pkg.name.clone(),
57                version,
58                tarball_url: pkg.resolved.clone().unwrap_or_default(),
59                integrity: pkg.integrity.clone(),
60            })
61        })
62        .collect()
63}
64
65/// Create `node_modules/.bin/<name>` symlinks for every package `bin`, so the installed CLIs run
66/// as under npm. The shims are *relative* (the tree stays relocatable) and their targets are made
67/// executable. On a name collision the first package (by sorted install path) wins. Unix only —
68/// `.bin` shims elsewhere are out of scope.
69///
70/// Path-traversal-safe against a crafted lockfile: the link *name* must be a single filename (no
71/// separator, `.` or `..`), and the link *target* is gated through [`safe_join`] — the same
72/// validated relative path feeds both the chmod and the symlink, so neither can escape
73/// `node_modules/`.
74#[cfg(unix)]
75fn link_bins(
76    node_modules: &Path,
77    plan: &[&LockedPackage],
78) -> Result<(), Box<dyn std::error::Error>> {
79    use std::collections::BTreeSet;
80    use std::os::unix::fs::{symlink, PermissionsExt};
81
82    let bin_dir = node_modules.join(".bin");
83    let mut linked: BTreeSet<String> = BTreeSet::new();
84    for pkg in plan {
85        let Some(install_rel) = pkg.key.strip_prefix("node_modules/") else {
86            continue;
87        };
88        for (bin_name, bin_path) in &pkg.bin {
89            // The link itself is a single filename directly under .bin/ — never a path, so it
90            // can't escape .bin/. Reject '/', '.'/'..' and empty (on Unix '/' is the only
91            // separator). NB: `safe_join` is wrong here — it permits a bare `.`, which would
92            // resolve the link to `.bin` itself.
93            if bin_name.is_empty() || bin_name.contains('/') || bin_name == "." || bin_name == ".."
94            {
95                continue;
96            }
97            if !linked.insert(bin_name.clone()) {
98                continue; // collision: the first (sorted) package keeps the name
99            }
100            // The target relative to node_modules. `safe_join` is the traversal gate: it rejects
101            // any `..`/absolute component in the (attacker-controlled) key or bin path, erroring
102            // before any symlink is written. The *same* validated `rel` feeds both the chmod and
103            // the symlink, so the two can never diverge.
104            let rel = format!("{}/{}", install_rel, bin_path.trim_start_matches("./"));
105            let target = safe_join(node_modules, &rel)?;
106            std::fs::create_dir_all(&bin_dir)?;
107            // chmod +x the real entry (npm does this on extract). metadata/set_permissions follow
108            // symlinks, but extraction never creates symlinks inside node_modules, so `target` is
109            // a regular file (or absent) — not an attacker-planted link out of the tree.
110            if let Ok(meta) = std::fs::metadata(&target) {
111                let mut perm = meta.permissions();
112                perm.set_mode(perm.mode() | 0o111);
113                let _ = std::fs::set_permissions(&target, perm);
114            }
115            // `../rel` from .bin/ resolves to node_modules/rel === the validated `target`.
116            let link = bin_dir.join(bin_name);
117            let _ = std::fs::remove_file(&link); // idempotent
118            symlink(format!("../{rel}"), &link)?;
119        }
120    }
121    Ok(())
122}
123
124#[cfg(not(unix))]
125fn link_bins(
126    _node_modules: &Path,
127    _plan: &[&LockedPackage],
128) -> Result<(), Box<dyn std::error::Error>> {
129    Ok(()) // `.bin` shims are Unix symlinks; skipped on other platforms
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use tempfile::tempdir;
136
137    /// Build a `LockedPackage` for the `.bin` test — only `key`, `name`, and `bin` matter here.
138    fn locked(key: &str, bin: &[(&str, &str)]) -> LockedPackage {
139        LockedPackage {
140            name: key
141                .rsplit("node_modules/")
142                .next()
143                .unwrap_or(key)
144                .to_string(),
145            key: key.to_string(),
146            version: "1.0.0".into(),
147            resolved: None,
148            integrity: None,
149            dev: false,
150            optional: false,
151            dev_optional: false,
152            link: false,
153            os: Vec::new(),
154            cpu: Vec::new(),
155            bin: bin
156                .iter()
157                .map(|(n, p)| (n.to_string(), p.to_string()))
158                .collect(),
159        }
160    }
161
162    #[test]
163    #[cfg(unix)]
164    fn link_bins_creates_relative_exec_symlinks_first_wins() {
165        use std::os::unix::fs::PermissionsExt;
166
167        let tmp = tempdir().unwrap();
168        let nm = tmp.path().join("node_modules");
169        for rel in [
170            "@playwright/test/cli.js",
171            "playwright/cli.js",
172            "typescript/bin/tsc",
173        ] {
174            let p = nm.join(rel);
175            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
176            std::fs::write(&p, b"#!/usr/bin/env node\n").unwrap();
177        }
178        // Sorted by install path (as Lockfile::installable returns): @playwright/test < playwright.
179        let pkgs = [
180            locked("node_modules/@playwright/test", &[("playwright", "cli.js")]),
181            locked("node_modules/playwright", &[("playwright", "cli.js")]),
182            locked("node_modules/typescript", &[("tsc", "bin/tsc")]),
183        ];
184        let plan: Vec<&LockedPackage> = pkgs.iter().collect();
185        link_bins(&nm, &plan).unwrap();
186
187        // Relative, relocatable shims.
188        assert_eq!(
189            std::fs::read_link(nm.join(".bin/tsc")).unwrap(),
190            Path::new("../typescript/bin/tsc")
191        );
192        // On the `playwright` collision the first (sorted) package keeps the name.
193        assert_eq!(
194            std::fs::read_link(nm.join(".bin/playwright")).unwrap(),
195            Path::new("../@playwright/test/cli.js")
196        );
197        // The real entry file was made executable.
198        let mode = std::fs::metadata(nm.join("typescript/bin/tsc"))
199            .unwrap()
200            .permissions()
201            .mode();
202        assert!(mode & 0o111 != 0, "bin target should be executable");
203    }
204
205    #[test]
206    #[cfg(unix)]
207    fn link_bins_rejects_a_traversing_bin_target() {
208        // A crafted lockfile bin path that climbs out of node_modules must never become a symlink
209        // pointing outside the tree: safe_join is the gate, so the install errors instead.
210        let tmp = tempdir().unwrap();
211        let nm = tmp.path().join("node_modules");
212        let pkgs = [locked(
213            "node_modules/evil",
214            &[("evil", "../../../../../../tmp/pwned")],
215        )];
216        let plan: Vec<&LockedPackage> = pkgs.iter().collect();
217        assert!(
218            link_bins(&nm, &plan).is_err(),
219            "a traversing bin target is rejected"
220        );
221        assert!(
222            !nm.join(".bin/evil").exists(),
223            "no symlink is created for a traversing target"
224        );
225    }
226
227    #[test]
228    #[cfg(unix)]
229    fn link_bins_skips_bin_names_that_are_paths() {
230        // A bin *name* is a single filename under .bin/; a name carrying a separator or `..` is
231        // skipped (never a traversing link), while a valid sibling bin still links.
232        let tmp = tempdir().unwrap();
233        let nm = tmp.path().join("node_modules");
234        std::fs::create_dir_all(nm.join("p")).unwrap();
235        std::fs::write(nm.join("p/cli.js"), b"#!/usr/bin/env node\n").unwrap();
236        let pkgs = [locked(
237            "node_modules/p",
238            &[("../escape", "cli.js"), ("ok", "cli.js")],
239        )];
240        let plan: Vec<&LockedPackage> = pkgs.iter().collect();
241        link_bins(&nm, &plan).unwrap();
242        assert!(nm.join(".bin/ok").exists(), "the valid bin is linked");
243        assert!(
244            !tmp.path().join("escape").exists() && !nm.join("escape").exists(),
245            "a path-like bin name creates nothing outside .bin/"
246        );
247    }
248
249    #[test]
250    #[ignore = "network: hits the npm registry"]
251    #[cfg(not(target_os = "macos"))]
252    fn installs_a_locked_tree_and_skips_offplatform_optional() {
253        // `ms@2.1.3` is a frozen package with a known sha512 (so integrity is really checked).
254        // `darwin-only` carries a bogus URL that MUST NOT be fetched on a non-darwin host —
255        // proving the platform skip end to end (a fetch would error on the invalid URL).
256        let tmp = tempdir().unwrap();
257        let lock = tmp.path().join("package-lock.json");
258        std::fs::write(
259            &lock,
260            r#"{
261              "name": "fixture",
262              "lockfileVersion": 3,
263              "packages": {
264                "": { "name": "fixture", "dependencies": { "ms": "2.1.3" } },
265                "node_modules/ms": {
266                  "version": "2.1.3",
267                  "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
268                  "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
269                },
270                "node_modules/darwin-only": {
271                  "version": "1.0.0",
272                  "resolved": "https://example.invalid/never-fetched.tgz",
273                  "integrity": "sha512-AAAA",
274                  "optional": true,
275                  "os": ["darwin"]
276                }
277              }
278            }"#,
279        )
280        .unwrap();
281
282        let installed = from_lockfile(&lock, tmp.path()).unwrap();
283        let names: Vec<&str> = installed.iter().map(|r| r.name.as_str()).collect();
284        assert_eq!(
285            names,
286            ["ms"],
287            "the darwin-only optional dep is skipped on this host"
288        );
289
290        let nm = tmp.path().join("node_modules");
291        assert!(
292            nm.join("ms/package.json").is_file(),
293            "ms downloaded, integrity-verified and extracted"
294        );
295        assert!(
296            !nm.join("darwin-only").exists(),
297            "off-platform dep not installed"
298        );
299
300        // Idempotent: the lockfile-hash marker short-circuits the second call.
301        let again = from_lockfile(&lock, tmp.path()).unwrap();
302        assert_eq!(again.len(), 1);
303    }
304}