1use 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
12pub 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 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 let want = crate::cache::file_hash(package_lock)?;
34
35 super::run_install(dest, &want, |node_modules| {
36 for pkg in &installable {
37 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#[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 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; }
100 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 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 let link = bin_dir.join(bin_name);
117 let _ = std::fs::remove_file(&link); 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(()) }
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135 use tempfile::tempdir;
136
137 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 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 assert_eq!(
189 std::fs::read_link(nm.join(".bin/tsc")).unwrap(),
190 Path::new("../typescript/bin/tsc")
191 );
192 assert_eq!(
194 std::fs::read_link(nm.join(".bin/playwright")).unwrap(),
195 Path::new("../@playwright/test/cli.js")
196 );
197 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 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 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 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 let again = from_lockfile(&lock, tmp.path()).unwrap();
302 assert_eq!(again.len(), 1);
303 }
304}