From 3a3610d12ebfaedce85f3e877338500da9e7f8dc Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 10:15:10 -0500 Subject: [PATCH] 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. --- .../corpus/7036-child-env-path-lookup.ts.json | 6 ++ packages/runtime/src/scr_child.c | 62 +++++++++++++------ tests/corpus/7036-child-env-path-lookup.ts | 40 ++++++++++++ .../7036-child-env-path-lookup.ts.json | 1 + 4 files changed, 89 insertions(+), 20 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7036-child-env-path-lookup.ts.json create mode 100644 tests/corpus/7036-child-env-path-lookup.ts create mode 100644 tests/harness/corpus-costs/7036-child-env-path-lookup.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7036-child-env-path-lookup.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7036-child-env-path-lookup.ts.json new file mode 100644 index 000000000..171ab060a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7036-child-env-path-lookup.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7036-child-env-path-lookup.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_child.c b/packages/runtime/src/scr_child.c index df60fcb26..c1d98b35b 100644 --- a/packages/runtime/src/scr_child.c +++ b/packages/runtime/src/scr_child.c @@ -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); diff --git a/tests/corpus/7036-child-env-path-lookup.ts b/tests/corpus/7036-child-env-path-lookup.ts new file mode 100644 index 000000000..cac0670e9 --- /dev/null +++ b/tests/corpus/7036-child-env-path-lookup.ts @@ -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, "")}`; +}; + +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 }); + }); +}); diff --git a/tests/harness/corpus-costs/7036-child-env-path-lookup.ts.json b/tests/harness/corpus-costs/7036-child-env-path-lookup.ts.json new file mode 100644 index 000000000..f9bfce7d9 --- /dev/null +++ b/tests/harness/corpus-costs/7036-child-env-path-lookup.ts.json @@ -0,0 +1 @@ +{ "plain": 2570, "san": 4110 }