use std::path::Path;
use crate::package_json::lock::{LockedPackage, Lockfile};
use semver::Version;
use crate::path_safety::safe_join;
use crate::registry::Resolved;
pub fn from_lockfile(
package_lock: &Path,
dest: &Path,
) -> Result<Vec<Resolved>, Box<dyn std::error::Error>> {
let lockfile = Lockfile::parse(&std::fs::read_to_string(package_lock)?)?;
let installable: Vec<&LockedPackage> = lockfile
.installable(std::env::consts::OS, std::env::consts::ARCH)
.into_iter()
.filter(|p| p.is_registry_tarball())
.collect();
let want = crate::cache::file_hash(package_lock)?;
super::run_install(dest, &want, |node_modules| {
for pkg in &installable {
let dir = safe_join(dest, &pkg.key)?;
let url = pkg.resolved.as_deref().unwrap_or_default();
super::fetch_verify_extract(&pkg.name, url, pkg.integrity.as_deref(), &dir)?;
}
link_bins(node_modules, &installable)?;
Ok(())
})?;
installable
.iter()
.map(|pkg| {
let version = Version::parse(&pkg.version).map_err(|e| {
format!(
"package `{}`: invalid version {:?}: {e}",
pkg.name, pkg.version
)
})?;
Ok(Resolved {
name: pkg.name.clone(),
version,
tarball_url: pkg.resolved.clone().unwrap_or_default(),
integrity: pkg.integrity.clone(),
})
})
.collect()
}
#[cfg(unix)]
fn link_bins(
node_modules: &Path,
plan: &[&LockedPackage],
) -> Result<(), Box<dyn std::error::Error>> {
use std::collections::BTreeSet;
use std::os::unix::fs::{symlink, PermissionsExt};
let bin_dir = node_modules.join(".bin");
let mut linked: BTreeSet<String> = BTreeSet::new();
for pkg in plan {
let Some(install_rel) = pkg.key.strip_prefix("node_modules/") else {
continue;
};
for (bin_name, bin_path) in &pkg.bin {
if bin_name.is_empty() || bin_name.contains('/') || bin_name == "." || bin_name == ".."
{
continue;
}
if !linked.insert(bin_name.clone()) {
continue; }
let rel = format!("{}/{}", install_rel, bin_path.trim_start_matches("./"));
let target = safe_join(node_modules, &rel)?;
std::fs::create_dir_all(&bin_dir)?;
if let Ok(meta) = std::fs::metadata(&target) {
let mut perm = meta.permissions();
perm.set_mode(perm.mode() | 0o111);
let _ = std::fs::set_permissions(&target, perm);
}
let link = bin_dir.join(bin_name);
let _ = std::fs::remove_file(&link); symlink(format!("../{rel}"), &link)?;
}
}
Ok(())
}
#[cfg(not(unix))]
fn link_bins(
_node_modules: &Path,
_plan: &[&LockedPackage],
) -> Result<(), Box<dyn std::error::Error>> {
Ok(()) }
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn locked(key: &str, bin: &[(&str, &str)]) -> LockedPackage {
LockedPackage {
name: key
.rsplit("node_modules/")
.next()
.unwrap_or(key)
.to_string(),
key: key.to_string(),
version: "1.0.0".into(),
resolved: None,
integrity: None,
dev: false,
optional: false,
dev_optional: false,
link: false,
os: Vec::new(),
cpu: Vec::new(),
bin: bin
.iter()
.map(|(n, p)| (n.to_string(), p.to_string()))
.collect(),
}
}
#[test]
#[cfg(unix)]
fn link_bins_creates_relative_exec_symlinks_first_wins() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempdir().unwrap();
let nm = tmp.path().join("node_modules");
for rel in [
"@playwright/test/cli.js",
"playwright/cli.js",
"typescript/bin/tsc",
] {
let p = nm.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(&p, b"#!/usr/bin/env node\n").unwrap();
}
let pkgs = [
locked("node_modules/@playwright/test", &[("playwright", "cli.js")]),
locked("node_modules/playwright", &[("playwright", "cli.js")]),
locked("node_modules/typescript", &[("tsc", "bin/tsc")]),
];
let plan: Vec<&LockedPackage> = pkgs.iter().collect();
link_bins(&nm, &plan).unwrap();
assert_eq!(
std::fs::read_link(nm.join(".bin/tsc")).unwrap(),
Path::new("../typescript/bin/tsc")
);
assert_eq!(
std::fs::read_link(nm.join(".bin/playwright")).unwrap(),
Path::new("../@playwright/test/cli.js")
);
let mode = std::fs::metadata(nm.join("typescript/bin/tsc"))
.unwrap()
.permissions()
.mode();
assert!(mode & 0o111 != 0, "bin target should be executable");
}
#[test]
#[cfg(unix)]
fn link_bins_rejects_a_traversing_bin_target() {
let tmp = tempdir().unwrap();
let nm = tmp.path().join("node_modules");
let pkgs = [locked(
"node_modules/evil",
&[("evil", "../../../../../../tmp/pwned")],
)];
let plan: Vec<&LockedPackage> = pkgs.iter().collect();
assert!(
link_bins(&nm, &plan).is_err(),
"a traversing bin target is rejected"
);
assert!(
!nm.join(".bin/evil").exists(),
"no symlink is created for a traversing target"
);
}
#[test]
#[cfg(unix)]
fn link_bins_skips_bin_names_that_are_paths() {
let tmp = tempdir().unwrap();
let nm = tmp.path().join("node_modules");
std::fs::create_dir_all(nm.join("p")).unwrap();
std::fs::write(nm.join("p/cli.js"), b"#!/usr/bin/env node\n").unwrap();
let pkgs = [locked(
"node_modules/p",
&[("../escape", "cli.js"), ("ok", "cli.js")],
)];
let plan: Vec<&LockedPackage> = pkgs.iter().collect();
link_bins(&nm, &plan).unwrap();
assert!(nm.join(".bin/ok").exists(), "the valid bin is linked");
assert!(
!tmp.path().join("escape").exists() && !nm.join("escape").exists(),
"a path-like bin name creates nothing outside .bin/"
);
}
#[test]
#[ignore = "network: hits the npm registry"]
#[cfg(not(target_os = "macos"))]
fn installs_a_locked_tree_and_skips_offplatform_optional() {
let tmp = tempdir().unwrap();
let lock = tmp.path().join("package-lock.json");
std::fs::write(
&lock,
r#"{
"name": "fixture",
"lockfileVersion": 3,
"packages": {
"": { "name": "fixture", "dependencies": { "ms": "2.1.3" } },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/darwin-only": {
"version": "1.0.0",
"resolved": "https://example.invalid/never-fetched.tgz",
"integrity": "sha512-AAAA",
"optional": true,
"os": ["darwin"]
}
}
}"#,
)
.unwrap();
let installed = from_lockfile(&lock, tmp.path()).unwrap();
let names: Vec<&str> = installed.iter().map(|r| r.name.as_str()).collect();
assert_eq!(
names,
["ms"],
"the darwin-only optional dep is skipped on this host"
);
let nm = tmp.path().join("node_modules");
assert!(
nm.join("ms/package.json").is_file(),
"ms downloaded, integrity-verified and extracted"
);
assert!(
!nm.join("darwin-only").exists(),
"off-platform dep not installed"
);
let again = from_lockfile(&lock, tmp.path()).unwrap();
assert_eq!(again.len(), 1);
}
}