Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/nub-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3634,6 +3634,7 @@ fn build_script_command(
);
let aug = nub_core::node::spawn::compute_augmentation_env(
&nub_binary,
node.path.as_std_path(),
node.version,
compat_mode,
pnp_ctx.as_ref().map(|c| c.pnp_cjs.as_path()),
Expand Down Expand Up @@ -4558,11 +4559,13 @@ fn run_watch(file: &str, args: &[String]) -> Result<i32> {
let mut node_args = vec!["--watch".to_string(), "--watch-preserve-output".to_string()];

let node_options = env::var("NODE_OPTIONS").ok();
let accepted = nub_core::node::discovery::accepted_env_flags(node.path.as_std_path());
let inject = nub_core::node::flags::compute_inject_flags(
node.version.clone(),
args,
node_options.as_deref(),
false,
accepted.as_ref(),
);
for flag in &inject {
node_args.push(flag.to_string());
Expand Down Expand Up @@ -4923,6 +4926,7 @@ fn apply_exec_augmentation(cmd: &mut std::process::Command, cwd: &Path) {
let pnp_ctx = nub_core::pnp::detect(cwd);
let Some(aug) = nub_core::node::spawn::compute_augmentation_env(
&nub_binary,
node.path.as_std_path(),
node.version,
false,
pnp_ctx.as_ref().map(|c| c.pnp_cjs.as_path()),
Expand Down
1 change: 1 addition & 0 deletions crates/nub-cli/src/pm_engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1393,6 +1393,7 @@ fn apply_lifecycle_augmentation(cwd: &Path) {
let pnp_ctx = nub_core::pnp::detect(cwd);
let Some(aug) = nub_core::node::spawn::compute_augmentation_env(
&nub_binary,
node.path.as_std_path(),
node.version,
// Lifecycle scripts are never compat: PM verbs run augmented (there is
// no `--node` lifecycle path).
Expand Down
116 changes: 116 additions & 0 deletions crates/nub-core/src/node/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1097,6 +1097,122 @@ fn write_version_cache(node_path: &Path, version: &NodeVersion) {
);
}

/// The set of flag NAMES the given Node binary accepts (its
/// `process.allowedNodeEnvironmentFlags`) — the authoritative universe of flags
/// nub may inject or propagate via `NODE_OPTIONS`. Spawns the binary once with a
/// tiny `-e` probe and caches the result on disk keyed by (path, mtime), so every
/// repeat call is spawn-free (one probe per binary, ever). Returns `None` if the
/// probe cannot run or produces nothing usable — callers then fall back to plain
/// version-band gating rather than dropping flags they still need.
///
/// ## Why an authoritative probe and not a static binary byte-scan
/// The option names ARE embedded as string literals in the Node binary, so a
/// `strings | grep` would appear to work — but a string match is not proof the
/// option is a LIVE accepted flag (it can sit in help text or unrelated data), and
/// injecting a non-accepted flag is the exact `node: bad option` startup abort this
/// guards against. `allowedNodeEnvironmentFlags` is Node's own ground truth.
///
/// ## Why this is needed (the open-ended-`Unflag`-band hazard)
/// Node does NOT keep every experimental flag forever. Most unflagged flags survive
/// as accepted no-ops (`--experimental-fetch`, `--experimental-modules`, …), but
/// some are HARD-REMOVED on a later major — `--experimental-policy` and
/// `--experimental-network-imports` are gone, and `--experimental-permission` was
/// accepted through Node 23.11 then deleted at 24.0 (it stabilized to
/// `--permission`). An open-ended `Unflag [lo, ∞)` band in the feature matrix would
/// keep injecting such a flag into every future Node and abort it at startup.
/// Intersecting the version-band inject set with this probe makes injection
/// self-correcting: a flag the running Node no longer accepts is simply dropped, no
/// nub release required.
pub fn accepted_env_flags(node_path: &Path) -> Option<std::collections::BTreeSet<String>> {
if let Some(cached) = read_env_flags_cache(node_path) {
return Some(cached);
}

// Clear an inherited NODE_OPTIONS for the probe. `node -e` (unlike `node
// --version`) runs NODE_OPTIONS `--require`/`--import` preloads, so an inherited
// preload that writes to stdout would pollute the flag list, and a preload with
// side effects would run for nothing. The accepted-flag set is a static property
// of the binary, independent of NODE_OPTIONS — so removing it makes the probe
// deterministic and side-effect-free. (Pollution could only ADD tokens, never
// drop a real flag, so this is defense-in-depth, not a correctness fix.)
let output = Command::new(node_path)
.arg("-e")
.arg("process.stdout.write([...process.allowedNodeEnvironmentFlags].join(\"\\n\"))")
.env_remove("NODE_OPTIONS")
.output()
.ok()?;
if !output.status.success() {
return None;
}

let stdout = String::from_utf8_lossy(&output.stdout);
let flags: std::collections::BTreeSet<String> =
stdout.split_whitespace().map(str::to_string).collect();
if flags.is_empty() {
return None;
}

write_env_flags_cache(node_path, &flags);
Some(flags)
}

/// mtime (seconds since epoch) of a Node binary, for cache-key freshness. `None`
/// if the path can't be stat'd — a miss then forces a fresh probe.
fn node_mtime_secs(node_path: &Path) -> Option<u64> {
fs::metadata(node_path)
.ok()?
.modified()
.ok()?
.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|d| d.as_secs())
}

/// Read the cached `allowedNodeEnvironmentFlags` set for a binary, honoring the
/// (path, mtime) key so a rebuilt/replaced Node at the same path re-probes. Kept in
/// a sibling file to `node-discovery.json` so it never risks the version cache.
fn read_env_flags_cache(node_path: &Path) -> Option<std::collections::BTreeSet<String>> {
let cache = cache_dir()?.join("node-env-flags.json");
let content = fs::read_to_string(&cache).ok()?;
let data: serde_json::Value = serde_json::from_str(&content).ok()?;
let key = node_path.to_string_lossy();
let entry = data.get(key.as_ref())?;
let cached_mtime = entry.get("mtime")?.as_u64()?;

if cached_mtime != node_mtime_secs(node_path)? {
return None;
}

let arr = entry.get("flags")?.as_array()?;
Some(
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect(),
)
}

fn write_env_flags_cache(node_path: &Path, flags: &std::collections::BTreeSet<String>) {
let Some(dir) = cache_dir() else { return };
let _ = fs::create_dir_all(&dir);
let cache = dir.join("node-env-flags.json");

let mut data: serde_json::Value = fs::read_to_string(&cache)
.ok()
.and_then(|c| serde_json::from_str(&c).ok())
.unwrap_or_else(|| serde_json::json!({}));

let key = node_path.to_string_lossy().to_string();
data[key] = serde_json::json!({
"flags": flags.iter().collect::<Vec<_>>(),
"mtime": node_mtime_secs(node_path).unwrap_or(0),
});

let _ = fs::write(
&cache,
serde_json::to_string_pretty(&data).unwrap_or_default(),
);
}

/// Scan the nvm install directory for a version matching the pin.
fn scan_nvm(pin: &VersionPin) -> Option<ResolvedNode> {
let nvm_dir = nvm_dir()?;
Expand Down
Loading
Loading