Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
27 changes: 25 additions & 2 deletions cli/args/flags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1913,9 +1913,17 @@ pub fn flags_from_vec_with_initial_cwd(
}
}
_ => {
// Node.js compatibility: `--interactive` / `-i` at the top level
// forces REPL mode regardless of any other flags.
let interactive_flag = matches
.try_get_one::<bool>("interactive")
.ok()
.flatten()
.copied()
.unwrap_or(false);
let has_non_globals = app
.get_arguments()
.filter(|arg| !arg.is_global_set())
.filter(|arg| !arg.is_global_set() && arg.get_id() != "interactive")
.any(|arg| {
matches
.value_source(arg.get_id().as_str())
Expand All @@ -1924,7 +1932,9 @@ pub fn flags_from_vec_with_initial_cwd(
})
});

if has_non_globals || matches.contains_id("script_arg") {
if !interactive_flag
&& (has_non_globals || matches.contains_id("script_arg"))
{
run_parse(&mut flags, &mut matches, app, true)?;
} else {
handle_repl_flags(
Expand Down Expand Up @@ -2137,6 +2147,19 @@ pub fn clap_root() -> Command {
.action(ArgAction::SetTrue)
.global(true),
)
.arg(
// Node.js compatibility: `node --interactive` / `node -i` forces REPL
// mode. We accept it as a no-op flag at the top level so that scripts
// spawning the binary the same way fall through to Deno's default
// REPL behavior. Deliberately not global() — only valid when no
// subcommand is given.
Arg::new("interactive")
.short('i')
.long("interactive")
.help("Enter REPL mode (Node.js compatibility)")
.action(ArgAction::SetTrue)
.hide(true),
)
Comment thread
littledivy marked this conversation as resolved.
Outdated
.subcommand(run_subcommand())
.subcommand(serve_subcommand())
.defer(|cmd| {
Expand Down
14 changes: 14 additions & 0 deletions cli/factory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,20 @@ impl CliFactory {
self.services.workspace_factory.get_or_try_init(|| {
let initial_cwd = match self.overrides.initial_cwd.clone() {
Some(v) => v,
// For modes that don't depend on a real cwd (REPL, eval), fall back
// to a sentinel path when current_dir() fails — matches Node.js
// semantics where `node --interactive` works even after the cwd has
// been unlinked.
None
if matches!(
self.flags.subcommand,
DenoSubcommand::Repl(_) | DenoSubcommand::Eval(_)
) =>
{
crate::util::env::resolve_cwd_or_fallback(
self.flags.initial_cwd.as_deref(),
)
}
None => {
crate::util::env::resolve_cwd(self.flags.initial_cwd.as_deref())?
.into_owned()
Expand Down
27 changes: 27 additions & 0 deletions cli/util/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use std::collections::HashSet;
use std::env;
use std::ffi::OsString;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::OnceLock;
Expand All @@ -32,6 +33,32 @@ pub fn resolve_cwd(
}
}

/// Like `resolve_cwd`, but falls back to a sensible default (the system
/// root) when the current working directory can't be determined — for
/// example when it has been unlinked. This matches Node.js semantics where
/// the REPL still starts even if the parent process's cwd was deleted.
pub fn resolve_cwd_or_fallback(initial_cwd: Option<&Path>) -> PathBuf {
match resolve_cwd(initial_cwd) {
Ok(cwd) => cwd.into_owned(),
Err(_) => fallback_cwd(),
}
}

fn fallback_cwd() -> PathBuf {
if cfg!(windows) {
// System drive root, e.g. `C:\`.
std::env::var_os("SystemDrive")
.map(|d| {
let mut p = PathBuf::from(d);
p.push("\\");
p
})
.unwrap_or_else(|| PathBuf::from("C:\\"))
} else {
PathBuf::from("/")
}
}

#[derive(Debug, Clone)]
struct WatchEnvTrackerInner {
// Track all loaded variables and their values
Expand Down
95 changes: 78 additions & 17 deletions ext/process/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,7 @@ fn create_command(
state: &mut OpState,
mut args: SpawnArgs,
api_name: &str,
allow_cwd_inherit: bool,
) -> Result<CreateCommand, ProcessError> {
let maybe_npm_process_state = if args.needs_npm_process_state {
let provider = state.borrow::<NpmProcessStateProviderRc>();
Expand All @@ -553,6 +554,7 @@ fn create_command(
args.clear_env,
state,
api_name,
allow_cwd_inherit,
)?;
let mut command = Command::new(cmd);

Expand All @@ -576,7 +578,9 @@ fn create_command(
command.args(args.args);
}

command.current_dir(run_env.cwd);
if run_env.set_cwd_on_command {
command.current_dir(&run_env.cwd);
}
command.env_clear();
command.envs(run_env.envs.into_iter().map(|(k, v)| (k.into_inner(), v)));

Expand Down Expand Up @@ -1059,14 +1063,14 @@ fn compute_run_cmd_and_check_permissions(
arg_clear_env: bool,
state: &mut OpState,
api_name: &str,
allow_cwd_inherit: bool,
) -> Result<(PathBuf, RunEnv), ProcessError> {
let run_env =
compute_run_env(arg_cwd, arg_envs, arg_clear_env).map_err(|e| {
ProcessError::SpawnFailed {
compute_run_env(arg_cwd, arg_envs, arg_clear_env, allow_cwd_inherit)
.map_err(|e| ProcessError::SpawnFailed {
command: arg_cmd.to_string(),
error: Box::new(e),
}
})?;
})?;
let cmd =
resolve_cmd(arg_cmd, &run_env).map_err(|e| ProcessError::SpawnFailed {
command: arg_cmd.to_string(),
Expand Down Expand Up @@ -1138,28 +1142,57 @@ impl std::cmp::PartialEq for EnvVarKey {

struct RunEnv {
envs: HashMap<EnvVarKey, OsString>,
/// Best-effort cwd used for resolving relative cmd paths and PATH lookups.
/// When the cwd cannot be determined and the caller permits inheritance
/// (see `set_cwd_on_command`), this is set to `"."` as a placeholder.
cwd: PathBuf,
/// When `false`, the spawned `Command` should not have its cwd set
/// explicitly so that the child inherits the parent's (possibly unlinked)
/// cwd. This matches Node.js semantics for `child_process.spawn` and is
/// only enabled for Node-compat code paths.
set_cwd_on_command: bool,
}

/// Computes the current environment, which will then be used to inform
/// permissions and finally spawning. This is very important to compute
/// ahead of time so that the environment used to verify permissions is
/// the same environment used to spawn the sub command. This protects against
/// someone doing timing attacks by changing the environment on a worker.
///
/// `allow_cwd_inherit` controls whether spawning is allowed to proceed when
/// no explicit cwd was passed and `current_dir()` fails (e.g. the parent's
/// cwd has been unlinked). Only Node-compat ops opt into this; Deno's own
/// `Deno.run` / `Deno.Command` keep the existing strict behavior.
fn compute_run_env(
arg_cwd: Option<&str>,
arg_envs: &[(String, String)],
arg_clear_env: bool,
allow_cwd_inherit: bool,
) -> Result<RunEnv, ProcessError> {
#[allow(
clippy::disallowed_methods,
reason = "ok for now because launching a sub process requires the real fs"
)]
let cwd =
std::env::current_dir().map_err(ProcessError::FailedResolvingCwd)?;
let cwd = arg_cwd
.map(|cwd_arg| resolve_path(cwd_arg, &cwd))
.unwrap_or(cwd);
let current_dir = std::env::current_dir();
let (cwd, set_cwd_on_command) = match arg_cwd {
Some(cwd_arg) => {
let arg_path = Path::new(cwd_arg);
if arg_path.is_absolute() {
(
deno_path_util::normalize_path(Cow::Borrowed(arg_path)).into_owned(),
true,
)
} else {
let base = current_dir.map_err(ProcessError::FailedResolvingCwd)?;
(resolve_path(cwd_arg, &base), true)
}
}
None => match current_dir {
Ok(c) => (c, true),
Err(_) if allow_cwd_inherit => (PathBuf::from("."), false),
Err(e) => return Err(ProcessError::FailedResolvingCwd(e)),
},
};
let envs = if arg_clear_env {
arg_envs
.iter()
Expand All @@ -1174,17 +1207,36 @@ fn compute_run_env(
}
envs
};
Ok(RunEnv { envs, cwd })
Ok(RunEnv {
envs,
cwd,
set_cwd_on_command,
})
}

fn resolve_cmd(cmd: &str, env: &RunEnv) -> Result<PathBuf, ProcessError> {
let is_path = cmd.contains('/');
#[cfg(windows)]
let is_path = is_path || cmd.contains('\\') || Path::new(&cmd).is_absolute();
if is_path {
Ok(resolve_path(cmd, &env.cwd))
let cmd_path = Path::new(cmd);
if cmd_path.is_absolute() {
Ok(deno_path_util::normalize_path(Cow::Borrowed(cmd_path)).into_owned())
} else if !env.set_cwd_on_command {
// Relative cmd path can't be resolved without a known cwd.
Err(ProcessError::FailedResolvingCwd(std::io::Error::from(
std::io::ErrorKind::NotFound,
)))
} else {
Ok(resolve_path(cmd, &env.cwd))
}
} else {
let path = env.envs.get(&EnvVarKey::new(OsString::from("PATH")));
// When the cwd is unknown (`set_cwd_on_command == false`) `env.cwd` is
// a placeholder `"."`. PATH-resolvable names don't need a real cwd; for
// unqualified names that fall back to a cwd-relative search this is a
// best-effort lookup that will simply miss when the parent's cwd has
// been unlinked.
match deno_permissions::which::which_in(
sys_traits::impls::RealSys,
cmd,
Expand Down Expand Up @@ -1291,7 +1343,7 @@ fn op_spawn_child(
) -> Result<Child, ProcessError> {
let detached = args.detached;
let (command, pipe_rid, extra_pipe_fds, handles_to_close) =
create_command(state, args, &api_name)?;
create_command(state, args, &api_name, /* allow_cwd_inherit */ false)?;
let child = spawn_child(state, command, pipe_rid, extra_pipe_fds, detached);
for handle in handles_to_close {
deno_io::close_raw_handle(handle);
Expand All @@ -1308,8 +1360,10 @@ fn op_node_spawn_child(
#[string] api_name: String,
) -> Result<NodeChild, ProcessError> {
let detached = args.detached;
// `child_process.spawn` in Node tolerates the parent's cwd being unlinked
// by inheriting it, so allow cwd inheritance for Node-compat spawns.
let (command, pipe_rid, extra_pipe_fds, handles_to_close) =
create_command(state, args, &api_name)?;
create_command(state, args, &api_name, /* allow_cwd_inherit */ true)?;
let child =
spawn_child_node(state, command, pipe_rid, extra_pipe_fds, detached);
for handle in handles_to_close {
Expand Down Expand Up @@ -1356,8 +1410,12 @@ fn op_spawn_sync(
let timeout = args.timeout;
#[cfg(unix)]
let kill_signal = args.kill_signal.clone();
let (mut command, _, _, _) =
create_command(state, args, "Deno.Command().outputSync()")?;
let (mut command, _, _, _) = create_command(
state,
args,
"Deno.Command().outputSync()",
/* allow_cwd_inherit */ false,
)?;

// When timeout is specified on Unix, create a new process group so we can
// kill the entire tree (shell + children) on timeout, not just the shell.
Expand Down Expand Up @@ -1656,6 +1714,7 @@ mod deprecated {
/* clear env */ false,
state,
"Deno.run()",
/* allow_cwd_inherit */ false,
)?;

#[cfg(windows)]
Expand All @@ -1665,7 +1724,9 @@ mod deprecated {
for arg in args.iter().skip(1) {
c.arg(arg);
}
c.current_dir(run_env.cwd);
if run_env.set_cwd_on_command {
c.current_dir(&run_env.cwd);
}

c.env_clear();
for (key, value) in run_env.envs {
Expand Down
1 change: 1 addition & 0 deletions tests/node_compat/config.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,7 @@
"parallel/test-crypto-verify-failure.js": {},
"parallel/test-crypto-webcrypto-aes-decrypt-tag-too-small.js": {},
"parallel/test-crypto-worker-thread.js": {},
"parallel/test-cwd-enoent-repl.js": {},
"parallel/test-datetime-change-notify.js": {},
"parallel/test-debugger-address.mjs": {
"ignore": true,
Expand Down
Loading