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 + Send + Sync>> {
from_lockfile_observed(package_lock, dest, |_| {})
}
pub(crate) fn from_lockfile_observed(
package_lock: &Path,
dest: &Path,
mut on_event: impl FnMut(super::InstallEvent<'_>),
) -> Result<Vec<Resolved>, Box<dyn std::error::Error + Send + Sync>> {
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 links: Vec<&LockedPackage> = lockfile
.packages
.iter()
.filter(|p| p.link && p.key.starts_with("node_modules/"))
.collect();
let want = crate::cache::file_hash(package_lock)?;
for host in installable
.iter()
.filter_map(|pkg| tarball_host(pkg.resolved.as_deref().unwrap_or_default()))
.filter(|host| *host != NPM_REGISTRY_HOST)
.collect::<std::collections::BTreeSet<_>>()
{
crate::warn::warn(&format!(
"lockfile fetches tarballs from non-registry host `{host}` — \
the URL and the integrity it is verified against both come from the lockfile"
));
}
let mut installed_idx: Vec<usize> = Vec::new();
let mut populated = false;
super::run_install(dest, &want, |node_modules| {
populated = true;
for (i, pkg) in installable.iter().enumerate() {
on_event(super::InstallEvent::Fetch {
index: i + 1,
total: installable.len(),
name: &pkg.name,
version: &pkg.version,
});
let dir = safe_join(dest, &pkg.key)?;
let url = pkg.resolved.as_deref().unwrap_or_default();
match super::fetch_verify_extract(&pkg.name, url, pkg.integrity.as_deref(), &dir) {
Ok(()) => installed_idx.push(i),
Err(e) if pkg.optional || pkg.dev_optional => {
crate::warn::warn(&format!(
"optional dependency `{}` failed to install ({e}); skipping",
pkg.name
));
}
Err(e) => return Err(e),
}
}
let installed_pkgs: Vec<&LockedPackage> =
installed_idx.iter().map(|&i| installable[i]).collect();
link_bins(node_modules, &installed_pkgs)?;
link_locals(dest, &links)?;
Ok(())
})?;
let result_set: Vec<&LockedPackage> = if populated {
installed_idx.iter().map(|&i| installable[i]).collect()
} else {
installable.clone()
};
result_set
.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(),
license: None,
})
})
.collect()
}
const NPM_REGISTRY_HOST: &str = "registry.npmjs.org";
fn tarball_host(url: &str) -> Option<String> {
let rest = url.strip_prefix("https://")?;
let host = rest.split('/').next().filter(|h| !h.is_empty())?;
Some(host.to_ascii_lowercase())
}
#[cfg(unix)]
fn link_bins(
node_modules: &Path,
plan: &[&LockedPackage],
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
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 + Send + Sync>> {
Ok(()) }
#[cfg(unix)]
fn link_locals(
dest: &Path,
links: &[&LockedPackage],
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use std::os::unix::fs::symlink;
for pkg in links {
let Some(resolved) = pkg.resolved.as_deref() else {
crate::warn::warn(&format!("link `{}` has no target; skipping", pkg.key));
continue;
};
let target = resolved
.strip_prefix("file:")
.unwrap_or(resolved)
.trim_start_matches("./");
let link_abs = safe_join(dest, &pkg.key)?;
if safe_join(dest, target).is_err() {
crate::warn::warn(&format!(
"workspace link `{}` targets `{target}` outside the project; skipping",
pkg.key
));
continue;
}
let depth = pkg.key.split('/').filter(|s| !s.is_empty()).count() - 1;
let link_value = format!("{}{target}", "../".repeat(depth));
if let Some(parent) = link_abs.parent() {
std::fs::create_dir_all(parent)?;
}
let _ = std::fs::remove_file(&link_abs); symlink(&link_value, &link_abs)?;
}
Ok(())
}
#[cfg(not(unix))]
fn link_locals(
_dest: &Path,
_links: &[&LockedPackage],
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
Ok(()) }
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn tarball_host_extracts_https_hosts_only() {
assert_eq!(
tarball_host("https://registry.npmjs.org/lit/-/lit-3.3.3.tgz").as_deref(),
Some("registry.npmjs.org")
);
assert_eq!(
tarball_host("https://NPM.example.com:8443/x.tgz").as_deref(),
Some("npm.example.com:8443"),
"host comparison is case-insensitive"
);
assert_eq!(tarball_host("http://registry.npmjs.org/x.tgz"), None);
assert_eq!(tarball_host("not a url"), None);
assert_eq!(tarball_host(""), None);
}
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,
license: 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(),
}
}
fn locked_link(key: &str, resolved: &str) -> LockedPackage {
LockedPackage {
name: key
.rsplit("node_modules/")
.next()
.unwrap_or(key)
.to_string(),
key: key.to_string(),
version: String::new(),
resolved: Some(resolved.to_string()),
integrity: None,
license: None,
dev: false,
optional: false,
dev_optional: false,
link: true,
os: Vec::new(),
cpu: Vec::new(),
bin: Vec::new(),
}
}
#[test]
#[cfg(unix)]
fn link_locals_symlinks_members_relative_to_dest() {
let tmp = tempdir().unwrap();
let dest = tmp.path();
let pkgs = [
locked_link("node_modules/@schuhkarton/assets-web", "modules/assets/web"),
locked_link("node_modules/plain", "file:packages/plain"),
];
let links: Vec<&LockedPackage> = pkgs.iter().collect();
link_locals(dest, &links).unwrap();
assert_eq!(
std::fs::read_link(dest.join("node_modules/@schuhkarton/assets-web")).unwrap(),
Path::new("../../modules/assets/web")
);
assert_eq!(
std::fs::read_link(dest.join("node_modules/plain")).unwrap(),
Path::new("../packages/plain")
);
}
#[test]
#[cfg(unix)]
fn link_locals_skips_a_target_escaping_the_project() {
let tmp = tempdir().unwrap();
let dest = tmp.path();
let pkgs = [locked_link("node_modules/outside", "file:../../../etc")];
let links: Vec<&LockedPackage> = pkgs.iter().collect();
link_locals(dest, &links).unwrap();
assert!(
!dest.join("node_modules/outside").exists()
&& std::fs::symlink_metadata(dest.join("node_modules/outside")).is_err(),
"an escaping link target creates nothing"
);
}
#[test]
#[cfg(unix)]
fn from_lockfile_materializes_a_workspace_link_offline() {
let tmp = tempdir().unwrap();
let dest = tmp.path();
std::fs::create_dir_all(dest.join("packages/member")).unwrap();
let lock = dest.join("package-lock.json");
std::fs::write(
&lock,
r#"{ "name": "root", "version": "1.0.0", "lockfileVersion": 3, "packages": {
"": { "name": "root", "version": "1.0.0" },
"packages/member": { "name": "@scope/member", "version": "1.0.0" },
"node_modules/@scope/member": { "resolved": "packages/member", "link": true }
} }"#,
)
.unwrap();
let installed = from_lockfile(&lock, dest).unwrap();
assert!(
installed.is_empty(),
"a link carries no tarball, so it is not in the installed set"
);
assert_eq!(
std::fs::read_link(dest.join("node_modules/@scope/member")).unwrap(),
Path::new("../../packages/member"),
"the workspace member is symlinked relative to dest"
);
}
#[test]
fn from_lockfile_observed_emits_fetch_before_each_download() {
let tmp = tempdir().unwrap();
let lock_path = tmp.path().join("package-lock.json");
std::fs::write(
&lock_path,
r#"{ "name": "demo", "version": "1.0.0", "lockfileVersion": 3, "packages": {
"": { "name": "demo", "version": "1.0.0" },
"node_modules/x": { "version": "1.2.3", "resolved": "https://localhost:1/x.tgz", "integrity": "sha512-AAA" }
} }"#,
)
.unwrap();
let mut events: Vec<String> = Vec::new();
let result = from_lockfile_observed(&lock_path, tmp.path(), |event| {
let super::super::InstallEvent::Fetch {
index,
total,
name,
version,
} = event;
events.push(format!("{index}/{total} {name}@{version}"));
});
assert!(result.is_err(), "the tarball fetch cannot succeed offline");
assert_eq!(events, ["1/1 x@1.2.3"]);
let empty = tempdir().unwrap();
let empty_lock = empty.path().join("package-lock.json");
std::fs::write(
&empty_lock,
r#"{ "name": "demo", "version": "1.0.0", "lockfileVersion": 3, "packages": {
"": { "name": "demo", "version": "1.0.0" }
} }"#,
)
.unwrap();
let mut count = 0;
let installed = from_lockfile_observed(&empty_lock, empty.path(), |_| count += 1).unwrap();
assert_eq!(count, 0);
assert!(installed.is_empty());
}
#[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);
}
#[test]
#[ignore = "network: fetches ms from the npm registry"]
fn tolerates_a_failing_onplatform_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/flaky-optional": {
"version": "1.0.0",
"resolved": "https://example.invalid/never-resolves.tgz",
"integrity": "sha512-AAAA",
"optional": true
}
}
}"#,
)
.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"],
"a failing optional dep is skipped, not fatal"
);
let nm = tmp.path().join("node_modules");
assert!(nm.join("ms/package.json").is_file());
assert!(!nm.join("flaky-optional").exists());
}
}