Pacquet's shared script environment builder treated TMPDIR as an always-reserved stamp. Explicit pnpm run calls use unsafePerm, so no replacement was written and the child lost the user-provided temporary directory entirely. Preserve inherited TMPDIR by default and continue replacing it with the per-package node_modules/.tmp directory when unsafePerm is disabled. This matches the TypeScript CLI and normal child-process environment inheritance. Closes pnpm/pnpm#13442.
405 lines
17 KiB
Rust
405 lines
17 KiB
Rust
use super::{
|
|
DEV_PREINSTALL_ALREADY_RAN_ENV, EnvOptions, VERIFY_DEPS_BEFORE_RUN_ENV, build_env,
|
|
build_env_for_platform, escape_newlines, is_dev_preinstall_marker, is_stamping_key,
|
|
sanitize_env_key, stamp_package,
|
|
};
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::json;
|
|
use std::{collections::HashMap, path::Path};
|
|
|
|
fn empty_extra() -> HashMap<String, String> {
|
|
HashMap::new()
|
|
}
|
|
|
|
fn base_opts<'a>(
|
|
pkg_root: &'a Path,
|
|
init_cwd: &'a Path,
|
|
extra_env: &'a HashMap<String, String>,
|
|
) -> EnvOptions<'a> {
|
|
EnvOptions {
|
|
stage: "postinstall",
|
|
script: "echo hi",
|
|
pkg_root,
|
|
init_cwd,
|
|
script_src_dir: pkg_root,
|
|
node_execpath: None,
|
|
npm_execpath: None,
|
|
node_gyp_path: None,
|
|
user_agent: None,
|
|
unsafe_perm: true,
|
|
extra_env,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn make_env_preserves_user_config_and_strips_auth_and_package_leakage() {
|
|
let mut parent = HashMap::new();
|
|
parent.insert("PATH".into(), "/usr/bin".into());
|
|
parent.insert("npm_config_platform_arch".into(), "x64".into());
|
|
parent.insert("npm_config__auth".into(), "should-not-leak".into());
|
|
parent.insert("npm_config__authToken".into(), "should-not-leak".into());
|
|
parent.insert("npm_config__password".into(), "should-not-leak".into());
|
|
parent.insert("npm_config_//registry.npmjs.org/:_authToken".into(), "should-not-leak".into());
|
|
parent.insert("npm_config_@scope:registry".into(), "https://example.com".into());
|
|
parent.insert("pnpm_config__authToken".into(), "should-not-leak".into());
|
|
parent.insert("pnpm_config_//registry.npmjs.org/:_authToken".into(), "should-not-leak".into());
|
|
parent.insert("npm_package_name".into(), "should-be-regenerated".into());
|
|
parent.insert("PNPM_HOME".into(), "/opt/pnpm".into());
|
|
parent.insert("HOME".into(), "/home/me".into());
|
|
|
|
let manifest = json!({
|
|
"name": "@scope/pkg",
|
|
"version": "1.2.3",
|
|
"config": { "myKey": "myValue" },
|
|
"_myPackage": { "secret": "ignored" },
|
|
"scripts": { "postinstall": "noop" },
|
|
});
|
|
|
|
let pkg_root = Path::new("/tmp/pkg-x");
|
|
let extra = empty_extra();
|
|
let built = build_env(&base_opts(pkg_root, pkg_root, &extra), &manifest, parent);
|
|
|
|
assert_eq!(built.env.get("npm_package_name").map(String::as_str), Some("@scope/pkg"));
|
|
assert_eq!(built.env.get("npm_package_version").map(String::as_str), Some("1.2.3"));
|
|
assert_eq!(built.env.get("npm_package_config_myKey").map(String::as_str), Some("myValue"));
|
|
assert!(
|
|
!built.env.contains_key("npm_package__myPackage_secret"),
|
|
"underscore-prefixed manifest keys must be ignored",
|
|
);
|
|
assert_eq!(
|
|
built.env.get("npm_config_platform_arch").map(String::as_str),
|
|
Some("x64"),
|
|
"user-defined npm_config_* vars from the parent env are preserved: {:?}",
|
|
built.env,
|
|
);
|
|
for stripped in [
|
|
"npm_config__auth",
|
|
"npm_config__authToken",
|
|
"npm_config__password",
|
|
"npm_config_//registry.npmjs.org/:_authToken",
|
|
"npm_config_@scope:registry",
|
|
"pnpm_config__authToken",
|
|
"pnpm_config_//registry.npmjs.org/:_authToken",
|
|
] {
|
|
assert!(
|
|
!built.env.contains_key(stripped),
|
|
"auth config key {stripped} must be stripped: {:?}",
|
|
built.env,
|
|
);
|
|
}
|
|
assert_eq!(
|
|
built.env.get("PNPM_HOME").map(String::as_str),
|
|
Some("/opt/pnpm"),
|
|
"pnpm_* (incl. PNPM_HOME) keys are NOT in upstream's strip filter — they must pass through",
|
|
);
|
|
assert_eq!(
|
|
built.env.get("HOME").map(String::as_str),
|
|
Some("/home/me"),
|
|
"non-npm parent keys are preserved",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn make_env_drops_non_keep_listed_top_level_keys() {
|
|
let manifest = json!({
|
|
"name": "x",
|
|
"version": "0.1.0",
|
|
"scripts": { "postinstall": "echo hi", "test": "exit 1" },
|
|
"dependencies": { "foo": "1.0.0" },
|
|
"homepage": "https://example.com",
|
|
});
|
|
|
|
let pkg_root = Path::new("/tmp/x");
|
|
let extra = empty_extra();
|
|
let built = build_env(&base_opts(pkg_root, pkg_root, &extra), &manifest, HashMap::new());
|
|
|
|
for not_kept in
|
|
["npm_package_scripts_postinstall", "npm_package_dependencies_foo", "npm_package_homepage"]
|
|
{
|
|
assert!(!built.env.contains_key(not_kept), "{not_kept} must be filtered out");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn make_env_stamps_lifecycle_specific_keys() {
|
|
let pkg_root = Path::new("/tmp/y");
|
|
let init_cwd = Path::new("/tmp/projects/y");
|
|
let extra = empty_extra();
|
|
|
|
let opts = EnvOptions {
|
|
stage: "preinstall",
|
|
script: "node x.js",
|
|
pkg_root,
|
|
init_cwd,
|
|
script_src_dir: pkg_root,
|
|
node_execpath: None,
|
|
npm_execpath: None,
|
|
node_gyp_path: None,
|
|
user_agent: None,
|
|
unsafe_perm: true,
|
|
extra_env: &extra,
|
|
};
|
|
|
|
let built = build_env(&opts, &json!({ "name": "y", "version": "1.0.0" }), HashMap::new());
|
|
|
|
// Compute expected paths through the same `join` so the
|
|
// assertions are correct on Windows (`\\` separator) as well as
|
|
// POSIX. Path-separator handling itself is `std`'s job — these
|
|
// tests verify build_env's mapping, not separator policy.
|
|
let expected_package_json = pkg_root.join("package.json").to_string_lossy().into_owned();
|
|
let expected_init_cwd = init_cwd.to_string_lossy().into_owned();
|
|
let expected_src_dir = pkg_root.to_string_lossy().into_owned();
|
|
|
|
assert_eq!(built.env.get("npm_lifecycle_event").map(String::as_str), Some("preinstall"));
|
|
assert_eq!(built.env.get("npm_lifecycle_script").map(String::as_str), Some("node x.js"));
|
|
assert_eq!(built.env.get("npm_package_json"), Some(&expected_package_json));
|
|
assert_eq!(built.env.get("INIT_CWD"), Some(&expected_init_cwd));
|
|
assert_eq!(built.env.get("PNPM_SCRIPT_SRC_DIR"), Some(&expected_src_dir));
|
|
}
|
|
|
|
#[test]
|
|
fn make_env_preserves_or_overrides_tmpdir_based_on_unsafe_perm() {
|
|
let pkg_root = Path::new("/tmp/z");
|
|
let extra = empty_extra();
|
|
let parent = HashMap::from([("TMPDIR".to_string(), "/alternate/tmp".to_string())]);
|
|
|
|
let mut opts = base_opts(pkg_root, pkg_root, &extra);
|
|
opts.unsafe_perm = true;
|
|
let built = build_env(&opts, &json!({"name":"z","version":"0"}), parent.clone());
|
|
assert!(built.tmpdir.is_none());
|
|
assert_eq!(built.env.get("TMPDIR").map(String::as_str), Some("/alternate/tmp"));
|
|
|
|
opts.unsafe_perm = false;
|
|
let built = build_env(&opts, &json!({"name":"z","version":"0"}), parent);
|
|
let expected_tmpdir = pkg_root.join("node_modules").join(".tmp");
|
|
assert_eq!(built.tmpdir.as_deref(), Some(expected_tmpdir.as_path()));
|
|
assert_eq!(built.env.get("TMPDIR"), Some(&expected_tmpdir.to_string_lossy().into_owned()));
|
|
}
|
|
|
|
#[test]
|
|
fn make_env_windows_tmpdir_override_removes_differently_cased_keys() {
|
|
let pkg_root = Path::new("/tmp/z");
|
|
let extra = HashMap::from([("tmpdir".to_string(), "/extra/tmp".to_string())]);
|
|
let parent = HashMap::from([("TmpDir".to_string(), "/parent/tmp".to_string())]);
|
|
let mut opts = base_opts(pkg_root, pkg_root, &extra);
|
|
opts.unsafe_perm = false;
|
|
|
|
let built = build_env_for_platform(&opts, &json!({"name":"z","version":"0"}), parent, true);
|
|
let expected_tmpdir = pkg_root.join("node_modules").join(".tmp");
|
|
|
|
assert_eq!(built.env.get("TMPDIR"), Some(&expected_tmpdir.to_string_lossy().into_owned()));
|
|
let tmpdir_key_count =
|
|
built.env.keys().filter(|key| key.eq_ignore_ascii_case("TMPDIR")).count();
|
|
assert_eq!(tmpdir_key_count, 1);
|
|
}
|
|
|
|
/// pnpm's reserved per-call stamps override a user `extraEnv` that
|
|
/// tries to set the same key (matching TS `runLifecycleHook`, which
|
|
/// spreads `extraEnv` before its own `INIT_CWD` / user-agent / etc.).
|
|
/// The non-reserved keys pnpm generates before `extra_env`
|
|
/// (`npm_lifecycle_event`, `npm_config_node_gyp`, `npm_package_*`) stay
|
|
/// overridable, matching TS `npm-lifecycle`, which applies `extraEnv`
|
|
/// after those. A brand-new key also applies.
|
|
#[test]
|
|
fn reserved_stamps_win_over_extra_env_but_custom_keys_apply() {
|
|
let pkg_root = Path::new("/tmp/w");
|
|
let node_gyp = Path::new("/pnpm/node-gyp");
|
|
let mut extra = HashMap::new();
|
|
extra.insert("INIT_CWD".into(), "/overridden".into());
|
|
extra.insert("npm_config_user_agent".into(), "evil".into());
|
|
extra.insert(VERIFY_DEPS_BEFORE_RUN_ENV.into(), "true".into());
|
|
extra.insert("npm_lifecycle_script".into(), "FAKE".into());
|
|
extra.insert("npm_lifecycle_event".into(), "from-hook".into());
|
|
extra.insert("npm_config_node_gyp".into(), "/from-hook/node-gyp".into());
|
|
extra.insert("npm_package_name".into(), "from-hook".into());
|
|
extra.insert("CUSTOM".into(), "hello".into());
|
|
|
|
let opts = EnvOptions {
|
|
stage: "postinstall",
|
|
script: "REAL",
|
|
pkg_root,
|
|
init_cwd: Path::new("/original"),
|
|
script_src_dir: pkg_root,
|
|
node_execpath: None,
|
|
npm_execpath: None,
|
|
node_gyp_path: Some(node_gyp),
|
|
user_agent: Some("pnpm"),
|
|
unsafe_perm: true,
|
|
extra_env: &extra,
|
|
};
|
|
|
|
let built = build_env(&opts, &json!({"name":"w","version":"0"}), HashMap::new());
|
|
|
|
// Reserved pnpm keys win over the user's `extraEnv`.
|
|
assert_eq!(built.env.get("INIT_CWD").map(String::as_str), Some("/original"));
|
|
assert_eq!(built.env.get("npm_config_user_agent").map(String::as_str), Some("pnpm"));
|
|
assert_eq!(built.env.get(VERIFY_DEPS_BEFORE_RUN_ENV).map(String::as_str), Some("false"));
|
|
assert_eq!(built.env.get("npm_lifecycle_script").map(String::as_str), Some("REAL"));
|
|
// Non-reserved stamps pnpm generates before `extra_env` stay
|
|
// overridable, matching TS.
|
|
assert_eq!(built.env.get("npm_lifecycle_event").map(String::as_str), Some("from-hook"));
|
|
assert_eq!(
|
|
built.env.get("npm_config_node_gyp").map(String::as_str),
|
|
Some("/from-hook/node-gyp"),
|
|
);
|
|
assert_eq!(built.env.get("npm_package_name").map(String::as_str), Some("from-hook"));
|
|
// A brand-new key from `extraEnv` also applies.
|
|
assert_eq!(built.env.get("CUSTOM").map(String::as_str), Some("hello"));
|
|
}
|
|
|
|
#[test]
|
|
fn stamp_package_recurses_into_kept_buckets() {
|
|
let mut env = HashMap::new();
|
|
stamp_package(
|
|
&mut env,
|
|
"npm_package_",
|
|
&json!({
|
|
"name": "pkg",
|
|
"config": { "port": 3000, "deep": { "nested": "value" } },
|
|
"engines": { "node": ">=18" },
|
|
"bin": { "foo": "./bin/foo.js" },
|
|
}),
|
|
);
|
|
assert_eq!(env.get("npm_package_name").map(String::as_str), Some("pkg"));
|
|
assert_eq!(env.get("npm_package_config_port").map(String::as_str), Some("3000"));
|
|
assert_eq!(
|
|
env.get("npm_package_config_deep_nested").map(String::as_str),
|
|
Some("value"),
|
|
"recursion must keep going beneath config/* — only the top-level filter restricts",
|
|
);
|
|
assert_eq!(env.get("npm_package_engines_node").map(String::as_str), Some(">=18"));
|
|
assert_eq!(env.get("npm_package_bin_foo").map(String::as_str), Some("./bin/foo.js"));
|
|
}
|
|
|
|
#[test]
|
|
fn stamp_package_handles_arrays() {
|
|
let mut env = HashMap::new();
|
|
stamp_package(&mut env, "npm_package_", &json!({"name":"a","bin":["./a","./b"]}));
|
|
assert_eq!(env.get("npm_package_bin_0").map(String::as_str), Some("./a"));
|
|
assert_eq!(env.get("npm_package_bin_1").map(String::as_str), Some("./b"));
|
|
}
|
|
|
|
#[test]
|
|
fn sanitize_env_key_matches_upstream_regex() {
|
|
assert_eq!(sanitize_env_key("npm_package_name"), "npm_package_name");
|
|
assert_eq!(sanitize_env_key("npm_package_@scope/foo"), "npm_package__scope_foo");
|
|
assert_eq!(sanitize_env_key("npm_package_a-b.c"), "npm_package_a_b_c");
|
|
assert_eq!(sanitize_env_key("npm_package_já"), "npm_package_j_");
|
|
}
|
|
|
|
#[test]
|
|
fn is_stamping_key_is_case_sensitive_on_posix() {
|
|
assert!(is_stamping_key("npm_package_name", false));
|
|
assert!(!is_stamping_key("NPM_PACKAGE_NAME", false));
|
|
assert!(!is_stamping_key("npm_config_user_agent", false));
|
|
assert!(!is_stamping_key("npm_config_platform_arch", false));
|
|
assert!(!is_stamping_key("pnpm_config_registry", false));
|
|
assert!(is_stamping_key("npm_config__auth", false));
|
|
assert!(is_stamping_key("npm_config__authToken", false));
|
|
assert!(is_stamping_key("npm_config_@scope:registry", false));
|
|
assert!(is_stamping_key("npm_config_//registry.npmjs.org/:_authToken", false));
|
|
assert!(is_stamping_key("npm_config_foo:_bar", false));
|
|
assert!(is_stamping_key("pnpm_config__authToken", false));
|
|
assert!(!is_stamping_key("NPM_CONFIG__AUTH", false));
|
|
assert!(!is_stamping_key("npm_lifecycle_event", false));
|
|
assert!(!is_stamping_key("Npm_Lifecycle_Event", false));
|
|
assert!(is_stamping_key("NODE", false));
|
|
assert!(!is_stamping_key("Node", false));
|
|
assert!(!is_stamping_key("node", false));
|
|
assert!(!is_stamping_key("TMPDIR", false));
|
|
assert!(is_stamping_key("INIT_CWD", false));
|
|
assert!(is_stamping_key("PNPM_SCRIPT_SRC_DIR", false));
|
|
assert!(!is_stamping_key("PNPM_HOME", false));
|
|
assert!(is_stamping_key(DEV_PREINSTALL_ALREADY_RAN_ENV, false));
|
|
assert!(!is_stamping_key(&DEV_PREINSTALL_ALREADY_RAN_ENV.to_lowercase(), false));
|
|
assert!(is_stamping_key(&DEV_PREINSTALL_ALREADY_RAN_ENV.to_lowercase(), true));
|
|
}
|
|
|
|
/// The marker describes the install currently running. Leaving it in a
|
|
/// script's env would make a nested install started by that script
|
|
/// treat its own root hook as already run.
|
|
#[test]
|
|
fn the_dev_preinstall_delegation_marker_never_reaches_a_script() {
|
|
let mut parent = HashMap::new();
|
|
parent.insert(DEV_PREINSTALL_ALREADY_RAN_ENV.into(), "true".into());
|
|
|
|
let pkg_root = Path::new("/tmp/nested");
|
|
// Whichever way the value arrives: inherited above, or named by a
|
|
// user's `extraEnv`, which is merged in after the parent-env filter.
|
|
let mut extra = empty_extra();
|
|
extra.insert(DEV_PREINSTALL_ALREADY_RAN_ENV.into(), "true".into());
|
|
let built = build_env(
|
|
&base_opts(pkg_root, pkg_root, &extra),
|
|
&json!({ "name": "nested", "version": "1.0.0" }),
|
|
parent,
|
|
);
|
|
|
|
assert_eq!(built.env.get(DEV_PREINSTALL_ALREADY_RAN_ENV), None);
|
|
}
|
|
|
|
/// On Windows a differently-cased spelling is the same variable, so an
|
|
/// `extraEnv` naming it that way must be dropped too.
|
|
#[test]
|
|
fn a_differently_cased_delegation_marker_is_dropped_on_windows() {
|
|
assert!(is_dev_preinstall_marker(DEV_PREINSTALL_ALREADY_RAN_ENV, false));
|
|
assert!(!is_dev_preinstall_marker(&DEV_PREINSTALL_ALREADY_RAN_ENV.to_lowercase(), false));
|
|
assert!(is_dev_preinstall_marker(&DEV_PREINSTALL_ALREADY_RAN_ENV.to_lowercase(), true));
|
|
assert!(!is_dev_preinstall_marker("PNPM_INTERNAL_SOMETHING_ELSE", true));
|
|
}
|
|
|
|
/// Regression: the byte-level prefix check inside the Windows
|
|
/// branch must not panic on non-ASCII keys whose UTF-8 byte
|
|
/// representation crosses byte 4. Slicing `key[..4]` panics for
|
|
/// e.g. `"x𐀀"` (1 ASCII byte + a 4-byte codepoint), since byte 4
|
|
/// lands inside the codepoint.
|
|
#[test]
|
|
fn is_stamping_key_handles_non_ascii_keys_without_panicking() {
|
|
// 1 ASCII byte + 4-byte UTF-8 codepoint = 5 bytes; byte 4 is
|
|
// mid-char. Comparison must be byte-safe.
|
|
assert!(!is_stamping_key("x\u{10000}", true));
|
|
assert!(!is_stamping_key("x\u{10000}", false));
|
|
// 2-byte codepoint repeated: byte 4 IS a char boundary, but
|
|
// the leading bytes are not ASCII so the comparison must
|
|
// still return false.
|
|
assert!(!is_stamping_key("\u{0419}\u{0419}", true));
|
|
// 1-character ASCII (< 4 bytes): byte slice short-circuits.
|
|
assert!(!is_stamping_key("abc", true));
|
|
assert!(!is_stamping_key("", true));
|
|
}
|
|
|
|
/// On Windows, Rust's `Command::env` treats env keys
|
|
/// case-insensitively, so `NPM_PACKAGE_FOO` and `npm_package_foo`
|
|
/// refer to the same variable. We must strip each stamped family
|
|
/// case-insensitively or our inserts collide at spawn time with an
|
|
/// unpredictable winner.
|
|
#[test]
|
|
fn is_stamping_key_is_case_insensitive_on_windows() {
|
|
assert!(is_stamping_key("npm_package_name", true));
|
|
assert!(is_stamping_key("NPM_PACKAGE_NAME", true));
|
|
assert!(!is_stamping_key("npm_config_platform_arch", true));
|
|
assert!(!is_stamping_key("NPM_CONFIG_PLATFORM_ARCH", true));
|
|
// The prefix is matched case-insensitively; the `_`/`/`/`@`/`:_`
|
|
// markers are ASCII and case-agnostic.
|
|
assert!(is_stamping_key("npm_config__auth", true));
|
|
assert!(is_stamping_key("NPM_CONFIG__AUTH", true));
|
|
assert!(is_stamping_key("npm_config_@scope:registry", true));
|
|
assert!(!is_stamping_key("Npm_Lifecycle_Event", true));
|
|
assert!(is_stamping_key("NODE", true));
|
|
assert!(is_stamping_key("Node", true));
|
|
assert!(is_stamping_key("node", true));
|
|
assert!(!is_stamping_key("tmpdir", true));
|
|
assert!(is_stamping_key("init_cwd", true));
|
|
assert!(is_stamping_key("pnpm_script_src_dir", true));
|
|
assert!(!is_stamping_key("NPM", true));
|
|
assert!(!is_stamping_key("npm", true));
|
|
assert!(!is_stamping_key("PNPM_HOME", true));
|
|
assert!(!is_stamping_key("pnpm_home", true));
|
|
}
|
|
|
|
#[test]
|
|
fn escape_newlines_json_encodes_multi_line_only() {
|
|
assert_eq!(escape_newlines("plain"), "plain");
|
|
assert_eq!(escape_newlines("a\nb"), r#""a\nb""#);
|
|
assert_eq!(escape_newlines(r#"has "quotes""#), r#"has "quotes""#);
|
|
}
|