feat(install): reuse relocated node_modules (#15112)

Recognize workspace state recorded under a previous project root and
rebase its project paths in memory. Accept the move only when the
project set, installed layout, lockfile graph, manifests, patches,
dependency links and bin paths still agree.

Reuse the frozen-install integrity check for package slots and direct
dependency links. Compare the installed graph using the dependency
groups recorded in .modules.yaml so production-only installations can
pass the pre-run gate after relocation.

Leave pnpmfile content validation to the normal install path. Persist
the new workspace-state keys after a successful relocation check. When
materialization is needed, relink every recorded slot's bins to repair
legacy absolute paths. Preserve the old keys during filtered installs
until an unfiltered install can settle the whole workspace.

Keep the workspace-state file format unchanged. Limit reuse to
supported Unix isolated and hoisted layouts.

Related to pnpm/pnpm#6937.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-09-20 00:28:14 +02:00
1 parent f0f648458d
commit 616b043f5f
40 files changed
+2838 -284

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"pacquet": minor
---
`pnpm install`, `pnpm run`, and `pnpm exec` now reuse a `node_modules` directory that moved or was copied together with its project. The first command after the move checks the tree and records its new location. Reuse works on macOS and Linux, for a project moved with `mv`, `cp -a`, or a copy-on-write clone [#6937](https://github.com/pnpm/pnpm/issues/6937).
+1
View File
@@ -110,6 +110,7 @@ mod publish;
mod publish_recursive;
mod python;
mod rebuild_recursive;
mod relocated_install;
mod remove;
mod repeat_install;
mod repo;
+1 -1
View File
@@ -37,7 +37,7 @@ const IS_POSITIVE_HOOKS_FILE_PATCH: &str = include_str!(
/// Adds a marker file, so a package's patched state can be read off the
/// filesystem without depending on the package's own sources.
const MARKER_PATCH: &str = concat!(
pub(crate) const MARKER_PATCH: &str = concat!(
"diff --git a/patched-marker.txt b/patched-marker.txt\n",
"new file mode 100644\n",
"index 0000000..3f2e1d4\n",
@@ -0,0 +1,492 @@
#![cfg(unix)]
use crate::{
_utils::{ManifestDeps, append_workspace_yaml_key, pacquet_in, write_project_manifest},
repeat_install::version_of,
};
use assert_cmd::prelude::*;
use command_extra::CommandExtra;
use pnpm_testing_utils::{
bin::{AddMockedRegistry, CommandTempCwd},
fs::{MTIME_STEP_MS, set_mtime_ms},
};
use pnpm_workspace_state::{WORKSPACE_STATE_FILENAME, load_workspace_state};
use std::{
collections::{BTreeMap, BTreeSet},
fs,
os::unix::fs::MetadataExt,
path::{Path, PathBuf},
process::Command,
};
use walkdir::WalkDir;
/// The inode and the mtime and ctime timestamps of every entry under a
/// root, symlinks included, which together show any write an install makes.
type TreeSnapshot = BTreeMap<PathBuf, (u64, i64, i64, i64, i64)>;
fn tree_snapshot(root: &Path) -> TreeSnapshot {
WalkDir::new(root)
.into_iter()
.map(|entry| {
let entry = entry.expect("walk the tree");
let metadata = entry.metadata().expect("lstat the entry");
let stamps = (
metadata.ino(),
metadata.mtime(),
metadata.mtime_nsec(),
metadata.ctime(),
metadata.ctime_nsec(),
);
(entry.into_path(), stamps)
})
.collect()
}
fn changed_entries(before: &TreeSnapshot, after: &TreeSnapshot, root: &Path) -> BTreeSet<String> {
before
.keys()
.chain(after.keys())
.filter(|path| before.get(*path) != after.get(*path))
.map(|path| {
path.strip_prefix(root)
.expect("an entry under root")
.display()
.to_string()
})
.collect()
}
fn files_mentioning(root: &Path, path: &Path) -> Vec<PathBuf> {
let needle = path.to_string_lossy().into_owned();
WalkDir::new(root)
.into_iter()
.map(|entry| entry.expect("walk the tree"))
.filter(|entry| entry.file_type().is_file())
.filter(|entry| {
let bytes = fs::read(entry.path()).expect("read the file");
String::from_utf8_lossy(&bytes).contains(&needle)
})
.map(walkdir::DirEntry::into_path)
.collect()
}
/// A workspace whose relative store and cache are pinned to the same
/// directories by their canonical absolute paths, so nothing in the tree
/// names the store through the project dir and a move to another depth
/// keeps the store. Returns the temp dirs to keep alive for the test and
/// the canonical workspace dir.
fn pinned_workspace() -> (CommandTempCwd<AddMockedRegistry>, PathBuf) {
let temp_cwd = CommandTempCwd::init().add_mocked_registry();
let root = fs::canonicalize(temp_cwd.root.path()).expect("canonicalize the test root");
for config_file in [".npmrc", "pnpm-workspace.yaml"] {
let path = temp_cwd.workspace.join(config_file);
let text = fs::read_to_string(&path)
.expect("read the store config")
.replace("../pacquet-store", &root.join("pacquet-store").to_string_lossy())
.replace("../pacquet-cache", &root.join("pacquet-cache").to_string_lossy());
fs::write(&path, text).expect("write the store config");
}
let workspace = fs::canonicalize(&temp_cwd.workspace).expect("canonicalize the workspace");
(temp_cwd, workspace)
}
#[test]
fn copied_workspace_is_up_to_date_after_one_state_write() {
for node_linker in ["isolated", "hoisted"] {
let (temp_cwd, workspace) = pinned_workspace();
append_workspace_yaml_key(&workspace, "nodeLinker", node_linker);
pacquet_in(&workspace)
.with_args(["add", "@pnpm.e2e/hello-world-js-bin-parent@1.0.0"])
.assert()
.success();
let copy = workspace.with_file_name("copy");
Command::new("cp")
.arg("-Rp")
.arg(&workspace)
.arg(&copy)
.assert()
.success();
let bin = copy.join("node_modules/.bin/hello-world-js-bin");
assert!(bin.is_file(), "{node_linker}: the fixture must contain a bin");
let before = tree_snapshot(&copy);
let first = pacquet_in(&copy)
.with_arg("install")
.assert()
.success();
let first_output = String::from_utf8_lossy(&first.get_output().stdout).into_owned();
assert!(
first_output.contains("Already up to date")
&& !first_output.contains("resolution step is skipped"),
"the copy must be up to date before the install pipeline runs: {first_output}",
);
assert_state_recorded_at(&copy);
let after_first = tree_snapshot(&copy);
assert_eq!(
changed_entries(&before, &after_first, &copy),
BTreeSet::from([
"node_modules".to_string(),
format!("node_modules/{WORKSPACE_STATE_FILENAME}"),
]),
);
pacquet_in(&copy)
.with_arg("install")
.assert()
.success();
assert_eq!(changed_entries(&after_first, &tree_snapshot(&copy), &copy), BTreeSet::new());
let run = Command::new(&bin).assert().success();
let stdout = String::from_utf8_lossy(&run.get_output().stdout);
assert!(
stdout.contains("Hello world from hello-world-js-bin-parent!"),
"{node_linker}: copied bin output: {stdout}",
);
drop(temp_cwd);
}
}
#[test]
fn moved_rootless_workspace_reuses_its_sibling_projects() {
for node_linker in ["isolated", "hoisted"] {
let (temp_cwd, workspace) = pinned_workspace();
append_workspace_yaml_key(&workspace, "nodeLinker", node_linker);
append_workspace_yaml_key(&workspace, "packages", "[packages/*]");
for project in ["a", "b"] {
write_project_manifest(
&workspace.join("packages").join(project),
project,
ManifestDeps { prod: &[("is-positive", "1.0.0")], ..ManifestDeps::default() },
);
}
assert!(!workspace.join("package.json").exists());
pacquet_in(&workspace)
.with_arg("install")
.assert()
.success();
let moved = workspace.with_file_name("moved-rootless");
fs::rename(&workspace, &moved).expect("move the rootless workspace");
let before = tree_snapshot(&moved);
let install = pacquet_in(&moved)
.with_arg("install")
.assert()
.success();
let output = String::from_utf8_lossy(&install.get_output().stdout);
assert!(
output.contains("Already up to date") && !output.contains("resolution step is skipped"),
"{node_linker}: the rootless move must reuse the installation: {output}",
);
assert_eq!(
changed_entries(&before, &tree_snapshot(&moved), &moved),
BTreeSet::from([
"node_modules".to_string(),
format!("node_modules/{WORKSPACE_STATE_FILENAME}"),
]),
);
let state = load_workspace_state(&moved).unwrap().unwrap();
let expected = ["a", "b"].map(|project| {
moved
.join("packages")
.join(project)
.to_string_lossy()
.into_owned()
});
assert_eq!(
state.projects
.keys()
.cloned()
.collect::<BTreeSet<_>>(),
expected.into(),
);
let settled = tree_snapshot(&moved);
pacquet_in(&moved)
.with_arg("install")
.assert()
.success();
assert_eq!(changed_entries(&settled, &tree_snapshot(&moved), &moved), BTreeSet::new());
drop(temp_cwd);
}
}
/// A `node_modules` copied into another checkout of the project, whose
/// lockfile pins another version, is not the tree that checkout wants, even
/// though every file of the checkout predates the state it carries.
#[test]
fn transplanted_node_modules_is_not_up_to_date() {
let (temp_cwd, workspace) = pinned_workspace();
pacquet_in(&workspace)
.with_args(["add", "@pnpm.e2e/dep-of-pkg-with-1-dep@100.1.0"])
.assert()
.success();
let checkout = workspace.with_file_name("checkout");
Command::new("cp")
.arg("-Rp")
.arg(&workspace)
.arg(&checkout)
.assert()
.success();
fs::write(
checkout.join("package.json"),
serde_json::json!({ "dependencies": { "@pnpm.e2e/dep-of-pkg-with-1-dep": "100.0.0" } })
.to_string(),
)
.expect("pin the checkout to another version");
pacquet_in(&checkout)
.with_args(["install", "--lockfile-only"])
.assert()
.success();
let validated = load_workspace_state(&checkout)
.expect("read the transplanted state")
.expect("a transplanted state")
.last_validated_timestamp;
for file in ["package.json", "pnpm-lock.yaml"] {
set_mtime_ms(&checkout.join(file), validated - 2 * MTIME_STEP_MS);
}
let install = pacquet_in(&checkout)
.with_arg("install")
.assert()
.success();
let output = String::from_utf8_lossy(&install.get_output().stdout).into_owned();
assert!(!output.contains("Already up to date"), "the transplant must be relinked: {output}");
assert_eq!(version_of(&checkout, "node_modules/@pnpm.e2e/dep-of-pkg-with-1-dep"), "100.0.0");
drop(temp_cwd);
}
const IS_POSITIVE_PATCH_FILE: &str = "patches/is-positive.patch";
fn install_patched_workspace() -> (CommandTempCwd<AddMockedRegistry>, PathBuf) {
let (temp_cwd, workspace) = pinned_workspace();
fs::create_dir(workspace.join("patches")).expect("create the patches dir");
fs::write(workspace.join(IS_POSITIVE_PATCH_FILE), crate::patch::MARKER_PATCH)
.expect("write the patch");
append_workspace_yaml_key(
&workspace,
"patchedDependencies",
format!("\n is-positive@1.0.0: {IS_POSITIVE_PATCH_FILE}"),
);
pacquet_in(&workspace)
.with_args(["add", "is-positive@1.0.0"])
.assert()
.success();
(temp_cwd, workspace)
}
/// A plain `cp -R` stamps fresh mtimes on the copy, the patch file
/// included, so the first install in it proves the move by content instead
/// of by the patch mtimes, and the install after it writes nothing. The
/// patch hashes the lockfile records are what refuses the same tree once
/// its patch is edited.
#[test]
fn a_moved_patched_workspace_is_up_to_date_until_its_patch_is_edited() {
let (temp_cwd, workspace) = install_patched_workspace();
let copy = workspace.with_file_name("copy");
Command::new("cp")
.arg("-R")
.arg(&workspace)
.arg(&copy)
.assert()
.success();
let first = pacquet_in(&copy)
.with_arg("install")
.assert()
.success();
let first_output = String::from_utf8_lossy(&first.get_output().stdout).into_owned();
assert!(
first_output.contains("Already up to date")
&& !first_output.contains("resolution step is skipped"),
"the copy must be up to date before the install pipeline runs: {first_output}",
);
let moved = workspace.with_file_name("moved");
fs::rename(&workspace, &moved).expect("move the workspace");
fs::write(
moved.join(IS_POSITIVE_PATCH_FILE),
crate::patch::MARKER_PATCH.replace("+patched", "+edited"),
)
.expect("edit the patch");
pacquet_in(&moved)
.with_arg("install")
.assert()
.success();
let marker = moved.join("node_modules/is-positive/patched-marker.txt");
assert_eq!(fs::read_to_string(marker).expect("read the marker"), "edited\n");
drop(temp_cwd);
}
fn assert_state_recorded_at(dir: &Path) {
let state = load_workspace_state(dir).expect("read the state").expect("a workspace state");
let keys: Vec<&String> = state.projects.keys().collect();
assert_eq!(keys, [&dir.to_string_lossy().into_owned()]);
}
/// Keep the harness's workspace manifest, so the install is a workspace
/// install. `dedupePeers` lands in the lockfile, which the proof of the
/// move compares with the configured value.
fn as_a_workspace(workspace: &Path) {
append_workspace_yaml_key(workspace, "dedupePeers", true);
}
fn as_a_single_project(workspace: &Path) {
fs::remove_file(workspace.join("pnpm-workspace.yaml")).expect("remove the workspace manifest");
}
#[test]
fn a_moved_project_runs_and_is_recorded_where_it_is_now() {
let shapes =
[("a workspace", as_a_workspace as fn(&Path)), ("one project", as_a_single_project)];
for (label, shape) in shapes {
let (temp_cwd, workspace) = pinned_workspace();
shape(&workspace);
fs::write(
workspace.join("package.json"),
serde_json::json!({ "scripts": { "hello": "hello-world-js-bin" } }).to_string(),
)
.expect("write package.json");
pacquet_in(&workspace)
.with_args(["add", "@pnpm.e2e/hello-world-js-bin@1.0.0"])
.assert()
.success();
let moved = workspace.with_file_name("moved");
fs::rename(&workspace, &moved).expect("move the project");
let run = pacquet_in(&moved)
.with_args(["--config.verify-deps-before-run=error", "run", "hello"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&run.get_output().stdout).into_owned();
assert!(
stdout.contains("Hello world!"),
"{label}: the bin must run from {moved:?}: {stdout}",
);
assert_state_recorded_at(&moved);
let moved_again = workspace.with_file_name("moved-again");
fs::rename(&moved, &moved_again).expect("move the project again");
pacquet_in(&moved_again)
.with_arg("install")
.assert()
.success();
assert_state_recorded_at(&moved_again);
drop(temp_cwd);
}
}
/// The injected-deps syncer links a package's own bins into the
/// `node_modules/.bin` beside it in its slot. A bin there that names where
/// the tree was refuses the move like one in any other `.bin`.
#[test]
fn moved_tree_with_a_stale_slot_bin_is_not_up_to_date() {
let (temp_cwd, workspace) = pinned_workspace();
pacquet_in(&workspace)
.with_args(["add", "is-positive@1.0.0"])
.assert()
.success();
let slot_bin_dir = workspace.join("node_modules/.pnpm/is-positive@1.0.0/node_modules/.bin");
fs::create_dir(&slot_bin_dir).expect("create the slot's bin dir");
fs::write(
slot_bin_dir.join("stale"),
format!("#!/bin/sh\n# cmd-shim-target={}/node_modules/stale/cli.js\n", workspace.display()),
)
.expect("write a bin naming where the tree is");
let moved = workspace.with_file_name("moved");
fs::rename(&workspace, &moved).expect("move the workspace");
let install = pacquet_in(&moved)
.with_arg("install")
.assert()
.success();
let output = String::from_utf8_lossy(&install.get_output().stdout).into_owned();
assert!(
output.contains("resolution step is skipped"),
"the stale bin must refuse the move before the install pipeline runs: {output}",
);
drop(temp_cwd);
}
const SLOT_SHIM: &str = "node_modules/.pnpm/@pnpm.e2e+hello-world-js-bin-parent@1.0.0/node_modules/@pnpm.e2e/hello-world-js-bin-parent/node_modules/.bin/hello-world-js-bin";
fn make_shims_absolute_and_move(location: &Path, name: &str) -> PathBuf {
for shim in [
"b/node_modules/.bin/hello-world-js-bin",
"node_modules/.pnpm/node_modules/.bin/hello-world-js-bin",
SLOT_SHIM,
] {
let shim = location.join(shim);
let shim_dir = format!(
"{}/",
shim.parent()
.expect("the shim's dir")
.display(),
);
let body = fs::read_to_string(&shim).expect("read the shim");
assert!(body.contains("$basedir_abs/"), "a relocatable shim: {body}");
fs::write(&shim, body.replace("$basedir_abs/", &shim_dir)).expect("write an absolute shim");
}
let moved = location.with_file_name(name);
fs::rename(location, &moved).expect("move the workspace");
moved
}
#[test]
fn moved_tree_with_absolute_shims_converges_after_one_unfiltered_install() {
for state in ["valid", "missing", "invalid"] {
check_absolute_shim_repair(state);
}
}
fn check_absolute_shim_repair(state: &str) {
let (temp_cwd, workspace) = pinned_workspace();
append_workspace_yaml_key(&workspace, "packages", "[a, b]");
write_project_manifest(&workspace, "root", ManifestDeps::default());
for (project, dependency) in
[("a", "is-positive"), ("b", "@pnpm.e2e/hello-world-js-bin-parent")]
{
let prod = [(dependency, "1.0.0")];
let deps = ManifestDeps { prod: &prod, ..ManifestDeps::default() };
write_project_manifest(&workspace.join(project), project, deps);
}
pacquet_in(&workspace)
.with_arg("install")
.assert()
.success();
let moved = make_shims_absolute_and_move(&workspace, "first-move");
let state_path = moved.join("node_modules").join(WORKSPACE_STATE_FILENAME);
match state {
"missing" => fs::remove_file(&state_path).expect("remove workspace state"),
"invalid" => fs::write(&state_path, "{").expect("corrupt workspace state"),
_ => {}
}
pacquet_in(&moved)
.with_args(["--filter", "a", "install"])
.assert()
.success();
pacquet_in(&moved)
.with_arg("install")
.assert()
.success();
let mentions = files_mentioning(&moved, &workspace);
assert!(mentions.is_empty(), "files naming where the tree was: {mentions:?}");
let relinked = tree_snapshot(&moved);
pacquet_in(&moved)
.with_arg("install")
.assert()
.success();
assert_eq!(changed_entries(&relinked, &tree_snapshot(&moved), &moved), BTreeSet::new());
let moved_again = make_shims_absolute_and_move(&moved, "second-move");
pacquet_in(&moved_again)
.with_args(["install", "--no-prefer-frozen-lockfile"])
.assert()
.success();
let mentions = files_mentioning(&moved_again, &moved);
assert!(mentions.is_empty(), "files naming where the tree was: {mentions:?}");
drop(temp_cwd);
}
@@ -19,7 +19,7 @@ use pnpm_testing_utils::{
use std::{fs, path::Path};
/// `version` field of the `package.json` under `workspace/relative`.
fn version_of(workspace: &Path, relative: &str) -> String {
pub(crate) fn version_of(workspace: &Path, relative: &str) -> String {
let text = fs::read_to_string(workspace.join(relative).join("package.json"))
.unwrap_or_else(|error| panic!("read {relative}/package.json: {error}"));
let manifest: serde_json::Value = serde_json::from_str(&text).expect("parse package.json");
+3
View File
@@ -1,4 +1,5 @@
pub use discovery::collect_packages_in_modules_dir;
pub use relocatable::bin_dir_is_relocatable;
pub use shim_writer::remove_bin;
use crate::{
@@ -575,3 +576,5 @@ use executable::{
mod discovery;
mod linking_paths;
mod relocatable;
@@ -0,0 +1,57 @@
use crate::shim::is_relocatable_shim;
use pnpm_fs::{is_subdir, realpath_missing};
use std::{
fs::{self, DirEntry},
io::{self, Read},
path::Path,
};
/// A generated shim stays far below this, so a larger `.bin` entry is not one
/// and is refused without being held in memory.
const MAX_SHIM_BYTES: u64 = 64 * 1024;
/// Missing bin directories are valid; unreadable entries and paths resolving
/// outside `root` are not. Only relative symlinks and recognized shims qualify.
#[must_use]
pub fn bin_dir_is_relocatable(bin_dir: &Path, root: &Path) -> bool {
let (Ok(root), Ok(bin_dir)) = (realpath_missing(root), realpath_missing(bin_dir)) else {
return false;
};
if !is_subdir(&root, &bin_dir) {
return false;
}
match fs::read_dir(&bin_dir) {
Ok(mut entries) => entries.all(|entry| {
entry.is_ok_and(|entry| is_relocatable_bin(&entry, &bin_dir, &root))
}),
Err(error) => error.kind() == io::ErrorKind::NotFound,
}
}
fn is_relocatable_bin(entry: &DirEntry, bin_dir: &Path, root: &Path) -> bool {
let path = entry.path();
match entry.file_type() {
Ok(file_type) if file_type.is_symlink() => fs::read_link(&path)
.is_ok_and(|link| {
link.is_relative()
&& realpath_missing(&bin_dir.join(link))
.is_ok_and(|target| is_subdir(root, &target))
}),
Ok(file_type) if file_type.is_file() => {
read_shim(&path).is_some_and(|content| is_relocatable_shim(&content, bin_dir, root))
}
_ => false,
}
}
/// The text of a file of at most [`MAX_SHIM_BYTES`], or `None` for a longer
/// one and for anything that is not readable text.
fn read_shim(path: &Path) -> Option<String> {
let mut content = String::new();
fs::File::open(path)
.ok()?
.take(MAX_SHIM_BYTES + 1)
.read_to_string(&mut content)
.ok()?;
(content.len() as u64 <= MAX_SHIM_BYTES).then_some(content)
}
@@ -42,3 +42,6 @@ mod security;
#[cfg(unix)]
mod relocatable;
#[cfg(unix)]
mod relocation_validation;
@@ -2,6 +2,7 @@ use super::{
Arc, Host, LinkBinsOptions, PackageBinSource, Path, PathBuf, create_dir_all, json,
link_bins_of_packages, read_to_string, tempdir, write_file,
};
use crate::link_bins::bin_dir_is_relocatable;
use pnpm_fs::lexical_normalize;
use std::{
fs,
@@ -575,3 +576,40 @@ fn external_target_directory_alias_can_be_retargeted_after_linking() {
assert!(output.status.success());
assert_eq!(String::from_utf8(output.stdout).unwrap().trim(), "second");
}
#[test]
fn bin_dir_is_relocatable_accepts_generated_and_rejects_absolute_symlinks() {
let tmp = tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let bin_dir = link_node_path_printer(&root, Some(root.clone()));
symlink("../.pnpm/foo@1.0.0/node_modules/foo/print.sh", bin_dir.join("linked")).unwrap();
assert!(bin_dir_is_relocatable(&bin_dir, &root));
assert!(bin_dir_is_relocatable(&root.join("missing/.bin"), &root), "nothing to move");
assert!(!bin_dir_is_relocatable(&bin_dir.join("foo"), &root), "unreadable, so it fails closed");
let legacy_root = root.join("legacy");
let legacy_bin_dir = link_node_path_printer(&legacy_root, None);
assert!(!bin_dir_is_relocatable(&legacy_bin_dir, &legacy_root), "absolute shims");
let outside_links = [
root.join("node_modules/.pnpm/foo@1.0.0/node_modules/foo/print.sh"),
PathBuf::from("../../../outside"),
];
for outside_link in outside_links {
let link = bin_dir.join("outside");
symlink(&outside_link, &link).unwrap();
assert!(!bin_dir_is_relocatable(&bin_dir, &root), "{outside_link:?}");
fs::remove_file(&link).unwrap();
}
}
#[test]
fn bin_dir_is_relocatable_rejects_directory_entries() {
let tmp = tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let bin_dir = link_node_path_printer(&root, Some(root.clone()));
assert!(bin_dir_is_relocatable(&bin_dir, &root));
create_dir_all(bin_dir.join("unexpected-directory")).unwrap();
assert!(!bin_dir_is_relocatable(&bin_dir, &root));
}
@@ -0,0 +1,128 @@
use crate::{link_bins::bin_dir_is_relocatable, shim::is_relocatable_shim};
use std::{fs, os::unix::fs::symlink};
use tempfile::tempdir;
#[test]
fn relative_bin_links_must_resolve_inside_the_physical_root() {
let tmp = tempdir().unwrap();
let root = tmp.path().join("project");
let bins = root.join(".bin");
fs::create_dir_all(&bins).unwrap();
fs::create_dir_all(tmp.path().join("outside")).unwrap();
fs::create_dir_all(root.join("internal")).unwrap();
let alias = root.join("alias");
symlink("internal", &alias).unwrap();
symlink("../alias/missing/cli", bins.join("tool")).unwrap();
assert!(bin_dir_is_relocatable(&bins, &root), "missing internal tail is valid");
let root_alias = tmp.path().join("project-alias");
symlink(&root, &root_alias).unwrap();
assert!(bin_dir_is_relocatable(&root_alias.join(".bin"), &root_alias));
fs::remove_file(&alias).unwrap();
symlink("../outside", &alias).unwrap();
assert!(!bin_dir_is_relocatable(&bins, &root), "ancestor escapes physically");
fs::remove_file(&alias).unwrap();
symlink("alias", &alias).unwrap();
assert!(!bin_dir_is_relocatable(&bins, &root), "unresolvable loop fails closed");
}
#[test]
fn shim_markers_and_node_paths_must_resolve_inside_the_physical_root() {
let tmp = tempdir().unwrap();
let root = tmp.path().join("project");
let bins = root.join(".bin");
fs::create_dir_all(&bins).unwrap();
fs::create_dir_all(root.join("internal")).unwrap();
fs::create_dir_all(tmp.path().join("outside")).unwrap();
let alias = root.join("alias");
symlink("internal", &alias).unwrap();
let marker = "# cmd-shim-target=../alias/missing/cli\n";
let node_path = concat!(
"# cmd-shim-target=../internal/cli\n",
"export NODE_PATH=\"$basedir_abs/../alias/missing/modules:$NODE_PATH\"\n",
);
for content in [marker, node_path] {
assert!(is_relocatable_shim(content, &bins, &root));
}
fs::remove_file(&alias).unwrap();
symlink("../outside", &alias).unwrap();
for content in [marker, node_path] {
assert!(!is_relocatable_shim(content, &bins, &root), "{content}");
}
}
#[test]
fn escaped_node_path_alias_is_checked_using_its_literal_name() {
let tmp = tempdir().unwrap();
let root = tmp.path().join("project");
let bins = root.join(".bin");
fs::create_dir_all(&bins).unwrap();
fs::create_dir_all(root.join("internal")).unwrap();
fs::create_dir_all(tmp.path().join("outside")).unwrap();
let alias = root.join(r#"alias$`"\"#);
symlink("internal", &alias).unwrap();
let content = concat!(
"# cmd-shim-target=../internal/cli\n",
r#"export NODE_PATH="$basedir_abs/../alias\$\`\"\\/missing/modules""#,
"\n",
);
assert!(is_relocatable_shim(content, &bins, &root));
fs::remove_file(&alias).unwrap();
symlink("../outside", &alias).unwrap();
assert!(!is_relocatable_shim(content, &bins, &root));
}
#[test]
fn node_path_validation_rejects_malformed_escapes_and_shell_expansion() {
let tmp = tempdir().unwrap();
let root = tmp.path();
let bins = root.join(".bin");
fs::create_dir_all(&bins).unwrap();
let entries = [r"../invalid\q", r"../trailing\", "../$HOME", "../`pwd`", r#"../unquoted"name"#];
for entry in entries {
let content =
format!("# cmd-shim-target=../cli\nexport NODE_PATH=\"$basedir_abs/{entry}\"\n");
assert!(!is_relocatable_shim(&content, &bins, root), "{content}");
}
}
#[test]
fn validators_reject_external_bin_directories_and_unresolvable_roots() {
let tmp = tempdir().unwrap();
let root = tmp.path().join("project");
let bins = root.join(".bin");
let outside = tmp.path().join("outside");
fs::create_dir_all(&bins).unwrap();
fs::create_dir_all(&outside).unwrap();
let content = "# cmd-shim-target=cli\n";
assert!(bin_dir_is_relocatable(&bins, &root));
assert!(is_relocatable_shim(content, &bins, &root));
let alias = root.join("bin-alias");
symlink("../outside", &alias).unwrap();
assert!(!bin_dir_is_relocatable(&alias, &root));
assert!(!is_relocatable_shim(content, &alias, &root));
let unresolved = tmp.path().join("loop");
symlink("loop", &unresolved).unwrap();
for (bin_dir, relocation_root) in [(&unresolved, &root), (&bins, &unresolved)] {
assert!(!bin_dir_is_relocatable(bin_dir, relocation_root));
assert!(!is_relocatable_shim(content, bin_dir, relocation_root));
}
}
#[test]
fn a_bin_file_past_the_size_bound_is_refused() {
let tmp = tempdir().unwrap();
let root = tmp.path();
let bins = root.join(".bin");
fs::create_dir_all(&bins).unwrap();
let shim = bins.join("tool");
let marker = "# cmd-shim-target=../cli\n";
fs::write(&shim, marker).unwrap();
assert!(bin_dir_is_relocatable(&bins, root));
let padding = " ".repeat(64 * 1024);
fs::write(&shim, format!("{marker}{padding}")).unwrap();
assert!(!bin_dir_is_relocatable(&bins, root), "a shim past the size bound fails closed");
}
+1 -1
View File
@@ -1,6 +1,6 @@
pub use powershell::generate_pwsh_shim;
pub use quoting::{cmd_escape, sh_single_quote};
pub(crate) use relocatable::is_within_root;
pub(crate) use relocatable::{is_relocatable_shim, is_within_root};
pub use sh::{generate_sh_shim, is_sh_shim_hardened, is_shim_pointing_at};
use crate::{capabilities::FsReadHead, path_util::lexical_normalize};
+58 -1
View File
@@ -5,7 +5,7 @@
//! when the shim runs.
use super::relative_path_from;
use pnpm_fs::is_subdir;
use pnpm_fs::{is_subdir, realpath_missing};
use std::{borrow::Cow, path::Path};
/// Sets `$basedir_abs` to the shim's physical directory so Node's lexical
@@ -84,3 +84,60 @@ fn escape_sh_double_quoted(text: &str) -> String {
}
escaped
}
/// Whether `shim_content`, a shim in `shim_dir`, names its target and every
/// `NODE_PATH` entry relative to itself, each resolving inside `root`. A shim
/// without a target marker, or with a `NODE_PATH` export it cannot read,
/// fails.
#[must_use]
pub(crate) fn is_relocatable_shim(shim_content: &str, shim_dir: &Path, root: &Path) -> bool {
let (Ok(root), Ok(shim_dir)) = (realpath_missing(root), realpath_missing(shim_dir)) else {
return false;
};
if !is_subdir(&root, &shim_dir) {
return false;
}
let resolves_in_root = |relative: &str| {
Path::new(relative).is_relative()
&& realpath_missing(&shim_dir.join(relative))
.is_ok_and(|target| is_subdir(&root, &target))
};
let entries_resolve_in_root = |value: &str| {
value
.split(':')
.filter(|entry| *entry != "$NODE_PATH")
.all(|entry| {
entry
.strip_prefix(BASEDIR_ABS)
.and_then(unescape_sh_double_quoted)
.is_some_and(|relative| resolves_in_root(&relative))
})
};
let mut markers = shim_target_markers(shim_content).peekable();
markers.peek().is_some()
&& markers.all(resolves_in_root)
&& shim_content
.lines()
.filter_map(|line| line.trim_start().strip_prefix("export NODE_PATH="))
.map(|value| value.strip_prefix('"')?.strip_suffix('"'))
.all(|value| value.is_some_and(entries_resolve_in_root))
}
fn unescape_sh_double_quoted(text: &str) -> Option<String> {
let mut unescaped = String::with_capacity(text.len());
let mut characters = text.chars();
while let Some(character) = characters.next() {
if character == '\\' {
let escaped = characters.next()?;
if !matches!(escaped, '\\' | '"' | '$' | '`') {
return None;
}
unescaped.push(escaped);
} else if matches!(character, '"' | '$' | '`') {
return None;
} else {
unescaped.push(character);
}
}
Some(unescaped)
}
@@ -1,4 +1,5 @@
use super::{Path, ScriptRuntime, generate_sh_shim};
use crate::shim::is_relocatable_shim;
#[test]
fn generate_sh_shim_keeps_paths_outside_the_root_absolute() {
@@ -66,14 +67,22 @@ fn relative_node_path_segments_are_sh_escaped() {
#[test]
#[cfg_attr(not(unix), ignore = "relocatable shims are only written on Unix")]
fn in_root_shim_is_written_relative_to_itself() {
let root = Path::new("/p");
let shim_dir = Path::new("/p/node_modules/.bin");
fn in_root_shim_is_written_relative_to_itself_and_only_such_a_shim_is_relocatable() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let shim_dir = root.join("node_modules/.bin");
std::fs::create_dir_all(&shim_dir).unwrap();
let shim_dir = shim_dir.as_path();
let shim = shim_dir.join("tsc");
let target = Path::new("/p/node_modules/typescript/bin/tsc");
let target = root.join("node_modules/typescript/bin/tsc");
let target = target.as_path();
let node_path = [
"/p/node_modules/.pnpm/typescript@5.0.0/node_modules".to_string(),
"/p/node_modules/.pnpm/node_modules".to_string(),
root.join("node_modules/.pnpm/typescript@5.0.0/node_modules")
.to_string_lossy()
.into_owned(),
root.join("node_modules/.pnpm/node_modules")
.to_string_lossy()
.into_owned(),
];
let relocatable = generate_sh_shim(target, &shim, None, &node_path, Some(root));
eprintln!("BODY:\n{relocatable}");
@@ -84,6 +93,42 @@ fn in_root_shim_is_written_relative_to_itself() {
" export NODE_PATH=\"$basedir_abs/../.pnpm/typescript@5.0.0/node_modules:$basedir_abs/../.pnpm/node_modules:$NODE_PATH\"\n",
"fi\n",
)));
assert!(!relocatable.contains("/p/"), "no path inside the root may stay absolute");
assert!(
!relocatable.contains(root.to_str().unwrap()),
"no path inside the root may stay absolute",
);
assert!(relocatable.ends_with("# cmd-shim-target=../typescript/bin/tsc\n"));
assert!(
is_relocatable_shim(&relocatable, shim_dir, root),
"a generated shim, its `$NODE_PATH` passthrough included, must pass",
);
let marker = "# cmd-shim-target=../typescript/bin/tsc\n";
let split_node_path = [
"/p/node_modules/a\nb/node_modules".to_string(),
"/old/p/node_modules/.pnpm/node_modules".to_string(),
];
let rejected = [
(
"an entry split over two lines",
generate_sh_shim(target, &shim, None, &split_node_path, Some(root)),
),
(
"absolute marker",
relocatable.replace(marker, &format!("# cmd-shim-target={}\n", target.display())),
),
("no marker", relocatable.replace(marker, "")),
(
"marker climbing out",
relocatable.replace(marker, "# cmd-shim-target=../../../../x/tsc\n"),
),
("absolute entry", relocatable.replace("$basedir_abs/../.pnpm", "/p/node_modules/.pnpm")),
(
"climbing out",
relocatable.replace("$basedir_abs/../.pnpm", "$basedir_abs/../../../../x"),
),
];
for (label, shim_content) in rejected {
assert!(!is_relocatable_shim(&shim_content, shim_dir, root), "{label}:\n{shim_content}");
}
}
@@ -160,6 +160,10 @@ pub struct PriorMaterialization<'a> {
pub unbuilt_builds: &'a crate::UnbuiltBuilds,
/// See [`crate::PruneStaleModules::prune_orphans`].
pub prune_orphans: bool,
/// Relink the bins of every slot the lockfile records, not only of the
/// slots this install materializes. A tree that moved with its project
/// needs it, because its other slots may hold bins naming where it was.
pub relink_every_slot_bin: bool,
}
#[derive(Default)]
@@ -86,8 +86,8 @@ impl<'a> InstallFrozenLockfile<'a> {
graph: crate::LinkLockfiles {
lockfile: install.lockfiles.wanted,
current_lockfile: install.lockfiles.current,
materialized_snapshots: install.prior.rebuild
.is_none()
materialized_snapshots: (install.prior.rebuild.is_none()
&& !install.prior.relink_every_slot_bin)
.then_some(phase.fetched.materialized_snapshots.as_slice()),
sidecar_lockfile: phase.current_lockfile,
},
+6 -4
View File
@@ -10,6 +10,9 @@ pub(crate) use lockfile_freshness::{
pub use lockfile_freshness::{
WantedLockfileSatisfactionCheck, wanted_lockfile_satisfies_workspace,
};
pub(crate) use modules_state::{
frozen_tree_intact, modules_layout_consistent_with, moved_tree_is_reusable, tree_may_move,
};
pub use run::{InstallExecution, InstallLockfilePolicy, ResolutionInputs};
pub use workspace_state::{
UpToDateFastPathCheck, UpToDateWorkspace, build_workspace_packages_map,
@@ -102,10 +105,9 @@ use lockfile_freshness::{
use materialize::{MaterializationInputs, Materialized, materialize};
use modules_state::{
build_modules_manifest, check_modules_settings_diff, current_contains_dep_path,
drain_settled_projects, frozen_tree_intact, gvs_build_marker_present,
gvs_build_markers_may_require_recovery, has_newly_allowed_ignored_builds,
has_revoked_allowed_builds, manifest_string_field, merge_filtered_modules_metadata,
merge_pending_builds, modules_consistent_with, modules_layout_consistent_with,
drain_settled_projects, gvs_build_marker_present, gvs_build_markers_may_require_recovery,
has_newly_allowed_ignored_builds, has_revoked_allowed_builds, manifest_string_field,
merge_filtered_modules_metadata, merge_pending_builds, modules_consistent_with,
project_requires_lifecycle_scripts, unapproved_recorded_ignored_builds,
};
use prepare_modules_state::{
@@ -235,7 +235,11 @@ fn finish_apply<Reporter: self::Reporter>(
inputs.prior.lockfile.take(),
));
write_applied_workspace_state(&inputs)?;
// Refreshing the root here would hide stale bins in unselected projects
// from the next install.
if !(inputs.prior.tree_moved && inputs.projects.filtered_install) {
write_applied_workspace_state(&inputs)?;
}
let completion = report_install_completion::<Reporter>(ReportInstallCompletionInputs {
workspace: crate::install::state_options::CompletionWorkspace {
@@ -44,6 +44,8 @@ pub(super) struct MaterializationModules<'a> {
pub(super) prior_hoisted_dependencies: Option<&'a HoistedDependencies>,
pub(super) prior_hoisted_locations: Option<&'a pnpm_deps_restorer::HoistedLocations>,
pub(super) prune_orphans: bool,
/// See [`pnpm_deps_restorer::PriorMaterialization::relink_every_slot_bin`].
pub(super) relink_every_slot_bin: bool,
pub(super) logged_methods: &'a AtomicU8,
}
@@ -157,6 +159,7 @@ impl<'a> MaterializationInputs<'a, '_> {
self.install.context.config,
),
prune_orphans: self.modules.prune_orphans,
relink_every_slot_bin: self.modules.relink_every_slot_bin,
}
}
@@ -70,6 +70,7 @@ impl<'a> MaterializationInputs<'a, '_> {
),
unbuilt_builds: prior_unbuilt_builds,
prune_orphans: self.modules.prune_orphans,
relink_every_slot_bin: self.modules.relink_every_slot_bin,
},
projects: pnpm_deps_restorer::FrozenProjectInputs {
workspace_root: self.workspace.workspace_root,
@@ -1,3 +1,5 @@
pub(crate) use integrity::frozen_tree_intact;
pub(super) use build_markers::{gvs_build_marker_present, gvs_build_markers_may_require_recovery};
pub(super) use merge_metadata::{
current_contains_dep_path, merge_filtered_modules_metadata, merge_pending_builds,
@@ -7,11 +9,15 @@ mod build_markers;
mod merge_metadata;
mod integrity;
use super::{
BTreeMap, Config, HoistedDependencies, IncludedDependencies, InstallError, LayoutVersion,
Lockfile, Modules, ModulesNodeLinker, NodeLinker, PNPM_VERSION, PackageManifest, Path,
BTreeMap, Config, HoistedDependencies, Host, IncludedDependencies, InstallError, LayoutVersion,
Lockfile, Modules, ModulesNodeLinker, NodeLinker, PNPM_VERSION, PackageManifest, Path, PathBuf,
write_modules_manifest,
};
use pnpm_cmd_shim::bin_dir_is_relocatable;
use rayon::prelude::*;
/// Translate pacquet's [`Config::node_linker`] into the
/// [`pnpm_modules_yaml::NodeLinker`] enum used on disk. The two
@@ -51,132 +57,109 @@ pub(super) fn modules_consistent_with(
modules.included == included && modules_layout_consistent_with(modules, config, node_linker)
}
/// The subset of [`modules_consistent_with`] that, when it drifts, requires
/// **wiping and recreating** `node_modules`. It deliberately excludes
/// `included`: a `--prod`<->full switch is satisfied by relinking the
/// newly-selected groups plus the targeted removal of the now-excluded
/// ones ([`crate::prune_direct_deps_excluded_by_groups`]), not by
/// deleting the directory. pnpm never purges the root project's
/// `node_modules` for an included mismatch — its `validateModules` only
/// does so for non-root importers (the `lockfileDir !== rootDir` check
/// in `pnpm11/installing/deps-installer/src/install/validateModules.ts`)
/// — so purging here would destroy the user's own non-pnpm entries (a
/// vendored directory, stray files) on a routine flag change. The
/// up-to-date fast path still compares `included` via
/// [`modules_consistent_with`], so the relink it triggers stays correct.
/// On-disk probe backing the frozen no-op short-circuit: the
/// short-circuit skips the materialization walk entirely, so it must
/// first prove the tree it would skip is still whole — pnpm's headless
/// path stats every package dir on every run, which is what repairs a
/// hand-deleted package. One metadata call per snapshot slot plus one
/// per direct-dep link; any missing entry falls through to the full
/// frozen path, which re-materializes it (emitting
/// `pnpm:_broken_node_modules`).
///
/// Under a global virtual store the slot paths depend on graph hashes
/// the short-circuit doesn't compute, and the hoisted linker has no
/// virtual-store slots; both probe only the importer links.
pub(super) fn frozen_tree_intact(
wanted: &Lockfile,
modules: &pnpm_modules_yaml::ModulesLayout,
config: &Config,
workspace_root: &Path,
node_linker: NodeLinker,
) -> bool {
if matches!(node_linker, NodeLinker::Pnp) && !workspace_root.join(crate::PNP_FILENAME).is_file()
{
return false;
}
let skipped = crate::SkippedSnapshots::from_strings(&modules.skipped);
let probe_slots =
!matches!(node_linker, NodeLinker::Hoisted) && !config.enable_global_virtual_store;
if probe_slots
&& let Some(snapshots) = wanted.snapshots.as_ref()
&& !all_virtual_store_slots_present(snapshots, config, &skipped)
{
return false;
}
if !config.symlink {
return probe_slots;
}
importer_symlinks_intact(wanted, modules, config, workspace_root, &skipped)
/// Whether a tree that moved with its project can be reused at all: only on
/// unix, where directory links are relative symlinks rather than junctions,
/// and only for an isolated or hoisted tree outside a global virtual store,
/// whose links point into a store that registers projects by their path.
pub(crate) fn tree_may_move(config: &Config, node_linker: NodeLinker) -> bool {
cfg!(unix) && !config.enable_global_virtual_store && node_linker != NodeLinker::Pnp
}
/// Whether every snapshot the lockfile records still has its virtual-store
/// slot on disk.
fn all_virtual_store_slots_present(
snapshots: &std::collections::HashMap<pnpm_lockfile::PackageKey, pnpm_lockfile::SnapshotEntry>,
/// Whether a tree that moved with its project keeps working where it is now:
/// [`tree_may_move`], and every importer, hoist, virtual-store slot and
/// hoisted-package `.bin` holds only bins that name their paths relative to
/// themselves, inside the directory holding `config.modules_dir`.
pub(crate) fn moved_tree_is_reusable(
config: &Config,
skipped: &crate::SkippedSnapshots,
node_linker: NodeLinker,
project_manifests: &[(PathBuf, &PackageManifest)],
lockfile: &Lockfile,
) -> bool {
let Some(root) = config.modules_dir.parent() else { return false };
tree_may_move(config, node_linker)
&& importer_bins_are_relocatable(config, project_manifests, root)
&& match node_linker {
NodeLinker::Hoisted => hoisted_bins_are_relocatable(config, root),
_ => virtual_store_bins_are_relocatable(config, lockfile, root),
}
}
fn importer_bins_are_relocatable(
config: &Config,
project_manifests: &[(PathBuf, &PackageManifest)],
root: &Path,
) -> bool {
let modules_dir_name: &std::ffi::OsStr =
config.modules_dir.file_name().unwrap_or_else(|| std::ffi::OsStr::new("node_modules"));
bin_dir_is_relocatable(&config.modules_dir.join(".bin"), root)
&& project_manifests
.iter()
.filter(|(project_dir, _)| project_dir != root)
.all(|(project_dir, _)| {
bin_dir_is_relocatable(&project_dir.join(modules_dir_name).join(".bin"), root)
})
}
/// The bins of the packages hoisted into the virtual store, and each slot's
/// own.
fn virtual_store_bins_are_relocatable(config: &Config, lockfile: &Lockfile, root: &Path) -> bool {
let layout = crate::VirtualStoreLayout::legacy(
config.virtual_store_dir.clone(),
config.virtual_store_dir_max_length as usize,
);
snapshots
.keys()
.all(|key| {
if skipped.contains(key) {
return true;
}
// The name is lockfile-controlled: join it with the same
// traversal-rejecting helper the linkers use, and treat a
// malformed name as not-intact so the full path's
// structural lockfile gate rejects it.
let slot_node_modules = layout.slot_dir(key).join("node_modules");
match crate::safe_join_modules_dir::safe_join_modules_dir(
&slot_node_modules,
&key.name.to_string(),
) {
Ok(dir) => dir.is_dir(),
Err(_) => false,
}
})
bin_dir_is_relocatable(&config.virtual_store_dir.join("node_modules").join(".bin"), root)
&& lockfile.snapshots
.as_ref()
.is_none_or(|snapshots| {
snapshots
.par_iter()
.all(|(key, _)| {
slot_bins_are_relocatable(
&layout.slot_dir(key).join("node_modules"),
key,
root,
)
})
})
}
/// Whether every importer's direct dependencies are still symlinked into its
/// own `node_modules`.
fn importer_symlinks_intact(
wanted: &Lockfile,
modules: &pnpm_modules_yaml::ModulesLayout,
config: &Config,
workspace_root: &Path,
skipped: &crate::SkippedSnapshots,
) -> bool {
let groups = crate::prune_direct_deps::selected_groups(modules.included);
let modules_dir_name: &std::ffi::OsStr =
config.modules_dir.file_name().unwrap_or_else(|| std::ffi::OsStr::new("node_modules"));
wanted.importers
/// The `.bin` dirs the hoisted linker writes inside the packages it placed,
/// enumerated from the `hoistedLocations` the install recorded. A record that
/// cannot be read, or a location naming a directory outside the tree, refuses
/// the move.
fn hoisted_bins_are_relocatable(config: &Config, root: &Path) -> bool {
let Ok(Some(modules)) = pnpm_modules_yaml::read_modules_manifest::<Host>(&config.modules_dir)
else {
return false;
};
modules.hoisted_locations
.iter()
.all(|(importer_id, snapshot)| {
if crate::symlink_direct_dependencies::validate_importer_id(importer_id).is_err() {
return true;
}
let modules_dir =
crate::symlink_direct_dependencies::importer_root_dir(workspace_root, importer_id)
.join(modules_dir_name);
crate::symlink_direct_dependencies::direct_dep_names_for_importer(
snapshot,
groups.iter().copied(),
skipped,
false,
)
.iter()
.all(|name| direct_dep_link_resolves(&modules_dir, name))
.flat_map(BTreeMap::values)
.flatten()
.all(|location| {
let package_dir = root.join(location);
pnpm_fs::is_subdir(root, &package_dir)
&& bin_dir_is_relocatable(&package_dir.join("node_modules").join(".bin"), root)
})
}
fn direct_dep_link_resolves(modules_dir: &Path, name: &str) -> bool {
match crate::safe_join_modules_dir::safe_join_modules_dir(modules_dir, name) {
// `metadata` follows the link, so a dangling direct-dep
// symlink (a wiped GVS store, a hand-deleted target)
// reads as broken and falls through to the repairing
// full path.
Ok(link) => std::fs::metadata(link).is_ok(),
// A malformed alias never probes the disk; the full
// path rejects it with its own typed error.
Err(_) => true,
}
/// The `.bin` the install links into the slot's package, and the one next
/// to the package, which the injected-deps syncer links a package's own
/// bins into.
fn slot_bins_are_relocatable(
slot_modules_dir: &Path,
key: &pnpm_lockfile::PackageKey,
root: &Path,
) -> bool {
bin_dir_is_relocatable(&slot_modules_dir.join(".bin"), root)
// A malformed lockfile-controlled name fails closed.
&& crate::safe_join_modules_dir::safe_join_modules_dir(
slot_modules_dir,
&key.name.to_string(),
)
.is_ok_and(|package_dir| {
bin_dir_is_relocatable(&package_dir.join("node_modules").join(".bin"), root)
})
}
/// The `validateModules` half pacquet enforces: when the mutation is
@@ -212,7 +195,20 @@ pub(super) fn normalized_pattern(pattern: Option<&[String]>) -> &[String] {
pattern.unwrap_or(&[])
}
pub(super) fn modules_layout_consistent_with(
/// The subset of [`modules_consistent_with`] that, when it drifts, requires
/// **wiping and recreating** `node_modules`. It deliberately excludes
/// `included`: a `--prod`<->full switch is satisfied by relinking the
/// newly-selected groups plus the targeted removal of the now-excluded
/// ones ([`crate::prune_direct_deps_excluded_by_groups`]), not by
/// deleting the directory. pnpm never purges the root project's
/// `node_modules` for an included mismatch: its `validateModules` only
/// does so for non-root importers (the `lockfileDir !== rootDir` check
/// in `pnpm11/installing/deps-installer/src/install/validateModules.ts`)
/// so purging here would destroy the user's own non-pnpm entries (a
/// vendored directory, stray files) on a routine flag change. The
/// up-to-date fast path still compares `included` via
/// [`modules_consistent_with`], so the relink it triggers stays correct.
pub(crate) fn modules_layout_consistent_with(
modules: &pnpm_modules_yaml::ModulesLayout,
config: &Config,
node_linker: NodeLinker,
@@ -509,3 +505,6 @@ pub(super) fn manifest_string_field(manifest: &PackageManifest, key: &str) -> Op
.and_then(|v| v.as_str())
.map(ToString::to_string)
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,199 @@
use super::{Config, Lockfile, NodeLinker, Path};
use pnpm_lockfile::PackageKey;
use std::collections::BTreeMap;
/// On-disk probe backing the frozen no-op short-circuit: the
/// short-circuit skips the materialization walk entirely, so it must
/// first prove the tree it would skip is still whole, as pnpm's headless
/// path stats every package dir on every run, which is what repairs a
/// hand-deleted package. Required packages and direct-dependency links are
/// checked; any missing entry falls through to the full
/// frozen path, which re-materializes it (emitting
/// `pnpm:_broken_node_modules`).
///
/// Under a global virtual store the slot paths depend on graph hashes
/// the short-circuit doesn't compute, and the hoisted linker has no
/// virtual-store slots. Hoisted packages are probed at their recorded
/// placements, then importer links are checked for both layouts.
pub(crate) fn frozen_tree_intact(
wanted: &Lockfile,
modules: &pnpm_modules_yaml::ModulesLayout,
config: &Config,
workspace_root: &Path,
node_linker: NodeLinker,
) -> bool {
if matches!(node_linker, NodeLinker::Pnp) && !workspace_root.join(crate::PNP_FILENAME).is_file()
{
return false;
}
let skipped = crate::SkippedSnapshots::from_strings(&modules.skipped);
if node_linker == NodeLinker::Hoisted
&& !hoisted_packages_present(wanted, config, workspace_root, &skipped)
{
return false;
}
let probe_slots =
!matches!(node_linker, NodeLinker::Hoisted) && !config.enable_global_virtual_store;
if probe_slots
&& let Some(snapshots) = wanted.snapshots.as_ref()
&& !all_virtual_store_slots_present(snapshots, config, &skipped)
{
return false;
}
if !config.symlink {
return probe_slots;
}
importer_symlinks_intact(wanted, modules, config, workspace_root, node_linker, &skipped)
}
/// Every required hoisted placement must survive, so a missing nested
/// version cannot silently resolve to a different version at an ancestor.
fn hoisted_packages_present(
wanted: &Lockfile,
config: &Config,
workspace_root: &Path,
skipped: &crate::SkippedSnapshots,
) -> bool {
let Some(snapshots) = wanted.snapshots.as_ref() else { return true };
if snapshots
.keys()
.all(|key| skipped.contains(key))
{
return true;
}
let Ok(Some(modules)) =
pnpm_modules_yaml::read_modules_manifest::<super::Host>(&config.modules_dir)
else {
return false;
};
let Some(locations) = modules.hoisted_locations else { return false };
let Some(locations) = hoisted_locations_by_identity(&locations) else { return false };
let Ok(root) = std::fs::canonicalize(workspace_root) else { return false };
snapshots
.keys()
.filter(|key| !skipped.contains(key))
.all(|key| {
locations
.get(&hoisted_package_identity(key))
.is_some_and(|dirs| {
!dirs.is_empty() && dirs.iter().all(|dir| hoisted_location_present(&root, dir))
})
})
}
fn hoisted_locations_by_identity(
locations: &BTreeMap<String, Vec<String>>,
) -> Option<BTreeMap<String, Vec<&str>>> {
let mut indexed: BTreeMap<String, Vec<&str>> = BTreeMap::new();
for (reference, dirs) in locations {
let key = reference.parse::<PackageKey>().ok()?;
indexed
.entry(hoisted_package_identity(&key))
.or_default()
.extend(dirs.iter().map(String::as_str));
}
Some(indexed)
}
/// Hoisting collapses registry peer variants, but injected directory variants
/// keep separate copies. A different patch must still require materialization.
fn hoisted_package_identity(key: &PackageKey) -> String {
let reference = key.to_string();
if pnpm_real_hoist::pkg_id(key) == reference {
reference
} else {
pnpm_deps_path::get_pkg_id_with_patch_hash(&reference).to_string()
}
}
fn hoisted_location_present(root: &Path, location: &str) -> bool {
std::fs::canonicalize(root.join(location))
.is_ok_and(|package_dir| {
package_dir.starts_with(root) && package_dir != root && package_dir.is_dir()
})
}
/// Whether every snapshot the lockfile records still has its virtual-store
/// slot on disk.
fn all_virtual_store_slots_present(
snapshots: &std::collections::HashMap<pnpm_lockfile::PackageKey, pnpm_lockfile::SnapshotEntry>,
config: &Config,
skipped: &crate::SkippedSnapshots,
) -> bool {
let layout = crate::VirtualStoreLayout::legacy(
config.virtual_store_dir.clone(),
config.virtual_store_dir_max_length as usize,
);
snapshots
.keys()
.all(|key| {
if skipped.contains(key) {
return true;
}
// The name is lockfile-controlled: join it with the same
// traversal-rejecting helper the linkers use, and treat a
// malformed name as not-intact so the full path's
// structural lockfile gate rejects it.
let slot_node_modules = layout.slot_dir(key).join("node_modules");
match crate::safe_join_modules_dir::safe_join_modules_dir(
&slot_node_modules,
&key.name.to_string(),
) {
Ok(dir) => dir.is_dir(),
Err(_) => false,
}
})
}
/// Whether every importer's direct dependencies resolve from its own
/// `node_modules`, or from an ancestor inside a hoisted workspace.
fn importer_symlinks_intact(
wanted: &Lockfile,
modules: &pnpm_modules_yaml::ModulesLayout,
config: &Config,
workspace_root: &Path,
node_linker: NodeLinker,
skipped: &crate::SkippedSnapshots,
) -> bool {
let groups = crate::prune_direct_deps::selected_groups(modules.included);
let modules_dir_name: &std::ffi::OsStr =
config.modules_dir.file_name().unwrap_or_else(|| std::ffi::OsStr::new("node_modules"));
wanted.importers
.iter()
.all(|(importer_id, snapshot)| {
if crate::symlink_direct_dependencies::validate_importer_id(importer_id).is_err() {
return false;
}
let importer_root =
crate::symlink_direct_dependencies::importer_root_dir(workspace_root, importer_id);
crate::symlink_direct_dependencies::direct_dep_names_for_importer(
snapshot,
groups.iter().copied(),
skipped,
false,
)
.iter()
.all(|name| {
importer_root
.ancestors()
.take_while(|dir| {
dir.starts_with(workspace_root)
&& (node_linker == NodeLinker::Hoisted || *dir == importer_root)
})
.any(|dir| direct_dep_link_resolves(&dir.join(modules_dir_name), name))
})
})
}
fn direct_dep_link_resolves(modules_dir: &Path, name: &str) -> bool {
match crate::safe_join_modules_dir::safe_join_modules_dir(modules_dir, name) {
// `metadata` follows the link, so a dangling direct-dep
// symlink (a wiped GVS store, a hand-deleted target)
// reads as broken and falls through to the repairing
// full path.
Ok(link) => std::fs::metadata(link).is_ok(),
// A malformed alias never probes the disk; the full
// path rejects it with its own typed error.
Err(_) => false,
}
}
@@ -0,0 +1,296 @@
use super::frozen_tree_intact;
use pnpm_config::{Config, NodeLinker};
use pnpm_lockfile::{Lockfile, PackageKey, ProjectSnapshot, SnapshotEntry};
use pnpm_modules_yaml::{
Host, IncludedDependencies, Modules, ModulesLayout, write_modules_manifest,
};
use std::{fs, path::Path};
use tempfile::tempdir;
use text_block_macros::text_block;
mod peer_variants;
fn shared_workspace_tree_intact(root: &Path, node_linker: NodeLinker) -> bool {
tree_intact(root, node_linker, &shared_workspace_lockfile())
}
fn shared_workspace_lockfile() -> Lockfile {
serde_saphyr::from_str(text_block! {
"lockfileVersion: '9.0'"
"importers:"
" packages/a:"
" dependencies:"
" foo:"
" specifier: 1.0.0"
" version: 1.0.0"
" packages/b:"
" dependencies:"
" foo:"
" specifier: 1.0.0"
" version: 1.0.0"
"snapshots:"
" foo@1.0.0: {}"
})
.expect("parse the two sibling importers")
}
fn tree_intact(root: &Path, node_linker: NodeLinker, lockfile: &Lockfile) -> bool {
let mut config = Config::new();
config.modules_dir = root.join("node_modules");
config.virtual_store_dir = config.modules_dir.join(".pnpm");
tree_intact_with_config(root, node_linker, lockfile, &config)
}
fn tree_intact_with_config(
root: &Path,
node_linker: NodeLinker,
lockfile: &Lockfile,
config: &Config,
) -> bool {
let modules = ModulesLayout {
included: IncludedDependencies {
dependencies: true,
dev_dependencies: false,
optional_dependencies: false,
},
..ModulesLayout::default()
};
frozen_tree_intact(lockfile, &modules, config, root, node_linker)
}
fn record_hoisted_locations(root: &Path, locations: &[(&str, &[&str])]) {
let modules = Modules {
hoisted_locations: Some(
locations
.iter()
.map(|(key, dirs)| {
(
key.to_string(),
dirs.iter()
.map(ToString::to_string)
.collect(),
)
})
.collect(),
),
..Modules::default()
};
write_modules_manifest::<Host>(&root.join("node_modules"), modules).unwrap();
}
#[test]
fn hoisted_importers_resolve_a_shared_dependency_at_the_workspace_root() {
let dir = tempdir().unwrap();
let root = dir.path().join("workspace");
fs::create_dir_all(root.join("node_modules/foo")).unwrap();
record_hoisted_locations(&root, &[("foo@1.0.0", &["node_modules/foo"])]);
assert!(shared_workspace_tree_intact(&root, NodeLinker::Hoisted));
fs::remove_dir(root.join("node_modules/foo")).unwrap();
assert!(!shared_workspace_tree_intact(&root, NodeLinker::Hoisted));
}
#[test]
fn hoisted_importers_do_not_resolve_a_missing_dependency_outside_the_workspace() {
let dir = tempdir().unwrap();
let root = dir.path().join("workspace");
fs::create_dir_all(root.join("node_modules")).unwrap();
fs::create_dir_all(dir.path().join("node_modules/foo")).unwrap();
record_hoisted_locations(&root, &[("foo@1.0.0", &["../node_modules/foo"])]);
assert!(!shared_workspace_tree_intact(&root, NodeLinker::Hoisted));
}
#[test]
fn hoisted_importers_do_not_resolve_a_dependency_from_a_sibling() {
let dir = tempdir().unwrap();
let root = dir.path().join("workspace");
fs::create_dir_all(root.join("packages/a/node_modules/foo")).unwrap();
record_hoisted_locations(&root, &[("foo@1.0.0", &["packages/a/node_modules/foo"])]);
assert!(!shared_workspace_tree_intact(&root, NodeLinker::Hoisted));
}
#[test]
fn isolated_importers_still_require_their_own_dependency_links() {
let dir = tempdir().unwrap();
let root = dir.path().join("workspace");
fs::create_dir_all(root.join("node_modules/foo")).unwrap();
fs::create_dir_all(root.join("node_modules/.pnpm/foo@1.0.0/node_modules/foo")).unwrap();
assert!(!shared_workspace_tree_intact(&root, NodeLinker::Isolated));
for project in ["a", "b"] {
fs::create_dir_all(
root.join("packages")
.join(project)
.join("node_modules/foo"),
)
.unwrap();
}
assert!(shared_workspace_tree_intact(&root, NodeLinker::Isolated));
}
#[test]
fn a_missing_nested_hoisted_version_cannot_fall_back_to_another_version() {
let dir = tempdir().unwrap();
let root = dir.path().join("workspace");
let nested = root.join("packages/b/node_modules/foo");
fs::create_dir_all(root.join("node_modules/foo")).unwrap();
fs::create_dir_all(&nested).unwrap();
record_hoisted_locations(
&root,
&[("foo@1.0.0", &["node_modules/foo"]), ("foo@2.0.0", &["packages/b/node_modules/foo"])],
);
let mut lockfile = shared_workspace_lockfile();
lockfile.importers
.get_mut("packages/b")
.unwrap()
.dependencies
.as_mut()
.unwrap()
.get_mut(&"foo".parse().unwrap())
.unwrap()
.version = serde_saphyr::from_str("2.0.0").unwrap();
lockfile.snapshots
.as_mut()
.unwrap()
.insert("foo@2.0.0".parse().unwrap(), SnapshotEntry::default());
assert!(tree_intact(&root, NodeLinker::Hoisted, &lockfile));
fs::remove_dir(nested).unwrap();
assert!(!tree_intact(&root, NodeLinker::Hoisted, &lockfile));
assert!(
shared_workspace_tree_intact(&root, NodeLinker::Hoisted),
"a placement outside the current graph does not require materialization",
);
}
#[test]
fn hoisted_dependencies_require_recorded_placements() {
let dir = tempdir().unwrap();
let root = dir.path().join("workspace");
fs::create_dir_all(root.join("node_modules/foo")).unwrap();
assert!(!shared_workspace_tree_intact(&root, NodeLinker::Hoisted));
record_hoisted_locations(&root, &[]);
assert!(!shared_workspace_tree_intact(&root, NodeLinker::Hoisted));
record_hoisted_locations(&root, &[("foo@1.0.0", &[])]);
assert!(!shared_workspace_tree_intact(&root, NodeLinker::Hoisted));
record_hoisted_locations(&root, &[("not-a-dep-path", &["node_modules/foo"])]);
assert!(!shared_workspace_tree_intact(&root, NodeLinker::Hoisted));
}
#[test]
fn skipped_hoisted_packages_do_not_require_placements() {
let dir = tempdir().unwrap();
let mut config = Config::new();
config.modules_dir = dir.path().join("node_modules");
let modules = ModulesLayout {
included: IncludedDependencies {
dependencies: true,
dev_dependencies: false,
optional_dependencies: false,
},
skipped: vec!["foo@1.0.0".to_string()],
..ModulesLayout::default()
};
assert!(frozen_tree_intact(
&shared_workspace_lockfile(),
&modules,
&config,
dir.path(),
NodeLinker::Hoisted
));
}
#[test]
fn hoisted_placements_cannot_resolve_to_an_external_package() {
let dir = tempdir().unwrap();
let root = dir.path().join("workspace");
let outside = dir.path().join("outside");
fs::create_dir_all(&outside).unwrap();
record_hoisted_locations(&root, &[("foo@1.0.0", &["node_modules/foo"])]);
pnpm_fs::symlink_dir(&outside, &root.join("node_modules/foo")).unwrap();
assert!(!shared_workspace_tree_intact(&root, NodeLinker::Hoisted));
}
#[test]
fn malformed_importer_paths_cannot_short_circuit_materialization() {
let dir = tempdir().unwrap();
for importer in ["../outside", "/absolute", "packages/../../outside"] {
let mut lockfile = shared_workspace_lockfile();
lockfile.importers.clear();
lockfile.importers.insert(importer.to_string(), ProjectSnapshot::default());
lockfile.snapshots = None;
assert!(!tree_intact(dir.path(), NodeLinker::Isolated, &lockfile), "{importer}");
}
}
#[test]
fn malformed_dependency_names_cannot_short_circuit_materialization() {
let dir = tempdir().unwrap();
for name in ["../outside", "@scope/../../outside", "/absolute"] {
let mut lockfile = shared_workspace_lockfile();
lockfile.importers.retain(|id, _| id == "packages/a");
let dependencies = lockfile.importers
.get_mut("packages/a")
.unwrap()
.dependencies
.as_mut()
.unwrap();
let dependency = dependencies
.remove(&"foo".parse().unwrap())
.unwrap();
dependencies.insert(name.parse().unwrap(), dependency);
lockfile.snapshots = None;
assert!(!tree_intact(dir.path(), NodeLinker::Isolated, &lockfile), "{name}");
}
}
#[test]
fn symlink_disabled_install_requires_a_complete_local_virtual_store() {
let dir = tempdir().unwrap();
let root = dir.path();
let mut config = Config::new();
config.modules_dir = root.join("node_modules");
config.virtual_store_dir = config.modules_dir.join(".pnpm");
config.symlink = false;
let package_dir = config.virtual_store_dir.join("foo@1.0.0/node_modules/foo");
fs::create_dir_all(&package_dir).unwrap();
let lockfile = shared_workspace_lockfile();
let intact =
|config: &Config| tree_intact_with_config(root, NodeLinker::Isolated, &lockfile, config);
assert!(intact(&config), "symlink:false does not require importer links");
config.symlink = true;
assert!(!intact(&config), "ordinary installs still require importer links");
config.symlink = false;
config.enable_global_virtual_store = true;
assert!(!intact(&config), "local slots cannot prove the global store is intact");
config.enable_global_virtual_store = false;
fs::remove_dir(package_dir).unwrap();
assert!(!intact(&config), "symlink:false must still repair a missing package");
}
#[test]
fn malformed_snapshot_names_cannot_use_directories_outside_their_slot_modules() {
let dir = tempdir().unwrap();
let root = dir.path();
let layout = crate::VirtualStoreLayout::legacy(root.join("node_modules/.pnpm"), 120);
for name in ["../outside", "@scope/../../outside"] {
let key: PackageKey = format!("{name}@1.0.0").parse().unwrap();
let slot_modules = layout.slot_dir(&key).join("node_modules");
let unchecked_dir = slot_modules.join(name);
fs::create_dir_all(&unchecked_dir).unwrap();
assert!(unchecked_dir.is_dir());
let mut lockfile = shared_workspace_lockfile();
lockfile.importers.clear();
let snapshots = lockfile.snapshots.as_mut().unwrap();
snapshots.clear();
snapshots.insert(key, SnapshotEntry::default());
assert!(!tree_intact(root, NodeLinker::Isolated, &lockfile), "{name}");
}
}
@@ -0,0 +1,84 @@
use super::{record_hoisted_locations, tree_intact};
use pnpm_config::NodeLinker;
use pnpm_deps_restorer::hoisted_dep_graph::{
LockfileToHoistedDepGraphOptions, lockfile_to_hoisted_dep_graph,
};
use pnpm_lockfile::Lockfile;
use pnpm_modules_yaml::{Host, Modules, write_modules_manifest};
use std::fs;
use tempfile::tempdir;
use text_block_macros::text_block;
fn lockfile_with_peer_variants() -> Lockfile {
serde_saphyr::from_str(text_block! {
"lockfileVersion: '9.0'"
"importers:"
" packages/a:"
" dependencies:"
" foo: {specifier: 1.0.0, version: 1.0.0(peer@1.0.0)}"
" peer: {specifier: 1.0.0, version: 1.0.0}"
" packages/b:"
" dependencies:"
" foo: {specifier: 1.0.0, version: 1.0.0(peer@2.0.0)}"
" peer: {specifier: 2.0.0, version: 2.0.0}"
"packages:"
" foo@1.0.0: {resolution: {integrity: sha512-AA==}}"
" peer@1.0.0: {resolution: {integrity: sha512-AA==}}"
" peer@2.0.0: {resolution: {integrity: sha512-AA==}}"
"snapshots:"
" foo@1.0.0(peer@1.0.0):"
" dependencies: {peer: 1.0.0}"
" foo@1.0.0(peer@2.0.0):"
" dependencies: {peer: 2.0.0}"
" peer@1.0.0: {}"
" peer@2.0.0: {}"
})
.unwrap()
}
#[test]
fn collapsed_peer_variants_reuse_the_hoisters_recorded_placements() {
let dir = tempdir().unwrap();
let lockfile = lockfile_with_peer_variants();
let options = LockfileToHoistedDepGraphOptions {
lockfile_dir: dir.path().to_path_buf(),
..LockfileToHoistedDepGraphOptions::default()
};
let hoisted = lockfile_to_hoisted_dep_graph(&lockfile, None, &options).unwrap();
let foo_locations: Vec<_> = hoisted.hoisted_locations
.iter()
.filter(|(key, _)| key.starts_with("foo@"))
.collect();
assert_eq!(foo_locations.len(), 1, "the hoister records one canonical peer variant");
let foo_dir = dir.path().join(&foo_locations[0].1[0]);
for package_dir in hoisted.graph.keys() {
fs::create_dir_all(package_dir).unwrap();
}
write_modules_manifest::<Host>(
&dir.path().join("node_modules"),
Modules { hoisted_locations: Some(hoisted.hoisted_locations), ..Modules::default() },
)
.unwrap();
assert!(tree_intact(dir.path(), NodeLinker::Hoisted, &lockfile));
fs::remove_dir_all(foo_dir).unwrap();
assert!(!tree_intact(dir.path(), NodeLinker::Hoisted, &lockfile));
}
#[test]
fn injected_peer_variants_and_different_patches_need_their_own_placements() {
for (required, recorded) in [
("foo@file:foo(peer@1.0.0)", "foo@file:foo(peer@2.0.0)"),
("foo@1.0.0(patch_hash=aaa)(peer@1.0.0)", "foo@1.0.0(patch_hash=bbb)(peer@2.0.0)"),
("foo@2.0.0(peer@1.0.0)", "foo@1.0.0(peer@2.0.0)"),
] {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("node_modules/foo")).unwrap();
record_hoisted_locations(dir.path(), &[(recorded, &["node_modules/foo"])]);
let lockfile: Lockfile = serde_saphyr::from_str(&format!(
"lockfileVersion: '9.0'\nimporters: {{}}\nsnapshots:\n {required}: {{}}\n",
))
.unwrap();
assert!(!tree_intact(dir.path(), NodeLinker::Hoisted, &lockfile), "{required}");
}
}
@@ -8,9 +8,13 @@ use purge::{
};
use super::{
Config, Host, InstallError, Lockfile, Modules, NodeLinker, Reporter,
modules_layout_consistent_with,
Config, Host, InstallError, Lockfile, Modules, NodeLinker, PackageManifest, PathBuf, Reporter,
WorkspaceState, modules_layout_consistent_with, tree_may_move,
};
use crate::{
install::state_options::RecordedWorkspace, optimistic_repeat_install::recorded_elsewhere,
};
use pnpm_workspace_state::load_workspace_state;
pub(super) struct PrepareModulesStateInputs<'a, 'install> {
pub(crate) tree: crate::install::state_options::ModulesTreeContext<'a>,
@@ -30,6 +34,7 @@ pub(super) struct PreparedModulesState<'install> {
pub(super) is_inconsistent: bool,
pub(super) lockfile_verification_override:
Option<super::LockfileVerificationOverride<'install>>,
pub(super) tree_moved: bool,
}
pub(super) fn prior_hoisted_dependencies(
@@ -62,7 +67,15 @@ pub(super) async fn prepare_modules_state<'install, Reporter: self::Reporter + '
prepare_modules_layout(&inputs, modules_manifest, is_inconsistent)?;
let up_to_date = frozen_tree_inputs(&inputs, modules_manifest);
let recorded_state = load_workspace_state(inputs.tree.workspace_root).ok().flatten();
let recorded = recorded_workspace(
recorded_state.as_ref(),
modules_manifest.is_some() || inputs.lockfiles.current.is_some(),
inputs.tree.config,
inputs.tree.node_linker,
inputs.projects.manifests,
);
let up_to_date = frozen_tree_inputs(&inputs, modules_manifest, recorded);
if let Some((wanted_lockfile, modules)) = frozen_tree_up_to_date(&up_to_date) {
report_prepared_up_to_date::<Reporter>(inputs, wanted_lockfile, modules).await?;
return Ok(None);
@@ -73,9 +86,29 @@ pub(super) async fn prepare_modules_state<'install, Reporter: self::Reporter + '
previous_modules_metadata,
is_inconsistent,
lockfile_verification_override: inputs.verification.override_check,
tree_moved: recorded.moved,
}))
}
/// The workspace state, read once for the recorded `supportedArchitectures`
/// and to tell a tree that moved with its project. An existing tree without
/// readable state may have moved too. Where a moved tree is never reused
/// ([`tree_may_move`]), one is not told from a tree in place.
fn recorded_workspace<'a>(
state: Option<&'a WorkspaceState>,
existing_tree: bool,
config: &Config,
node_linker: NodeLinker,
projects: &'a [(PathBuf, &'a PackageManifest)],
) -> RecordedWorkspace<'a> {
RecordedWorkspace {
state,
moved: tree_may_move(config, node_linker)
&& state.map_or(existing_tree, |state| recorded_elsewhere(state, projects)),
projects,
}
}
fn prepare_modules_layout(
inputs: &PrepareModulesStateInputs<'_, '_>,
modules_manifest: Option<&pnpm_modules_yaml::ModulesLayout>,
@@ -149,6 +182,7 @@ async fn report_prepared_up_to_date<Reporter: self::Reporter + 'static>(
fn frozen_tree_inputs<'a>(
inputs: &PrepareModulesStateInputs<'a, '_>,
modules_manifest: Option<&'a pnpm_modules_yaml::ModulesLayout>,
recorded: RecordedWorkspace<'a>,
) -> FrozenTreeUpToDate<'a> {
FrozenTreeUpToDate {
tree: crate::install::state_options::ModulesTreeContext {
@@ -169,6 +203,7 @@ fn frozen_tree_inputs<'a>(
lockfile: inputs.lockfiles.wanted,
current_lockfile: inputs.lockfiles.current,
modules_manifest,
recorded,
}
}
@@ -1,10 +1,20 @@
use std::{fs, path::Path};
use std::{collections::BTreeMap, fs, path::Path, time::SystemTime};
use pnpm_config::Config;
use pnpm_config::{Config, NodeLinker};
use pnpm_lockfile::Lockfile;
use pnpm_modules_yaml::{IncludedDependencies, LayoutVersion, ModulesLayout};
use pnpm_package_manifest::PackageManifest;
use pnpm_workspace_state::{ProjectEntry, WorkspaceState, load_workspace_state};
use tempfile::tempdir;
use crate::install::prepare_modules_state::purge::{
is_safe_modules_purge_target, purge_modules_dir_entries,
use crate::install::{
moved_tree_is_reusable,
prepare_modules_state::{
purge::{is_safe_modules_purge_target, purge_modules_dir_entries},
recorded_workspace,
up_to_date::{FrozenTreeUpToDate, frozen_tree_up_to_date},
},
state_options::{ModulesTreeContext, RecordedWorkspace, RepeatInstallPolicy},
};
#[test]
@@ -41,3 +51,311 @@ fn purge_removes_directory_links_without_following_them() {
assert!(remaining.is_empty());
assert!(link_target.join("package.json").exists(), "the purge must not follow the link");
}
/// A shim target no move can keep working, so a tree holding one is refused.
const ABSOLUTE_TARGET: &str = "/elsewhere/project/node_modules/typescript/bin/tsc";
/// A shim target the tree carries with it.
#[cfg(unix)]
const RELATIVE_TARGET: &str = "../typescript/bin/tsc";
/// The `.bin` dirs a moved tree may hold a stale bin in, named from the
/// project root: the root importer's, [`PROJECT_DIR`]'s, and the hoist one.
const ROOT_BIN: &str = "node_modules/.bin";
const PROJECT_BIN: &str = "packages/a/node_modules/.bin";
const HOIST_BIN: &str = "node_modules/.pnpm/node_modules/.bin";
/// The `.bin` the injected-deps syncer writes next to a slot's package, in the
/// slot [`TWO_SLOTS`] records first.
const SLOT_PACKAGE_BIN: &str = "node_modules/.pnpm/a@1.0.0/node_modules/a/node_modules/.bin";
/// The `.bin` the hoisted linker writes inside a package it placed, and the
/// `hoistedLocations` entry [`short_circuits_over_a_bin`] records it at.
const NESTED_BIN: &str = "node_modules/nested/node_modules/.bin";
const HOISTED_LOCATION: &str = "node_modules/nested";
/// The workspace project [`short_circuits_over_a_bin`] records besides the
/// root.
const PROJECT_DIR: &str = "packages/a";
/// A lockfile with no slot to scan, and one whose second slot is clean, so a
/// scan one slot satisfies accepts a tree the other refuses.
const NO_SLOTS: &str = "lockfileVersion: '9.0'\nimporters: {}\n";
const TWO_SLOTS: &str =
"lockfileVersion: '9.0'\nimporters: {}\nsnapshots:\n a@1.0.0: {}\n b@1.0.0: {}\n";
/// The `hoistedLocations` of a package the hoisted linker placed outside the
/// tree, and a record no install can have written.
const HOISTED_OUTSIDE: &str = "hoistedLocations:\n a@1.0.0:\n - ../outside\n";
const UNREADABLE_RECORD: &str = "hoistedLocations: [";
fn parse_lockfile(yaml: &str) -> Lockfile {
serde_saphyr::from_str(yaml).expect("parse lockfile")
}
/// Whether the frozen short-circuit fires over an up-to-date `node_linker`
/// tree whose `bin_dir`, named from the project root, holds a shim naming
/// `target`.
fn short_circuits_over_a_bin(
node_linker: NodeLinker,
tree_moved: bool,
bin_dir: &str,
target: &str,
state: Option<&WorkspaceState>,
) -> bool {
let dir = tempdir().expect("create a temp dir");
let project_root = dir.path().join("project");
let mut config = Config::new();
config.store_dir = dir.path().join("store").into();
config.modules_dir = project_root.join("node_modules");
config.virtual_store_dir = config.modules_dir.join(".pnpm");
let config = config.leak();
let bin_dir = project_root.join(bin_dir);
fs::create_dir_all(&bin_dir).expect("create the bin dir");
fs::write(bin_dir.join("tsc"), format!("#!/bin/sh\n# cmd-shim-target={target}\n"))
.expect("write the shim");
fs::create_dir_all(&config.modules_dir).expect("create the modules dir");
fs::write(
config.modules_dir.join(pnpm_modules_yaml::MODULES_FILENAME),
format!("hoistedLocations:\n nested@1.0.0:\n - {HOISTED_LOCATION}\n"),
)
.expect("write the modules manifest");
let lockfile = parse_lockfile(NO_SLOTS);
let included = IncludedDependencies {
dependencies: true,
dev_dependencies: true,
optional_dependencies: true,
};
let modules = ModulesLayout {
hoist_pattern: config.hoist_pattern.clone(),
included,
layout_version: Some(LayoutVersion),
node_linker: Some(match node_linker {
NodeLinker::Hoisted => pnpm_modules_yaml::NodeLinker::Hoisted,
_ => pnpm_modules_yaml::NodeLinker::Isolated,
}),
pruned_at: httpdate::fmt_http_date(SystemTime::now()),
public_hoist_pattern: config.public_hoist_pattern.clone(),
store_dir: config.store_dir.display().to_string(),
virtual_store_dir: config
.effective_virtual_store_dir()
.to_string_lossy()
.into_owned(),
virtual_store_dir_max_length: config.virtual_store_dir_max_length,
..Default::default()
};
let manifest =
PackageManifest::from_value(project_root.join("package.json"), serde_json::json!({}));
let projects = [(project_root.clone(), &manifest), (project_root.join(PROJECT_DIR), &manifest)];
frozen_tree_up_to_date(&FrozenTreeUpToDate {
tree: ModulesTreeContext { config, workspace_root: &project_root, node_linker, included },
repeat: RepeatInstallPolicy {
frozen: true,
filtered: false,
disable_optimistic_check: false,
supported_architectures: None,
rebuild: None,
effective_node_version: None,
},
lockfile: Some(&lockfile),
current_lockfile: Some(&lockfile),
modules_manifest: Some(&modules),
recorded: RecordedWorkspace { state, moved: tree_moved, projects: &projects },
})
.is_some()
}
/// Every `.bin` a moved tree may hold a stale bin in is checked, under the
/// linker that writes it.
#[test]
fn a_moved_tree_with_an_absolute_bin_is_refused() {
let cases = [
(NodeLinker::Isolated, ROOT_BIN),
(NodeLinker::Isolated, PROJECT_BIN),
(NodeLinker::Isolated, HOIST_BIN),
(NodeLinker::Hoisted, NESTED_BIN),
];
for (node_linker, bin_dir) in cases {
assert!(
!short_circuits_over_a_bin(
node_linker,
true,
bin_dir,
ABSOLUTE_TARGET,
Some(&WorkspaceState::default()),
),
"{node_linker:?} {bin_dir}",
);
}
}
/// A tree in place keeps its short-circuit whatever its bins name.
#[test]
fn frozen_short_circuit_unchanged_in_place() {
assert!(short_circuits_over_a_bin(
NodeLinker::Isolated,
false,
ROOT_BIN,
ABSOLUTE_TARGET,
Some(&WorkspaceState::default()),
));
}
#[test]
fn frozen_short_circuit_requires_recorded_workspace_state() {
for node_linker in [NodeLinker::Isolated, NodeLinker::Hoisted] {
assert!(
!short_circuits_over_a_bin(node_linker, false, ROOT_BIN, ABSOLUTE_TARGET, None),
"{node_linker:?}: without state the absolute shim's original root is unknown",
);
}
}
/// A moved tree whose bins name their targets relative to themselves is
/// reused, under the hoisted linker as under the isolated one.
#[cfg(unix)]
#[test]
fn a_moved_tree_with_relative_bins_is_reused() {
for node_linker in [NodeLinker::Isolated, NodeLinker::Hoisted] {
assert!(
short_circuits_over_a_bin(
node_linker,
true,
ROOT_BIN,
RELATIVE_TARGET,
Some(&WorkspaceState::default()),
),
"{node_linker:?}",
);
}
}
/// Whether the move proof accepts a [`TWO_SLOTS`] tree under `node_linker`
/// whose `.modules.yaml` holds `modules_yaml`, with a shim naming
/// [`ABSOLUTE_TARGET`] in `absolute_bin`, named from the project root, when
/// there is one.
fn moved_tree_is_reusable_over(
node_linker: NodeLinker,
modules_yaml: &str,
absolute_bin: Option<&str>,
) -> bool {
let dir = tempdir().expect("create a temp dir");
let project_root = dir.path().join("project");
let mut config = Config::new();
config.modules_dir = project_root.join("node_modules");
config.virtual_store_dir = config.modules_dir.join(".pnpm");
fs::create_dir_all(&config.modules_dir).expect("create the modules dir");
fs::write(config.modules_dir.join(pnpm_modules_yaml::MODULES_FILENAME), modules_yaml)
.expect("write the modules manifest");
if let Some(absolute_bin) = absolute_bin {
let bin_dir = project_root.join(absolute_bin);
fs::create_dir_all(&bin_dir).expect("create the bin dir");
fs::write(bin_dir.join("tsc"), format!("#!/bin/sh\n# cmd-shim-target={ABSOLUTE_TARGET}\n"))
.expect("write the shim");
}
let manifest =
PackageManifest::from_value(project_root.join("package.json"), serde_json::json!({}));
moved_tree_is_reusable(
&config,
node_linker,
&[(project_root, &manifest)],
&parse_lockfile(TWO_SLOTS),
)
}
/// Every `.bin` the proof must scan is scanned, each slot the lockfile records
/// included, and a `hoistedLocations` record it cannot trust refuses the move.
#[test]
fn a_moved_tree_is_refused_unless_every_bin_it_must_scan_is_relative() {
assert_eq!(
moved_tree_is_reusable_over(NodeLinker::Isolated, "", None),
cfg!(unix),
"a tree with no bin to refuse is reusable wherever a moved tree may be",
);
let cases = [
("the bin beside a slot's package", NodeLinker::Isolated, "", Some(SLOT_PACKAGE_BIN)),
("a package hoisted outside the tree", NodeLinker::Hoisted, HOISTED_OUTSIDE, None),
("an unreadable hoist record", NodeLinker::Hoisted, UNREADABLE_RECORD, None),
];
for (label, node_linker, modules_yaml, absolute_bin) in cases {
assert!(!moved_tree_is_reusable_over(node_linker, modules_yaml, absolute_bin), "{label}");
}
}
/// The move signal is gated as the move proof is: where a moved tree is
/// never reused, a tree recorded elsewhere takes the path of one in place.
/// A workspace keeping one recorded project in place is not elsewhere.
#[test]
fn a_tree_recorded_elsewhere_is_moved_only_where_a_moved_tree_may_be_reused() {
let project_root = Path::new("/here/project");
let manifest =
PackageManifest::from_value(project_root.join("package.json"), serde_json::json!({}));
let projects = [(project_root.to_path_buf(), &manifest)];
let state = WorkspaceState {
projects: BTreeMap::from([("/elsewhere/project".to_string(), ProjectEntry::default())]),
..WorkspaceState::default()
};
let mut config = Config::new();
config.modules_dir = project_root.join("node_modules");
let moved =
recorded_workspace(Some(&state), true, &config, NodeLinker::Isolated, &projects).moved;
assert_eq!(moved, cfg!(unix));
let in_place = WorkspaceState {
projects: BTreeMap::from([(
project_root.to_string_lossy().into_owned(),
ProjectEntry::default(),
)]),
..WorkspaceState::default()
};
let gained = [projects[0].clone(), (project_root.join("packages/b"), &manifest)];
let moved =
recorded_workspace(Some(&in_place), true, &config, NodeLinker::Isolated, &gained).moved;
assert!(!moved, "a workspace that gained a project in place is recorded here");
let lockfile = parse_lockfile(NO_SLOTS);
config.enable_global_virtual_store = true;
let moved =
recorded_workspace(Some(&state), true, &config, NodeLinker::Isolated, &projects).moved;
assert!(!moved, "a global virtual store never reuses a moved tree");
assert!(
!moved_tree_is_reusable(&config, NodeLinker::Isolated, &projects, &lockfile),
"nor does its proof of a move",
);
config.enable_global_virtual_store = false;
let moved = recorded_workspace(Some(&state), true, &config, NodeLinker::Pnp, &projects).moved;
assert!(!moved, "pnp never reuses a moved tree");
assert!(
!moved_tree_is_reusable(&config, NodeLinker::Pnp, &projects, &lockfile),
"nor does its proof of a move",
);
}
#[test]
fn missing_or_corrupt_state_requires_relinking_only_for_an_existing_tree() {
for state_contents in [None, Some("{")] {
let dir = tempdir().unwrap();
let root = dir.path();
let mut config = Config::new();
config.modules_dir = root.join("node_modules");
fs::create_dir_all(&config.modules_dir).unwrap();
if let Some(contents) = state_contents {
fs::write(pnpm_workspace_state::get_file_path(root), contents).unwrap();
}
let manifest =
PackageManifest::from_value(root.join("package.json"), serde_json::json!({}));
let projects = [(root.to_path_buf(), &manifest)];
let state = load_workspace_state(root).ok().flatten();
assert!(state.is_none(), "the fixture must not provide a readable state");
let existing =
recorded_workspace(state.as_ref(), true, &config, NodeLinker::Isolated, &projects);
assert_eq!(existing.moved, cfg!(unix), "the old bins may name another root");
let fresh =
recorded_workspace(state.as_ref(), false, &config, NodeLinker::Isolated, &projects);
assert!(!fresh.moved, "a first filtered install must still write its initial state");
}
}
@@ -3,7 +3,8 @@ use super::super::{
ResolutionVerifier, Stage, StageLog, SummaryLog, SystemTime, build_workspace_state,
frozen_tree_intact, gvs_build_marker_present, has_newly_allowed_ignored_builds,
has_revoked_allowed_builds, map_frozen_lockfile_error, modules_consistent_with,
unapproved_recorded_ignored_builds, update_workspace_state, verify_lockfile_eagerly,
moved_tree_is_reusable, unapproved_recorded_ignored_builds, update_workspace_state,
verify_lockfile_eagerly,
};
use crate::optimistic_repeat_install::{filesystem_now_ms, materialized_shape_matches};
@@ -27,6 +28,7 @@ pub(super) struct FrozenTreeUpToDate<'a> {
pub(super) lockfile: Option<&'a Lockfile>,
pub(super) current_lockfile: Option<&'a Lockfile>,
pub(super) modules_manifest: Option<&'a pnpm_modules_yaml::ModulesLayout>,
pub(crate) recorded: crate::install::state_options::RecordedWorkspace<'a>,
}
/// The lockfile and modules manifest of a tree nothing has to be done to, or
/// `None` when the install has to materialize.
@@ -43,6 +45,7 @@ pub(super) fn frozen_tree_up_to_date<'a>(
{
return None;
}
context.recorded.state?;
let wanted_lockfile = context.lockfile?;
let current = context.current_lockfile?;
// Past this gate `current` is the graph this install would
@@ -68,7 +71,7 @@ pub(super) fn frozen_tree_up_to_date<'a>(
// premise doesn't hold and the platform packages must be re-evaluated.
if !modules_consistent_with(modules, config, context.tree.node_linker, context.tree.included)
|| !crate::optimistic_repeat_install::recorded_supported_architectures_match(
context.tree.workspace_root,
context.recorded.state,
context.repeat.supported_architectures,
)
|| !build_state_unchanged(context, current, modules)
@@ -85,10 +88,21 @@ pub(super) fn frozen_tree_up_to_date<'a>(
config,
context.tree.workspace_root,
context.tree.node_linker,
);
)
&& bins_resolve_where_the_tree_is(context, current);
tree_intact.then_some((wanted_lockfile, modules))
}
fn bins_resolve_where_the_tree_is(context: &FrozenTreeUpToDate<'_>, current: &Lockfile) -> bool {
!context.recorded.moved
|| moved_tree_is_reusable(
context.tree.config,
context.tree.node_linker,
context.recorded.projects,
current,
)
}
/// Whether the builds the tree already ran are still the builds this
/// install would run, which neither the lockfile nor `.modules.yaml`
/// records on its own.
@@ -1,8 +1,8 @@
use super::{
super::{
ApplyMaterializationInputs, Arc, AtomicU8, InstallError, LogEvent, LogLevel,
MaterializationInputs, PackageManifest, PathBuf, Reporter, SummaryLog,
apply_materialization_result, materialize, prior_hoisted_dependencies,
ApplyMaterializationInputs, Arc, AtomicU8, IncludedDependencies, InstallError, LogEvent,
LogLevel, MaterializationInputs, PackageManifest, PathBuf, RebuildOptions, Reporter,
SummaryLog, apply_materialization_result, materialize, prior_hoisted_dependencies,
prior_hoisted_locations,
},
Dispatched, InstallRunOutcome, InstallScope, Loaded, Lockfiles, RunExecution, Settled,
@@ -149,7 +149,6 @@ impl<'a> RunExecution<'a> {
let resolution = self.materialization_resolution(loaded);
let early_host_detection = loaded.early_host_detection.take();
let lockfiles = materialization_lockfiles(loaded, lockfiles, dispatched, verification);
let prior_modules = dispatched.modules.previous_modules_metadata.as_ref();
MaterializationInputs {
install: self.install,
resolution,
@@ -168,15 +167,11 @@ impl<'a> RunExecution<'a> {
),
scope,
),
modules: crate::install::materialize::MaterializationModules {
included: self.mode.included,
rebuild: self.options.rebuild.as_ref(),
modules_manifest: dispatched.modules.old_modules.as_ref(),
prior_hoisted_dependencies: prior_hoisted_dependencies(prior_modules),
prior_hoisted_locations: prior_hoisted_locations(prior_modules),
prune_orphans: !scope.importers.filtered_install,
modules: dispatched.materialization_modules(
(self.mode.included, self.options.rebuild.as_ref()),
!scope.importers.filtered_install,
logged_methods,
},
),
execution: self.mode.materialization_execution(
&self.owned,
&self.options,
@@ -246,6 +241,7 @@ impl<'a> RunExecution<'a> {
layout: dispatched.modules.old_modules,
metadata: dispatched.modules.previous_modules_metadata,
is_inconsistent: dispatched.modules.is_inconsistent,
tree_moved: dispatched.modules.tree_moved,
},
projects: crate::install::state_options::ApplyProjectSelection {
importers: crate::install::state_options::SelectedImporters {
@@ -315,6 +311,27 @@ impl super::InstallWorkspace<'_> {
}
}
impl Dispatched<'_> {
fn materialization_modules<'r>(
&'r self,
(included, rebuild): (IncludedDependencies, Option<&'r RebuildOptions>),
prune_orphans: bool,
logged_methods: &'r AtomicU8,
) -> crate::install::materialize::MaterializationModules<'r> {
let prior_modules = self.modules.previous_modules_metadata.as_ref();
crate::install::materialize::MaterializationModules {
included,
rebuild,
modules_manifest: self.modules.old_modules.as_ref(),
prior_hoisted_dependencies: prior_hoisted_dependencies(prior_modules),
prior_hoisted_locations: prior_hoisted_locations(prior_modules),
prune_orphans,
relink_every_slot_bin: self.modules.tree_moved,
logged_methods,
}
}
}
impl super::RunMode {
fn materialization_execution<'r>(
&mut self,
@@ -1,7 +1,7 @@
use super::{
Arc, Catalogs, Config, HashSet, IncludedDependencies, Lockfile, Modules, NodeLinker,
PackageManifest, Path, PathBuf, ProjectMutation, RebuildOptions, ResolutionVerifier,
WorkspaceInstallSelection,
WorkspaceInstallSelection, WorkspaceState,
};
use pnpm_store_dir::VerifiedFileIntegrity;
@@ -17,6 +17,8 @@ pub(crate) struct ApplyPriorState {
pub(crate) layout: Option<pnpm_modules_yaml::ModulesLayout>,
pub(crate) metadata: Option<Modules>,
pub(crate) is_inconsistent: bool,
/// See [`RecordedWorkspace::moved`].
pub(crate) tree_moved: bool,
}
pub(crate) struct ApplyProjectSelection<'a> {
@@ -167,3 +169,16 @@ pub(crate) struct PruneEligibility {
pub(crate) is_inconsistent: bool,
pub(crate) filtered_install: bool,
}
/// What the last install recorded about the workspace, against the projects
/// the tree holds now.
#[derive(Clone, Copy)]
pub(crate) struct RecordedWorkspace<'a> {
pub(crate) state: Option<&'a WorkspaceState>,
/// A known or potentially moved tree, where moves are supported
/// ([`crate::install::tree_may_move`]). No current project is one `state`
/// records, or an existing tree has no readable state to prove its origin.
/// Its bins may still name where it was.
pub(crate) moved: bool,
pub(crate) projects: &'a [(PathBuf, &'a PackageManifest)],
}
@@ -1,5 +1,5 @@
use super::{
super::{Install, InstallError, ProjectMutation},
super::{Install, InstallError, ProjectMutation, build_workspace_state},
InstallDirs, PARTIAL_INSTALL_LOCKFILE, seed_placeholder_virtual_store_slot,
};
use crate::PolicyExcludes;
@@ -9,7 +9,7 @@ use pnpm_modules_yaml::{Host, LayoutVersion, Modules, NodeLinker, write_modules_
use pnpm_package_manifest::{DependencyGroup, PackageManifest};
use pnpm_reporter::{LogEvent, Reporter, SilentReporter, Stage};
use pnpm_testing_utils::registry::TestRegistry;
use pnpm_workspace_state::load_workspace_state;
use pnpm_workspace_state::{load_workspace_state, update_workspace_state};
use std::{fs, sync::Mutex, time::Duration};
use text_block_macros::text_block;
@@ -746,9 +746,9 @@ async fn no_prefer_frozen_lockfile_flag_forces_fresh_resolve() {
);
}
/// End-to-end: when `.modules.yaml`, `<virtual_store_dir>/lock.yaml`,
/// and the wanted lockfile all agree — and the tree those files
/// describe is still on disk (the short-circuit probes it; a missing
/// entry must fall through to the repairing full path) —
/// and the wanted lockfile agree, workspace state records the current root,
/// and the tree those files describe is still on disk (the short-circuit
/// probes it; a missing entry falls through to the repairing full path),
/// [`Install::run`] must emit the `name: "pnpm"` "Lockfile is up to
/// date" log and return without running materialization (the
/// `allProjectsAreUpToDate` + `validateModules` short-circuit).
@@ -824,6 +824,18 @@ async fn frozen_install_short_circuits_when_modules_and_lockfile_are_consistent(
lockfile
.save_current_to_virtual_store_dir(&dirs.virtual_store_dir)
.expect("seed current lockfile");
let seed_state = build_workspace_state::<Host>(
&dirs.project_root,
config,
pnpm_config::NodeLinker::Isolated,
included,
None,
&Default::default(),
&[(dirs.project_root.clone(), &manifest)],
false,
None,
);
update_workspace_state(&dirs.project_root, &seed_state).expect("seed workspace state");
// Date the manifest inside a millisecond after the lockfile, as a
// sub-millisecond filesystem leaves a manifest edited after the last
// lockfile write: the manifest's truncated mtime alone would read as
@@ -234,6 +234,8 @@ pub(crate) struct FreshPriorInstall<'a> {
pub(crate) unbuilt_builds: &'a crate::UnbuiltBuilds,
/// See [`crate::PruneStaleModules::prune_orphans`].
pub(crate) prune_orphans: bool,
/// See [`pnpm_deps_restorer::PriorMaterialization::relink_every_slot_bin`].
pub(crate) relink_every_slot_bin: bool,
}
#[derive(Clone, Copy)]
@@ -170,6 +170,25 @@ impl<'a> OnDiskInputs<'a> {
Ok(materialized)
}
/// The graphs the link phase reconciles. A tree that moved with its
/// project relinks every slot's bins, not only the materialized ones.
fn link_lockfiles<'b>(
&self,
materialized_snapshots: &'b [pnpm_lockfile::PackageKey],
) -> pnpm_deps_restorer::LinkLockfiles<'b>
where
'a: 'b,
{
pnpm_deps_restorer::LinkLockfiles {
lockfile: self.projects.materialization_lockfile,
current_lockfile: self.install.prior.lockfile,
materialized_snapshots: (!self.install.prior.relink_every_slot_bin).then_some(
materialized_snapshots,
),
sidecar_lockfile: self.projects.materialization_lockfile,
}
}
/// Link the materialized store into every project and report
/// `importing_done`, which reporters use to close the import progress
/// display before the `pnpm:lifecycle` events of the build.
@@ -185,12 +204,7 @@ impl<'a> OnDiskInputs<'a> {
let linked = pnpm_deps_restorer::linking::run_link_phase::<Reporter>(
pnpm_deps_restorer::linking::LinkPhaseInputs {
graph: pnpm_deps_restorer::LinkLockfiles {
lockfile: self.projects.materialization_lockfile,
current_lockfile: self.install.prior.lockfile,
materialized_snapshots: Some(&materialized.materialized_snapshots),
sidecar_lockfile: self.projects.materialization_lockfile,
},
graph: self.link_lockfiles(&materialized.materialized_snapshots),
packages: pnpm_deps_restorer::LinkPackageData {
package_manifests: &materialized.package_manifests,
requires_build_by_snapshot: None,
@@ -64,14 +64,15 @@ pub(crate) mod timestamps;
pub(crate) use conflict_markers::{
LockfileConflictCheckFailure, first_lockfile_requiring_conflict_safe_install,
};
pub(crate) use current_lockfile::materialized_shape_matches;
pub use deps_status::{RunDepsStatus, check_deps_status_before_run};
pub(crate) use local_file_deps::{
has_local_file_dep_requiring_install, has_local_file_override, has_local_file_package_extension,
};
pub(crate) use manifest_agreement::{
ManifestStat, materialized_shape_matches, modified_manifests_match_lockfile, stat_manifests,
unstatted_manifests,
ManifestStat, modified_manifests_match_lockfile, stat_manifests, unstatted_manifests,
};
pub(crate) use relocation::recorded_elsewhere;
pub(crate) use settings::{
catalogs_cache_matches, current_settings_with_catalogs, first_setting_drift,
recorded_supported_architectures_match, settings_match,
@@ -82,6 +83,8 @@ pub(crate) use timestamps::{
validation_baseline_ms, wanted_lockfile_mtime,
};
mod current_lockfile;
mod relocation;
mod settle;
use settle::{
current_lockfile_file_has_content, current_lockfile_unusable_with_non_empty_wanted,
@@ -218,7 +221,11 @@ pub(crate) fn check_optimistic_repeat_install_ignoring(
let Ok(Some(state)) = load_workspace_state(check.workspace_root) else {
return Decision::Skipped { reason: "no workspace state on disk" };
};
if let Some(reason) = state_blocks_fast_path(check, &state, ignored_workspace_state_settings) {
let (state, moved) =
relocation::relocated_state(&state, check.workspace_root, check.project_manifests)
.map_or((state, false), |relocated| (relocated, true));
let blocked = state_blocks_fast_path(check, &state, ignored_workspace_state_settings, moved);
if let Some(reason) = blocked {
return Decision::Skipped { reason };
}
// The fast-path conclusion: walk every manifest and report up to
@@ -229,6 +236,9 @@ pub(crate) fn check_optimistic_repeat_install_ignoring(
let Some(drift) = ManifestDrift::stat(check, &state) else {
return Decision::Skipped { reason: "failed to stat a project manifest" };
};
if moved {
return relocation::moved_tree_decision(check, &state, &drift);
}
let modified = drift.modified();
if let Some(decision) = early_repeat_verdict(check, &modified, drift.lockfile_modified) {
return decision;
@@ -249,7 +259,7 @@ pub(crate) fn check_optimistic_repeat_install_ignoring(
check.config.dedupe_peers,
) {
Ok(loaded_current) => {
match settle_repeat_install(check, &state, loaded_current, filesystem_now) {
match settle_repeat_install(check, &state, loaded_current, filesystem_now, false) {
Ok(()) => Decision::UpToDate,
Err(reason) => Decision::Skipped { reason },
}
@@ -335,11 +345,12 @@ fn config_blocks_fast_path(config: &Config) -> Option<&'static str> {
}
/// The first reason the recorded workspace state cannot prove this install is
/// a no-op.
/// a no-op. `moved` marks a state [`relocation::relocated_state`] re-keyed.
fn state_blocks_fast_path(
check: &OptimisticRepeatInstallCheck<'_>,
state: &WorkspaceState,
ignored_workspace_state_settings: &[&str],
moved: bool,
) -> Option<&'static str> {
// A filtered install refreshes `lastValidatedTimestamp` while
// materializing only the projects it selected, so its state cannot
@@ -357,7 +368,7 @@ fn state_blocks_fast_path(
}
local_file_blocks_fast_path(check)
.or_else(|| settings_block_fast_path(check, state, ignored_workspace_state_settings))
.or_else(|| lockfile_inputs_block_fast_path(check, state))
.or_else(|| lockfile_inputs_block_fast_path(check, state, moved))
}
/// A local file dependency's contents can change with nothing in the manifest
@@ -444,10 +455,13 @@ fn settings_block_fast_path(
/// The lockfile and the resolution inputs beside the manifests: a missing
/// lockfile the current one may not stand in for, an edited patch, an edited
/// pnpmfile.
/// pnpmfile. The patch mtimes of a `moved` tree come from where it was
/// validated, so its patches are left to the content proof, which hashes
/// them.
fn lockfile_inputs_block_fast_path(
check: &OptimisticRepeatInstallCheck<'_>,
state: &WorkspaceState,
moved: bool,
) -> Option<&'static str> {
let &OptimisticRepeatInstallCheck {
workspace_root,
@@ -491,7 +505,7 @@ fn lockfile_inputs_block_fast_path(
// changes the patched output and the patch hash. This check runs
// before the manifest-modified exit so the patch reason wins when
// both a patch and a manifest are newer than the last validation.
if patches_modified_since(workspace_root, config, state.last_validated_timestamp) {
if !moved && patches_modified_since(workspace_root, config, state.last_validated_timestamp) {
return Some("a patch file is newer than the last validation");
}
// A pnpmfile added, removed, or edited in place can change
@@ -0,0 +1,94 @@
use super::{Config, DependencyGroup, Lockfile};
use pnpm_modules_yaml::IncludedDependencies;
/// Assert the wanted lockfile equals the current one: with no current
/// lockfile every importer of the wanted one must be dependency-free
/// (`RUN_CHECK_DEPS_NO_DEPS`); otherwise the current lockfile must record
/// what materializing the wanted one produces, per
/// [`materialized_shape_matches`] (`RUN_CHECK_DEPS_OUTDATED_DEPS`).
pub(crate) fn assert_wanted_lockfile_equals_current(
wanted: &Lockfile,
config: &Config,
included: IncludedDependencies,
) -> Result<(), &'static str> {
assert_current_lockfile_records(wanted, config, |current| {
materialized_shape_matches(wanted, current, included)
})
}
/// [`assert_wanted_lockfile_equals_current`] with `records` deciding
/// whether the parsed current lockfile is up to date with the wanted one,
/// for a caller that accepts a current lockfile of another shape.
pub(crate) fn assert_current_lockfile_records(
wanted: &Lockfile,
config: &Config,
records: impl FnOnce(&Lockfile) -> bool,
) -> Result<(), &'static str> {
let current = Lockfile::load_current_from_virtual_store_dir(&config.virtual_store_dir)
.map_err(|_| "the current lockfile cannot be loaded")?;
assert_loaded_current_lockfile_records(wanted, current.as_ref(), records)
}
/// [`assert_current_lockfile_records`] for a caller that already holds the
/// parsed current lockfile.
pub(crate) fn assert_loaded_current_lockfile_records(
wanted: &Lockfile,
current: Option<&Lockfile>,
records: impl FnOnce(&Lockfile) -> bool,
) -> Result<(), &'static str> {
match current {
None => {
let any_deps = wanted.importers
.values()
.any(|snapshot| {
snapshot
.dependencies_by_groups([
DependencyGroup::Prod,
DependencyGroup::Dev,
DependencyGroup::Optional,
])
.next()
.is_some()
});
if any_deps {
Err("the lockfile requires dependencies but none were installed")
} else {
Ok(())
}
}
Some(current) => {
if records(current) {
Ok(())
} else {
Err("the installed dependencies are not up to date with the lockfile")
}
}
}
}
/// Whether `current` already records what materializing `wanted` would
/// produce.
///
/// The current lockfile keeps only what the importers reach
/// ([`crate::filter_lockfile_for_current`]) and none of the top-level keys
/// pnpm does not define, so a wanted lockfile carrying a snapshot no
/// importer reaches any more, or an embedder's extension block, can never
/// equal it. Comparing the same shape both sides is what lets such a tree
/// settle instead of re-materializing on every run.
///
/// The equal case is the common one and answers without building the
/// filtered shape at all.
pub(crate) fn materialized_shape_matches(
wanted: &Lockfile,
current: &Lockfile,
included: IncludedDependencies,
) -> bool {
if wanted == current {
return true;
}
// A transient skip (a failed optional fetch) prunes the current lockfile
// further, and its set is not known here. Such a tree simply falls
// through to materialization, which retries the fetch anyway.
current
== &crate::filter_lockfile_for_current(wanted, included, &crate::SkippedSnapshots::new())
}
@@ -6,7 +6,9 @@ use super::{
current_lockfile_file_has_content, current_lockfile_unusable_with_non_empty_wanted,
filesystem_now_ms, first_lockfile_requiring_conflict_safe_install,
first_project_missing_modules_dir, first_setting_drift, modified_manifests_match_lockfile,
patches_modified_since, pnpmfiles_drift, project_structure_matches, update_workspace_state,
patches_modified_since, pnpmfiles_drift, project_structure_matches,
relocation::{prove_move, rekeyed_validation_now, relocated_state},
update_workspace_state,
};
/// Outcome of [`check_deps_status_before_run`].
@@ -51,13 +53,19 @@ pub fn check_deps_status_before_run(
if check.layout.node_linker == NodeLinker::Pnp {
return RunDepsStatus::SkippedPnp;
}
if let Some(issue) = first_static_drift(check, state) {
let relocated = relocated_state(state, check.workspace_root, check.project_manifests);
let moved = relocated.is_some();
let state = relocated.as_ref().unwrap_or(state);
if let Some(issue) = first_static_drift(check, state, moved) {
return outdated(issue);
}
let Some(drift) = super::ManifestDrift::stat(check, state) else {
return outdated("Cannot check whether dependencies are outdated".to_string());
};
if moved {
return moved_tree_status(check, state, &drift, &outdated);
}
let modified = drift.modified();
if let Some(status) =
early_content_verdict(check, &modified, drift.lockfile_modified, &outdated)
@@ -80,12 +88,33 @@ pub fn check_deps_status_before_run(
}
}
/// The gate's verdict on a tree whose `state` was re-keyed by
/// [`relocated_state`]: a tree [`prove_move`] refuses reports the structure
/// change an unrecognized move would.
fn moved_tree_status(
check: &OptimisticRepeatInstallCheck<'_>,
state: &WorkspaceState,
drift: &super::ManifestDrift<'_>,
outdated: &impl Fn(String) -> RunDepsStatus,
) -> RunDepsStatus {
let filesystem_now = rekeyed_validation_now(check, state, drift);
if prove_move(check, drift).is_err() {
return outdated(WORKSPACE_STRUCTURE_CHANGED.to_string());
}
record_content_check_state(check, state, filesystem_now);
RunDepsStatus::UpToDate
}
const WORKSPACE_STRUCTURE_CHANGED: &str = "The workspace structure has changed since last install";
/// The first drift the gate can decide before stat-ing any manifest.
fn first_static_drift(
check: &OptimisticRepeatInstallCheck<'_>,
state: &WorkspaceState,
moved: bool,
) -> Option<String> {
first_lockfile_or_setting_drift(check, state).or_else(|| first_workspace_drift(check, state))
first_lockfile_or_setting_drift(check, state)
.or_else(|| first_workspace_drift(check, state, moved))
}
fn first_lockfile_or_setting_drift(
@@ -126,9 +155,12 @@ fn first_lockfile_or_setting_drift(
None
}
/// A `moved` tree leaves its patches to the content proof, as the install
/// fast path does.
fn first_workspace_drift(
check: &OptimisticRepeatInstallCheck<'_>,
state: &WorkspaceState,
moved: bool,
) -> Option<String> {
let &OptimisticRepeatInstallCheck {
workspace_root,
@@ -138,7 +170,7 @@ fn first_workspace_drift(
..
} = check;
if !project_structure_matches(state, project_manifests) {
return Some("The workspace structure has changed since last install".to_string());
return Some(WORKSPACE_STRUCTURE_CHANGED.to_string());
}
// A filtered install legitimately leaves unselected projects
// without a modules directory.
@@ -155,7 +187,7 @@ fn first_workspace_drift(
{
return Some(format!("Cannot find a lockfile in {}", workspace_root.display()));
}
if patches_modified_since(workspace_root, config, state.last_validated_timestamp) {
if !moved && patches_modified_since(workspace_root, config, state.last_validated_timestamp) {
return Some("Patches were modified".to_string());
}
pnpmfiles_drift(workspace_root, config, &state.pnpmfiles, state.last_validated_timestamp)
@@ -187,18 +219,31 @@ fn early_content_verdict(
None
}
/// Record the passing content check so the next run's gate can short-circuit
/// on the refreshed timestamp.
/// Settle the passing content check for a tree in place. A single project
/// uses the lockfile mtimes and leaves its workspace state unchanged.
fn settle_content_check(
check: &OptimisticRepeatInstallCheck<'_>,
state: &WorkspaceState,
filesystem_now: Option<i64>,
) -> Result<(), String> {
missing_wanted_lockfile_stand_in_ok(check)?;
if check.is_workspace_install {
record_content_check_state(check, state, filesystem_now);
}
Ok(())
}
/// Record a passing content check so the next run can use the refreshed
/// timestamp and project locations.
fn record_content_check_state(
check: &OptimisticRepeatInstallCheck<'_>,
state: &WorkspaceState,
filesystem_now: Option<i64>,
) {
let &OptimisticRepeatInstallCheck {
workspace_root,
config,
project_manifests,
is_workspace_install,
catalogs,
layout:
crate::RepeatInstallLayout {
@@ -209,10 +254,6 @@ fn settle_content_check(
},
..
} = check;
missing_wanted_lockfile_stand_in_ok(check)?;
if !is_workspace_install {
return Ok(());
}
let mut new_state = crate::install::build_workspace_state::<Host>(
workspace_root,
config,
@@ -232,7 +273,6 @@ fn settle_content_check(
new_state.settings.optional = state.settings.optional;
new_state.settings.production = state.settings.production;
refresh_content_check_state(workspace_root, &new_state);
Ok(())
}
/// Read-only twin of [`crate::optimistic_repeat_install::regenerate_wanted_lockfile_if_missing`](crate::optimistic_repeat_install::settle::regenerate_wanted_lockfile_if_missing) for the
@@ -2,10 +2,12 @@
use super::{
Config, DependencyGroup, FileMtime, ImporterDepVersion, Lockfile, OptimisticRepeatInstallCheck,
PackageManifest, Path, PathBuf, ProjectSnapshot, WorkspaceState, file_mtime,
modified_at_or_after, mtime_ms,
PackageManifest, Path, PathBuf, ProjectSnapshot, WorkspaceState,
current_lockfile::assert_wanted_lockfile_equals_current, file_mtime, modified_at_or_after,
mtime_ms,
};
use pnpm_modules_yaml::IncludedDependencies;
use rayon::prelude::*;
/// One project manifest's stat outcome, paired with the inputs the
/// content re-check needs.
@@ -84,7 +86,7 @@ pub(crate) fn modified_manifests_match_lockfile(
/// The full content check of the modified projects against the wanted
/// lockfile, once its settings are known not to have drifted.
fn check_projects_content(
pub(super) fn check_projects_content(
check: &OptimisticRepeatInstallCheck<'_>,
wanted: &Lockfile,
to_check: &[&ManifestStat<'_>],
@@ -98,11 +100,12 @@ fn check_projects_content(
check.catalogs,
crate::install::CheckLockfileSettingsDriftOptions {
parsed_overrides: parsed_overrides.as_deref(),
// `pnpmfileChecksum` needs no comparison here: reaching this
// point means `pnpmfiles_modified_since` already proved the
// pnpmfile list and contents are what the install that wrote
// this lockfile saw. Computing the checksum instead would cost
// a Node worker on the path that exists to avoid starting one.
// `pnpmfileChecksum` is not compared here: every caller already
// compared the pnpmfile list and trusted their contents by their
// mtimes (`pnpmfiles_drift`). The move proof refuses active
// pnpmfiles because their mtimes cannot prove content. Computing
// the checksum would cost a Node worker on the path that exists to avoid
// starting one.
pnpmfile_checksum: pnpm_lockfile::PnpmfileChecksumCheck::Skip,
dedupe_peers,
},
@@ -123,8 +126,13 @@ fn check_projects_content(
ignored_optional_matcher: &ignored_optional_matcher,
parsed_overrides: parsed_overrides.as_deref(),
};
for project in to_check {
project_content_check(&content_check, project)?;
// Collect before returning so errors remain ordered by project.
let results: Vec<Result<(), &'static str>> = to_check
.par_iter()
.map(|project| project_content_check(&content_check, project))
.collect();
for result in results {
result?;
}
Ok(())
}
@@ -242,75 +250,6 @@ fn project_content_check(
Ok(())
}
/// Assert the wanted lockfile equals the current one: with no current
/// lockfile every importer of the wanted one must be dependency-free
/// (`RUN_CHECK_DEPS_NO_DEPS`); otherwise the current lockfile must record
/// what materializing the wanted one produces, per
/// [`materialized_shape_matches`] (`RUN_CHECK_DEPS_OUTDATED_DEPS`).
pub(crate) fn assert_wanted_lockfile_equals_current(
wanted: &Lockfile,
config: &Config,
included: IncludedDependencies,
) -> Result<(), &'static str> {
let current = Lockfile::load_current_from_virtual_store_dir(&config.virtual_store_dir)
.map_err(|_| "the current lockfile cannot be loaded")?;
match current {
None => {
let any_deps = wanted.importers
.values()
.any(|snapshot| {
snapshot
.dependencies_by_groups([
DependencyGroup::Prod,
DependencyGroup::Dev,
DependencyGroup::Optional,
])
.next()
.is_some()
});
if any_deps {
Err("the lockfile requires dependencies but none were installed")
} else {
Ok(())
}
}
Some(current) => {
if materialized_shape_matches(wanted, &current, included) {
Ok(())
} else {
Err("the installed dependencies are not up to date with the lockfile")
}
}
}
}
/// Whether `current` already records what materializing `wanted` would
/// produce.
///
/// The current lockfile keeps only what the importers reach
/// ([`crate::filter_lockfile_for_current`]) and none of the top-level keys
/// pnpm does not define, so a wanted lockfile carrying a snapshot no
/// importer reaches any more, or an embedder's extension block, can never
/// equal it. Comparing the same shape both sides is what lets such a tree
/// settle instead of re-materializing on every run.
///
/// The equal case is the common one and answers without building the
/// filtered shape at all.
pub(crate) fn materialized_shape_matches(
wanted: &Lockfile,
current: &Lockfile,
included: IncludedDependencies,
) -> bool {
if wanted == current {
return true;
}
// A transient skip (a failed optional fetch) prunes the current lockfile
// further, and its set is not known here. Such a tree simply falls
// through to materialization, which retries the fetch anyway.
current
== &crate::filter_lockfile_for_current(wanted, included, &crate::SkippedSnapshots::new())
}
/// Shared lookups for [`linked_packages_are_up_to_date`], built once
/// per content check.
pub(crate) struct LinkedPackagesContext<'a> {
@@ -0,0 +1,216 @@
//! A `node_modules` that moved or was copied together with its project:
//! recognizing the move from the workspace state it carries, and proving the
//! tree still works where it is now.
use super::{
Decision, Host, Lockfile, ManifestDrift, OptimisticRepeatInstallCheck, PackageManifest, Path,
PathBuf, WorkspaceState, current_lockfile::assert_loaded_current_lockfile_records,
current_pnpmfiles, filesystem_now_ms, manifest_agreement::check_projects_content,
project_structure_matches, settle_repeat_install,
};
use std::{collections::BTreeSet, path::Component};
/// `state` re-keyed onto `workspace_root` when it records these same
/// projects under one other root, or `None` when it records them in place or
/// no single rebase matches them. A pnpmfile outside that other root keeps
/// its path.
pub(super) fn relocated_state(
state: &WorkspaceState,
workspace_root: &Path,
project_manifests: &[(PathBuf, &PackageManifest)],
) -> Option<WorkspaceState> {
if !recorded_elsewhere(state, project_manifests) {
return None;
}
let recorded_root = recorded_root(state, workspace_root, project_manifests)?;
let rebase_onto_root = |path: &str| {
rebase(Path::new(path), recorded_root, workspace_root)
.map(|rebased| rebased.to_string_lossy().into_owned())
};
let projects = state.projects
.iter()
.map(|(dir, entry)| Some((rebase_onto_root(dir)?, entry.clone())))
.collect::<Option<_>>()?;
let pnpmfiles = state.pnpmfiles
.iter()
.map(|path| rebase_onto_root(path).unwrap_or_else(|| path.clone()))
.collect();
let relocated = WorkspaceState { projects, pnpmfiles, ..state.clone() };
project_structure_matches(&relocated, project_manifests).then_some(relocated)
}
/// Whether no current project dir is a key of `state`.
pub(crate) fn recorded_elsewhere(
state: &WorkspaceState,
project_manifests: &[(PathBuf, &PackageManifest)],
) -> bool {
!project_manifests
.iter()
.any(|(root_dir, _)| state.projects.contains_key(root_dir.to_string_lossy().as_ref()))
}
/// The unique old root reproducing every current workspace-relative project
/// path. The workspace root itself need not have a manifest or recorded entry.
fn recorded_root<'a>(
state: &'a WorkspaceState,
workspace_root: &Path,
project_manifests: &[(PathBuf, &PackageManifest)],
) -> Option<&'a Path> {
let relative_projects = relative_project_dirs(
project_manifests.iter().map(|(dir, _)| dir.as_path()),
workspace_root,
)?;
if relative_projects.len() != project_manifests.len()
|| relative_projects.len() != state.projects.len()
{
return None;
}
// A deep suffix narrows candidates without assuming the first recorded
// project is the workspace root or an ancestor of the other projects.
let anchor = relative_projects
.iter()
.max_by_key(|dir| dir.components().count())?;
let mut candidates = state.projects
.keys()
.map(Path::new)
.filter(|dir| dir.ends_with(anchor))
.filter_map(|dir| {
dir.ancestors()
.nth(anchor.components().count())
})
.filter(|root| {
relative_project_dirs(state.projects.keys().map(Path::new), root).as_ref()
== Some(&relative_projects)
});
let root = candidates.next()?;
candidates
.next()
.is_none()
.then_some(root)
}
fn relative_project_dirs<'a>(
dirs: impl Iterator<Item = &'a Path>,
root: &Path,
) -> Option<BTreeSet<&'a Path>> {
dirs.map(|dir| {
dir.strip_prefix(root)
.ok()
.filter(|relative| {
relative
.components()
.all(|component| matches!(component, Component::Normal(_)))
})
})
.collect()
}
/// `path` moved from under `from` to under `to`, or `None` outside `from`.
fn rebase(path: &Path, from: &Path, to: &Path) -> Option<PathBuf> {
let rest = path.strip_prefix(from).ok()?;
// `Path::join("")` would append a trailing separator to `to`.
Some(if rest.as_os_str().is_empty() { to.to_path_buf() } else { to.join(rest) })
}
/// The fast path's verdict on a tree whose `state` was re-keyed by
/// [`relocated_state`]: up to date once [`prove_move`] holds and the state is
/// recorded where the tree is now.
pub(super) fn moved_tree_decision(
check: &OptimisticRepeatInstallCheck<'_>,
state: &WorkspaceState,
drift: &ManifestDrift<'_>,
) -> Decision {
let filesystem_now = rekeyed_validation_now(check, state, drift);
match prove_move(check, drift)
.and_then(|()| settle_repeat_install(check, state, None, filesystem_now, true))
{
Ok(()) => Decision::UpToDate,
Err(reason) => Decision::Skipped { reason },
}
}
/// The filesystem time a re-keyed state is validated at. A tree with nothing
/// newer than its last validation keeps that timestamp, so nothing is probed.
pub(super) fn rekeyed_validation_now(
check: &OptimisticRepeatInstallCheck<'_>,
state: &WorkspaceState,
drift: &ManifestDrift<'_>,
) -> Option<i64> {
if drift.modified().is_empty() && !drift.lockfile_modified {
Some(state.last_validated_timestamp)
} else {
filesystem_now_ms(check.workspace_root)
}
}
/// Read-only proof that a tree which moved with its project is the one this
/// checkout wants, and resolves everything from where it is now. Its
/// pnpmfiles require a full install to validate their content. `Err`
/// carries the reason it cannot be reused.
pub(super) fn prove_move(
check: &OptimisticRepeatInstallCheck<'_>,
drift: &ManifestDrift<'_>,
) -> Result<(), &'static str> {
let &OptimisticRepeatInstallCheck {
config,
project_manifests,
layout: crate::RepeatInstallLayout { node_linker, .. },
..
} = check;
if !current_pnpmfiles(check.workspace_root, config).is_empty() {
return Err("a moved tree requires pnpmfile content validation");
}
// Both lockfiles are read here, and a refusal below falls through to the
// full install, which reads the wanted one too, so the prefetch is never
// spent for nothing: it parses while this thread does the rest.
check.lockfile.prefetch();
let wanted = validated_moved_lockfile(check)?;
check_projects_content(
check,
wanted,
&drift.stats.iter().collect::<Vec<_>>(),
config.dedupe_peers,
)?;
if !crate::install::moved_tree_is_reusable(config, node_linker, project_manifests, wanted) {
return Err("a bin in the moved tree names a path outside it");
}
Ok(())
}
fn validated_moved_lockfile<'a>(
check: &'a OptimisticRepeatInstallCheck<'_>,
) -> Result<&'a Lockfile, &'static str> {
let config = check.config;
let node_linker = check.layout.node_linker;
let modules = pnpm_modules_yaml::read_modules_layout::<Host>(&config.modules_dir)
.ok()
.flatten()
.filter(|modules| {
crate::install::modules_layout_consistent_with(modules, config, node_linker)
})
.ok_or("the moved tree's store or virtual store does not resolve from where it is")?;
let current = Lockfile::load_current_from_virtual_store_dir(&config.virtual_store_dir)
.map_err(|_| "the current lockfile cannot be loaded")?;
let wanted = check.lockfile
.get()
.map_err(|_| "the wanted lockfile cannot be read or parsed")?
.ok_or("a moved tree has no wanted lockfile to compare against")?;
assert_loaded_current_lockfile_records(wanted, current.as_ref(), |current| {
super::materialized_shape_matches(wanted, current, modules.included)
})?;
if let Some(current) = current.as_ref()
&& !crate::install::frozen_tree_intact(
current,
&modules,
config,
check.workspace_root,
node_linker,
)
{
return Err("the moved tree is missing an installed dependency");
}
Ok(wanted)
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,142 @@
use super::relocated_state;
use pnpm_package_manifest::PackageManifest;
use pnpm_workspace_state::{ProjectEntry, WorkspaceState};
use std::path::{Path, PathBuf};
fn state_recording(dirs: &[PathBuf], pnpmfiles: Vec<String>) -> WorkspaceState {
WorkspaceState {
last_validated_timestamp: 1_700_000_000_000,
projects: dirs
.iter()
.map(|dir| (path_string(dir), ProjectEntry::default()))
.collect(),
pnpmfiles,
..WorkspaceState::default()
}
}
/// [`relocated_state`] over the nameless projects [`state_recording`] records.
fn relocated_onto(
state: &WorkspaceState,
workspace_root: &Path,
dirs: &[PathBuf],
) -> Option<WorkspaceState> {
let manifests: Vec<PackageManifest> = dirs
.iter()
.map(|dir| PackageManifest::from_value(dir.join("package.json"), serde_json::json!({})))
.collect();
let project_manifests: Vec<(PathBuf, &PackageManifest)> = dirs
.iter()
.cloned()
.zip(&manifests)
.collect();
relocated_state(state, workspace_root, &project_manifests)
}
fn path_string(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
#[test]
fn rebases_the_projects_and_the_pnpmfiles_inside_the_recorded_root() {
let old_root = Path::new("/old/ws");
let new_root = Path::new("/new/deeper/ws");
let global_pnpmfile = path_string(Path::new("/global/pnpmfile.cjs"));
let state = state_recording(
&[old_root.to_path_buf(), old_root.join("packages").join("a")],
vec![path_string(&old_root.join(".pnpmfile.cjs")), global_pnpmfile.clone()],
);
let current = [new_root.to_path_buf(), new_root.join("packages").join("a")];
let relocated = relocated_onto(&state, new_root, &current)
.expect("the state records the same projects under another root");
let keys: Vec<&String> = relocated.projects.keys().collect();
assert_eq!(keys, [&path_string(&current[0]), &path_string(&current[1])]);
assert_eq!(
relocated.pnpmfiles,
[path_string(&new_root.join(".pnpmfile.cjs")), global_pnpmfile],
);
}
#[test]
fn rebases_rootless_projects_from_their_workspace_relative_paths() {
let old_root = Path::new("/old/ws");
let new_root = Path::new("/new/deeper/ws");
let layouts: &[&[&str]] = &[
&["packages/a", "packages/b"],
&["packages/deep/app"],
&["apps/a", "packages/deep/b"],
&["packages/a", "packages/a/child"],
];
for relative_dirs in layouts {
let recorded: Vec<_> = relative_dirs
.iter()
.map(|dir| old_root.join(dir))
.collect();
let current: Vec<_> = relative_dirs
.iter()
.map(|dir| new_root.join(dir))
.collect();
let state = state_recording(&recorded, vec![path_string(&old_root.join(".pnpmfile.cjs"))]);
let relocated = relocated_onto(&state, new_root, &current)
.expect("the same rootless workspace can move to another root");
assert_eq!(
relocated,
state_recording(&current, vec![path_string(&new_root.join(".pnpmfile.cjs"))]),
);
}
}
#[test]
fn refuses_rootless_projects_without_one_matching_workspace_root() {
let old_root = Path::new("/old/ws");
let new_root = Path::new("/new/ws");
let recorded = [old_root.join("packages/a"), old_root.join("packages/b")];
let state = state_recording(&recorded, Vec::new());
let invalid_layouts = [
[new_root.join("packages/a"), new_root.join("renamed/b")],
[new_root.join("packages/a"), PathBuf::from("/outside/packages/b")],
[new_root.join("packages/a"), new_root.join("../outside/packages/b")],
[new_root.join("packages/a"), new_root.join("packages/a")],
];
for current in invalid_layouts {
assert_eq!(relocated_onto(&state, new_root, &current), None);
}
let unrelated = state_recording(
&[old_root.join("packages/a"), PathBuf::from("/unrelated/ws/packages/b")],
Vec::new(),
);
let current = [new_root.join("packages/a"), new_root.join("packages/b")];
assert_eq!(relocated_onto(&unrelated, new_root, &current), None);
assert_eq!(relocated_onto(&state, new_root, &current[..1]), None);
let repeated_suffix = state_recording(
&[old_root.join("packages/a"), old_root.join("nested/packages/a")],
Vec::new(),
);
assert_eq!(relocated_onto(&repeated_suffix, new_root, &current), None);
}
/// Nothing is re-keyed but a whole tree proven to have moved under one
/// other root.
#[test]
fn refuses_to_rebase_anything_else() {
let root = Path::new("/ws");
let in_place = [root.to_path_buf(), root.join("a")];
let state = state_recording(&in_place, Vec::new());
let new_root = Path::new("/new/ws");
let under_a_new_root = [new_root.to_path_buf(), new_root.join("packages").join("a")];
let cases = [
("recorded in place", root, &in_place[..]),
("one project renamed in place", root, &[root.to_path_buf(), root.join("b")][..]),
("a rebase reproducing another layout", new_root, &under_a_new_root[..]),
];
for (label, workspace_root, dirs) in cases {
assert_eq!(relocated_onto(&state, workspace_root, dirs), None, "{label}");
}
}
@@ -1,9 +1,9 @@
//! Comparing the settings a previous install recorded against the current ones.
use super::{
Catalogs, Config, IncludedDependencies, LinkWorkspacePackages, NodeLinker, Path,
Catalogs, Config, IncludedDependencies, LinkWorkspacePackages, NodeLinker,
SupportedArchitectures, TrustPolicy, WorkspaceState, WorkspaceStateNodeLinker,
WorkspaceStateSettings, WorkspaceStateTrustPolicy, load_workspace_state,
WorkspaceStateSettings, WorkspaceStateTrustPolicy,
};
/// Whether the `supportedArchitectures` recorded by the last install
@@ -11,18 +11,19 @@ use super::{
/// state for the frozen path's lockfile-up-to-date early return, whose
/// other guards (`wanted == current`, `.modules.yaml` consistency) cannot
/// see an architecture change: the skip set it would produce differs, so
/// the shortcut must not fire. A missing state file or an unreadable one
/// the shortcut must not fire. A missing or unreadable state (`None`)
/// reports a match only when `live` is also unset — the conservative
/// direction is a full install.
pub(crate) fn recorded_supported_architectures_match(
workspace_root: &Path,
recorded: Option<&WorkspaceState>,
live: Option<&SupportedArchitectures>,
) -> bool {
let recorded = match load_workspace_state(workspace_root) {
Ok(Some(state)) => state.settings.supported_architectures,
_ => None,
};
recorded == live.and_then(|value| serde_json::to_value(value).ok())
let recorded =
recorded.and_then(|state| state.settings.supported_architectures.as_ref());
recorded
== live
.and_then(|value| serde_json::to_value(value).ok())
.as_ref()
}
pub(crate) fn settings_match(
@@ -42,13 +42,15 @@ pub(super) fn early_repeat_verdict(
///
/// The workspace branch rewrites the state; the single-project branch keys
/// its comparisons off the lockfile mtimes instead and leaves the state
/// alone. A failed write only costs the next run a repeat of the content
/// alone, unless the tree `moved` and the state has to be recorded where it
/// is now. A failed write only costs the next run a repeat of the content
/// check, so it degrades rather than fails.
pub(super) fn settle_repeat_install(
check: &OptimisticRepeatInstallCheck<'_>,
state: &WorkspaceState,
loaded_current: Option<Lockfile>,
filesystem_now: Option<i64>,
moved: bool,
) -> Result<(), &'static str> {
let &OptimisticRepeatInstallCheck {
workspace_root,
@@ -66,7 +68,7 @@ pub(super) fn settle_repeat_install(
..
} = check;
regenerate_wanted_lockfile_if_missing(check, loaded_current)?;
if !is_workspace_install {
if !is_workspace_install && !moved {
return Ok(());
}
// This path refreshes the timestamp without materializing anything, so
@@ -1,13 +1,15 @@
#[cfg(unix)]
use super::FOO_LOCKFILE;
use super::{
super::{
Decision, OptimisticRepeatInstallCheck, check_optimistic_repeat_install,
settings::current_settings,
},
FOO_MANIFEST, assert_content_check_converges_after_collision, backdate_validated_files, check,
check_with_lockfile, collide_mtimes_with_recorded_state, content_check_decision,
isolated_included, linked_sibling_decision_for_spec, setup_content_check_project,
setup_fresh_install, setup_fresh_install_with_config, validate_existing_files,
write_local_tarball_lockfile, write_state,
FOO_MANIFEST, RunDepsStatus, assert_content_check_converges_after_collision,
backdate_validated_files, check, check_with_lockfile, collide_mtimes_with_recorded_state,
content_check_decision, isolated_included, linked_sibling_decision_for_spec,
setup_content_check_project, setup_fresh_install, setup_fresh_install_with_config,
validate_existing_files, workspace_deps_status, write_local_tarball_lockfile, write_state,
};
use pnpm_config::Config;
use pnpm_lockfile::{Lockfile, MaybeLazyLockfile};
@@ -228,7 +230,7 @@ fn returns_up_to_date_when_a_project_has_only_link_dependencies() {
/// Project list mismatch (cached state has a project that today's
/// walk doesn't) invalidates the cached state.
#[test]
fn returns_skipped_when_workspace_project_set_changes() {
fn install_and_run_refuse_a_changed_workspace_project_set() {
let (dir, config, manifest) =
setup_fresh_install(pnpm_config::NodeLinker::Isolated, "root", "1.0.0", "");
@@ -261,7 +263,230 @@ fn returns_skipped_when_workspace_project_set_changes() {
&[(dir.path().to_path_buf(), &manifest)],
);
assert!(matches!(decision, Decision::Skipped { reason } if reason.contains("project list")));
let status = workspace_deps_status(&dir, config, &[(dir.path().to_path_buf(), &manifest)]);
assert_eq!(
status,
RunDepsStatus::Outdated {
issue: "The workspace structure has changed since last install".to_string(),
install_args: Vec::new(),
},
);
let state = load_workspace_state(dir.path()).unwrap().unwrap();
assert_eq!(state.projects.len(), 2, "a rejected project set must not refresh the state");
}
/// Record the only project under a sibling of `workspace_root`, as a tree
/// copied from there carries it. Returns the recorded dir.
fn record_projects_elsewhere(workspace_root: &std::path::Path) -> String {
let mut state =
load_workspace_state(workspace_root).expect("read state").expect("state on disk");
let (_, entry) = state.projects.pop_first().expect("the recorded project");
let elsewhere = workspace_root
.with_file_name("elsewhere")
.to_string_lossy()
.into_owned();
state.projects.insert(elsewhere.clone(), entry);
update_workspace_state(workspace_root, &state).expect("write workspace state");
elsewhere
}
/// The patch mtimes of a moved tree come from wherever it was validated, so
/// a newer patch file leaves the verdict to the proof of the move, at the
/// fast path and at the gate alike. This tree has no `.modules.yaml` for
/// that proof to get past, so both refuse it for the move it cannot prove.
#[test]
fn a_moved_tree_leaves_a_newer_patch_to_the_move_proof() {
let (dir, config, manifest) = setup_fresh_install_with_config(
pnpm_config::NodeLinker::Isolated,
"root",
"1.0.0",
"",
|config| {
let patch = ("foo@1.0.0".to_string(), "patches/foo.patch".to_string());
config.patched_dependencies = Some(indexmap::IndexMap::from([patch]));
},
);
let elsewhere = record_projects_elsewhere(dir.path());
fs::create_dir_all(dir.path().join("patches")).unwrap();
fs::write(dir.path().join("patches/foo.patch"), "--- a\n+++ b\n").unwrap();
let projects = [(dir.path().to_path_buf(), &manifest)];
let decision = check(dir.path(), config, pnpm_config::NodeLinker::Isolated, &projects);
assert_eq!(
decision,
Decision::Skipped {
reason: "the moved tree's store or virtual store does not resolve from where it is",
},
);
let status = workspace_deps_status(&dir, config, &projects);
assert!(
matches!(&status, RunDepsStatus::Outdated { issue, .. } if issue == "The workspace structure has changed since last install"),
"unexpected status: {status:?}",
);
let state = load_workspace_state(dir.path()).expect("read state").expect("state on disk");
assert!(state.projects.contains_key(&elsewhere), "the refused move must not re-key the state");
}
/// The current lockfile keeps only what the importers reach, so a wanted
/// lockfile still carrying a snapshot no importer reaches never equals it.
/// The proof of a move compares the shape a materialization would settle on,
/// as the in-place short-circuit does, so such a tree is reused.
#[cfg(unix)]
#[test]
fn a_moved_tree_with_unreachable_lockfile_snapshots_is_up_to_date() {
let (dir, config) = setup_content_check_project();
write_relocatable_layout(config);
install_foo_slot(config);
// A snapshot the only importer does not reach, as a lockfile keeps until
// something prunes it.
let wanted = format!("{FOO_LOCKFILE} bar@1.0.0: {{}}\n");
fs::write(dir.path().join(Lockfile::FILE_NAME), wanted).unwrap();
record_projects_elsewhere(dir.path());
let manifest = PackageManifest::from_path(dir.path().join("package.json")).unwrap();
let decision =
content_check_decision(&dir, config, false, &[(dir.path().to_path_buf(), &manifest)]);
assert_eq!(decision, Decision::UpToDate);
}
#[cfg(unix)]
fn write_relocatable_layout(config: &Config) {
let layout = serde_json::json!({
"layoutVersion": 5,
"nodeLinker": "isolated",
"included": isolated_included(),
"hoistPattern": config.hoist_pattern,
"publicHoistPattern": config.public_hoist_pattern,
"storeDir": config.store_dir.display().to_string(),
"virtualStoreDir": config.effective_virtual_store_dir().to_string_lossy(),
"virtualStoreDirMaxLength": config.virtual_store_dir_max_length,
});
fs::write(config.modules_dir.join(pnpm_modules_yaml::MODULES_FILENAME), layout.to_string())
.unwrap();
}
#[cfg(unix)]
fn install_foo_slot(config: &Config) {
let slot = config.virtual_store_dir.join("foo@1.0.0/node_modules/foo");
fs::create_dir_all(&slot).unwrap();
std::os::unix::fs::symlink(".pnpm/foo@1.0.0/node_modules/foo", config.modules_dir.join("foo"))
.unwrap();
}
#[cfg(unix)]
#[test]
fn a_moved_tree_with_a_missing_dependency_is_not_up_to_date() {
for missing in ["link", "slot"] {
let (dir, config) = setup_content_check_project();
write_relocatable_layout(config);
install_foo_slot(config);
match missing {
"link" => fs::remove_file(config.modules_dir.join("foo")).unwrap(),
_ => fs::remove_dir_all(config.virtual_store_dir.join("foo@1.0.0")).unwrap(),
}
record_projects_elsewhere(dir.path());
let manifest = PackageManifest::from_path(dir.path().join("package.json")).unwrap();
let projects = [(dir.path().to_path_buf(), &manifest)];
let decision = content_check_decision(&dir, config, false, &projects);
assert!(matches!(decision, Decision::Skipped { .. }), "missing {missing}: {decision:?}");
let status = workspace_deps_status(&dir, config, &projects);
assert!(matches!(status, RunDepsStatus::Outdated { .. }), "missing {missing}: {status:?}");
}
}
#[cfg(unix)]
#[test]
fn a_moved_tree_with_a_missing_current_lockfile_is_not_up_to_date() {
let (dir, config) = setup_content_check_project();
write_relocatable_layout(config);
install_foo_slot(config);
let elsewhere = record_projects_elsewhere(dir.path());
fs::remove_file(config.virtual_store_dir.join(Lockfile::CURRENT_FILE_NAME)).unwrap();
let manifest = PackageManifest::from_path(dir.path().join("package.json")).unwrap();
let projects = [(dir.path().to_path_buf(), &manifest)];
let decision = content_check_decision(&dir, config, false, &projects);
assert_eq!(
decision,
Decision::Skipped { reason: "the lockfile requires dependencies but none were installed" },
);
let status = workspace_deps_status(&dir, config, &projects);
assert_eq!(
status,
RunDepsStatus::Outdated {
issue: "The workspace structure has changed since last install".to_string(),
install_args: Vec::new(),
},
);
let state = load_workspace_state(dir.path()).unwrap().unwrap();
assert_eq!(state.projects.keys().collect::<Vec<_>>(), [&elsewhere]);
}
#[cfg(unix)]
#[test]
fn a_moved_tree_cannot_trust_a_pnpmfile_with_an_older_mtime() {
let (dir, config) = setup_content_check_project();
write_relocatable_layout(config);
install_foo_slot(config);
let elsewhere = record_projects_elsewhere(dir.path());
let hook = dir.path().join(".pnpmfile.cjs");
fs::write(&hook, "module.exports = { hooks: { readPackage(pkg) { pkg.dependencies = {}; return pkg; } } };\n")
.unwrap();
let mut state = load_workspace_state(dir.path()).unwrap().unwrap();
state.pnpmfiles = vec![
std::path::Path::new(&elsewhere)
.join(".pnpmfile.cjs")
.to_string_lossy()
.into_owned(),
];
update_workspace_state(dir.path(), &state).unwrap();
pnpm_testing_utils::fs::set_mtime_ms(&hook, state.last_validated_timestamp - 2_000);
let manifest = PackageManifest::from_path(dir.path().join("package.json")).unwrap();
let projects = [(dir.path().to_path_buf(), &manifest)];
let decision = content_check_decision(&dir, config, false, &projects);
assert!(matches!(decision, Decision::Skipped { .. }), "unexpected decision: {decision:?}");
let status = workspace_deps_status(&dir, config, &projects);
assert!(matches!(status, RunDepsStatus::Outdated { .. }), "unexpected status: {status:?}");
}
#[cfg(unix)]
#[test]
fn a_moved_production_install_passes_the_run_gate() {
let (dir, config) = setup_content_check_project();
write_relocatable_layout(config);
let included = pnpm_modules_yaml::IncludedDependencies {
dependencies: true,
dev_dependencies: false,
optional_dependencies: true,
};
let layout_path = config.modules_dir.join(pnpm_modules_yaml::MODULES_FILENAME);
let mut layout: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&layout_path).unwrap()).unwrap();
layout["included"] = serde_json::to_value(included).unwrap();
fs::write(layout_path, layout.to_string()).unwrap();
fs::write(
dir.path().join("package.json"),
FOO_MANIFEST.replace("dependencies", "devDependencies"),
)
.unwrap();
fs::write(
dir.path().join(Lockfile::FILE_NAME),
FOO_LOCKFILE.replace("dependencies:", "devDependencies:"),
)
.unwrap();
let wanted = Lockfile::load_wanted_from_dir(dir.path()).unwrap().unwrap();
let current =
crate::filter_lockfile_for_current(&wanted, included, &crate::SkippedSnapshots::new());
current
.save_to_path(&config.virtual_store_dir.join(Lockfile::CURRENT_FILE_NAME))
.unwrap();
record_projects_elsewhere(dir.path());
let mut state = load_workspace_state(dir.path()).unwrap().unwrap();
state.settings.dev = Some(false);
update_workspace_state(dir.path(), &state).unwrap();
let manifest = PackageManifest::from_path(dir.path().join("package.json")).unwrap();
let status = workspace_deps_status(&dir, config, &[(dir.path().to_path_buf(), &manifest)]);
assert_eq!(status, RunDepsStatus::UpToDate);
assert_eq!(load_workspace_state(dir.path()).unwrap().unwrap().settings.dev, Some(false));
}
/// Drift in `injectWorkspacePackages` invalidates the cached state.
/// Toggling the flag changes whether workspace resolutions land as
/// `link:` symlinks or `file:` hard-linked copies, so the previous
@@ -431,6 +656,39 @@ fn workspace_content_check_refreshes_last_validated_timestamp() {
.last_validated_timestamp;
assert!(after > before, "expected the state timestamp to advance ({before} -> {after})");
}
/// Every project the content check takes is checked, not only the first: a
/// project the lockfile no longer records is refused however current the root
/// is.
#[test]
fn workspace_content_check_refuses_a_project_after_the_first() {
let (dir, config) = setup_content_check_project();
let dropped_dir = dir.path().join("packages").join("b");
fs::create_dir_all(&dropped_dir).unwrap();
fs::write(dropped_dir.join("package.json"), r#"{"name":"b","version":"1.0.0"}"#).unwrap();
// Rewritten so both manifests are newer than the recorded validation and
// both reach the content check, the root's first.
fs::write(dir.path().join("package.json"), FOO_MANIFEST).unwrap();
let mut state = load_workspace_state(dir.path()).unwrap().unwrap();
state.projects.insert(
dropped_dir.to_string_lossy().into_owned(),
ProjectEntry { name: Some("b".into()), version: Some("1.0.0".into()) },
);
update_workspace_state(dir.path(), &state).unwrap();
let root = PackageManifest::from_path(dir.path().join("package.json")).unwrap();
let dropped = PackageManifest::from_path(dropped_dir.join("package.json")).unwrap();
let decision = content_check_decision(
&dir,
config,
true,
&[(dir.path().to_path_buf(), &root), (dropped_dir, &dropped)],
);
assert_eq!(
decision,
Decision::Skipped { reason: "a modified manifest is no longer satisfied by the lockfile" },
);
}
#[test]
fn workspace_content_check_converges_after_a_same_millisecond_mtime_collision() {
assert_content_check_converges_after_collision(500_000);