Look up spawn and execFileSync commands on the replacement environment's PATH

spawn and execFileSync with an `env` option searched the parent's PATH
for a command without a '/'. They now resolve it the way spawnSync does,
as Node (libuv) does: on the replacement environment's PATH, or the
default path when it has none, with relative entries read from the cwd
option's directory.
This commit is contained in:
Chris Tate
2026-10-22 10:15:10 -05:00
parent c9ae4d8019
commit 3a3610d12e
4 changed files with 89 additions and 20 deletions
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7036-child-env-path-lookup.ts"
],
"diags": []
}
+42 -20
View File
@@ -3116,9 +3116,12 @@ static void scr_exec_envp_free(char **envp);
* environment, found as libuv finds it: a name with a '/' is used as
* given; otherwise each entry of the replacement's PATH (an empty entry
* is the current directory), or of the default path when the replacement
* has no PATH, is tried in order. Returns a malloc'd path, or NULL with
* *err set: EACCES when a candidate exists but cannot run, else ENOENT. */
static char *scr_spawn_path_lookup(const char *file, char *const *envp, int *err) {
* has no PATH, is tried in order. Relative candidates are checked from
* `cwd`, the directory the child starts in (NULL: this process's). Returns
* a malloc'd path, or NULL with *err set: EACCES when a candidate exists
* but cannot run, else ENOENT. */
static char *scr_spawn_path_lookup(const char *file, char *const *envp, const char *cwd,
int *err) {
if (strchr(file, '/')) {
char *copy = strdup(file);
if (!copy) scr_child_oom();
@@ -3132,6 +3135,7 @@ static char *scr_spawn_path_lookup(const char *file, char *const *envp, int *err
}
}
size_t file_len = strlen(file);
size_t cwd_len = cwd ? strlen(cwd) : 0;
bool denied = false;
for (const char *dir = path;;) {
const char *end = strchr(dir, ':');
@@ -3143,13 +3147,24 @@ static char *scr_spawn_path_lookup(const char *file, char *const *envp, int *err
memcpy(candidate, prefix, prefix_len);
candidate[prefix_len] = '/';
memcpy(candidate + prefix_len + 1, file, file_len + 1);
char *checked = candidate;
if (cwd_len > 0 && candidate[0] != '/') {
checked = malloc(cwd_len + 1 + strlen(candidate) + 1);
if (!checked) scr_child_oom();
memcpy(checked, cwd, cwd_len);
checked[cwd_len] = '/';
strcpy(checked + cwd_len + 1, candidate);
}
struct stat st;
if (stat(candidate, &st) == 0) {
if (S_ISREG(st.st_mode) && access(candidate, X_OK) == 0) return candidate;
denied = true;
bool runs = false;
if (stat(checked, &st) == 0) {
runs = S_ISREG(st.st_mode) && access(checked, X_OK) == 0;
if (!runs) denied = true;
} else if (errno == EACCES) {
denied = true;
}
if (checked != candidate) free(checked);
if (runs) return candidate;
free(candidate);
if (!end) break;
dir = end + 1;
@@ -3158,6 +3173,22 @@ static char *scr_spawn_path_lookup(const char *file, char *const *envp, int *err
return NULL;
}
/* posix_spawnp for the child_process spawns: with `envp` (a replacement
* environment) the command resolves on that environment's PATH
* (scr_spawn_path_lookup) as Node's does; without it, on this process's
* PATH over `environ`. Returns posix_spawn's error number. */
static int scr_spawn_command(pid_t *pid, const char *file, const posix_spawn_file_actions_t *fa,
const posix_spawnattr_t *attr, char *const *argv,
char *const *envp, const char *cwd) {
if (envp == NULL) return posix_spawnp(pid, file, fa, attr, argv, environ);
int err = 0;
char *resolved = scr_spawn_path_lookup(file, envp, cwd, &err);
if (!resolved) return err;
err = posix_spawn(pid, resolved, fa, attr, argv, envp);
free(resolved);
return err;
}
/* env_pairs: NULL inherits the parent's environment; else [k,v,...]
* replaces it, as Node's `env` option does, and the command is looked up
* on the replacement's PATH (scr_spawn_path_lookup). */
@@ -3200,16 +3231,7 @@ static ScrSpawnRes *scr_spawn_sync_core(ScrStr *cmd, ScrArr *args, double timeou
char **argv = scr_child_argv(cmd, args);
char **envp = env_pairs != NULL ? scr_exec_envp(env_pairs) : NULL;
pid_t pid = -1;
int spawn_err;
if (envp != NULL) {
char *resolved = scr_spawn_path_lookup(cmd->data, envp, &spawn_err);
if (resolved) {
spawn_err = posix_spawn(&pid, resolved, &fa, NULL, argv, envp);
free(resolved);
}
} else {
spawn_err = posix_spawnp(&pid, cmd->data, &fa, NULL, argv, environ);
}
int spawn_err = scr_spawn_command(&pid, cmd->data, &fa, NULL, argv, envp, NULL);
posix_spawn_file_actions_destroy(&fa);
free(argv);
scr_exec_envp_free(envp);
@@ -3556,8 +3578,8 @@ ScrStr *scr_exec_sync_core(ScrStr *cmd, ScrArr *args, const ScrExecOpts *o) {
char **argv = scr_child_argv(cmd, args);
char **envp = o->env_pairs != NULL ? scr_exec_envp(o->env_pairs) : NULL;
pid_t pid = -1;
int spawn_err = posix_spawnp(&pid, cmd->data, &fa, NULL, argv,
envp != NULL ? envp : environ);
int spawn_err = scr_spawn_command(&pid, cmd->data, &fa, NULL, argv, envp,
o->cwd != NULL ? o->cwd->data : NULL);
posix_spawn_file_actions_destroy(&fa);
free(argv);
scr_exec_envp_free(envp);
@@ -5115,8 +5137,8 @@ ScrChild *scr_spawn_opts(ScrStr *cmd, ScrArr *args, double in_mode,
char **envp = has_env && env_pairs != NULL ? scr_exec_envp(env_pairs) : NULL;
pid_t pid = -1;
const char *file = shell ? "/bin/sh" : cmd->data;
int spawn_err = posix_spawnp(&pid, file, &fa, &at, argv,
envp != NULL ? envp : environ);
int spawn_err = scr_spawn_command(&pid, file, &fa, &at, argv, envp,
cwd != NULL && cwd->len > 0 ? cwd->data : NULL);
posix_spawnattr_destroy(&at);
posix_spawn_file_actions_destroy(&fa);
if (!shell) free(argv);
@@ -0,0 +1,40 @@
// spawn and execFileSync with an env option find the command on the
// replacement environment's PATH, or on the default path when it has
// none, as Node does; a relative PATH entry is read from the cwd option.
import { execFileSync, spawn } from "node:child_process";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const dir = mkdtempSync(join(tmpdir(), "scr-child-path-"));
mkdirSync(join(dir, "tools"));
writeFileSync(join(dir, "tools", "child-tool"), "#!/bin/sh\necho child-tool $1\n");
chmodSync(join(dir, "tools", "child-tool"), 0o755);
const show = (e: unknown): string => {
const err = e as NodeJS.ErrnoException;
return `${err.code} ${err.message.replace(dir, "<dir>")}`;
};
console.log(execFileSync("child-tool", ["sync"], { env: { PATH: join(dir, "tools") }, encoding: "utf8" }).trim());
console.log(execFileSync("sh", ["-c", "echo default"], { env: { A: "1" }, encoding: "utf8" }).trim());
try {
execFileSync("sh", ["-c", "echo never"], { env: { PATH: "/nonexistent" }, encoding: "utf8" });
} catch (e) {
console.log(show(e));
}
console.log(
execFileSync("child-tool", ["relative"], { env: { PATH: "tools" }, cwd: dir, encoding: "utf8" }).trim(),
);
const child = spawn("child-tool", ["async"], { env: { PATH: join(dir, "tools") }, stdio: ["ignore", "pipe", "ignore"] });
let out = "";
child.stdout!.on("data", (chunk: Buffer) => (out += chunk.toString()));
child.on("close", (code) => {
console.log(out.trim(), code);
const missing = spawn("sh", ["-c", "true"], { env: { PATH: "/nonexistent" }, stdio: "ignore" });
missing.on("error", (e) => {
console.log("error", show(e));
rmSync(dir, { recursive: true });
});
});
@@ -0,0 +1 @@
{ "plain": 2570, "san": 4110 }