diff --git a/doc/api/child_process.md b/doc/api/child_process.md index a32917997d37f1..b5f398663556ec 100644 --- a/doc/api/child_process.md +++ b/doc/api/child_process.md @@ -282,6 +282,10 @@ controller.abort(); -* `file` {string} The name or path of the executable file to run. +* `file` {string|URL} The name or path of the executable file to run. * `args` {string\[]} List of string arguments. * `options` {Object} * `cwd` {string|URL} Current working directory of the child process. @@ -394,6 +398,10 @@ controller.abort(); -* `command` {string} The command to run. +* `command` {string|URL} The command to run. * `args` {string\[]} List of string arguments. * `options` {Object} * `cwd` {string|URL} Current working directory of the child process. @@ -897,6 +909,10 @@ configuration at startup. -* `file` {string} The name or path of the executable file to run. +* `file` {string|URL} The name or path of the executable file to run. * `args` {string\[]} List of string arguments. * `options` {Object} * `cwd` {string|URL} Current working directory of the child process. @@ -1040,6 +1056,10 @@ metacharacters may be used to trigger arbitrary command execution.** -* `command` {string} The command to run. +* `command` {string|URL} The command to run. * `args` {string\[]} List of string arguments. * `options` {Object} * `cwd` {string|URL} Current working directory of the child process. diff --git a/lib/child_process.js b/lib/child_process.js index 449013906e93e5..10bcaacc3a9f06 100644 --- a/lib/child_process.js +++ b/lib/child_process.js @@ -106,7 +106,7 @@ let addAbortListener; * cwd?: string | URL; * detached?: boolean; * env?: Record; - * execPath?: string; + * execPath?: string | URL; * execArgv?: string[]; * gid?: number; * serialization?: string; @@ -302,7 +302,7 @@ function normalizeExecFileArgs(file, args, options, callback) { /** * Spawns the specified file as a shell. - * @param {string} file + * @param {string | URL} file * @param {string[]} [args] * @param {{ * cwd?: string | URL; @@ -545,8 +545,8 @@ function copyProcessEnvToEnv(env, name, optionEnv) { } function normalizeSpawnArguments(file, args, options) { + file = getValidatedPath(file, 'file'); validateString(file, 'file'); - validateArgumentNullCheck(file, 'file'); if (file.length === 0) throw new ERR_INVALID_ARG_VALUE('file', file, 'cannot be empty'); @@ -730,7 +730,7 @@ function abortChildProcess(child, killSignal, reason) { /** * Spawns a new process using the given `file`. - * @param {string} file + * @param {string | URL} file * @param {string[]} [args] * @param {{ * cwd?: string | URL; @@ -800,7 +800,7 @@ function spawn(file, args, options) { /** * Spawns a new process synchronously using the given `file`. - * @param {string} file + * @param {string | URL} file * @param {string[]} [args] * @param {{ * cwd?: string | URL; diff --git a/test/parallel/test-child-process-urlfile.mjs b/test/parallel/test-child-process-urlfile.mjs new file mode 100644 index 00000000000000..381f2c2c7f6ef9 --- /dev/null +++ b/test/parallel/test-child-process-urlfile.mjs @@ -0,0 +1,293 @@ +import { isWindows, mustCall, mustSucceed, mustNotCall } from '../common/index.mjs'; +import { strictEqual, throws } from 'node:assert'; +import cp from 'node:child_process'; +import url from 'node:url'; +import tmpdir from '../common/tmpdir.js'; + +tmpdir.refresh(); + +const cwd = tmpdir.path; +const cwdUrl = url.pathToFileURL(cwd); +const whichCommand = isWindows ? 'where.exe cmd.exe' : 'which pwd'; +const pwdFullPath = `${cp.execSync(whichCommand)}`.trim(); +const pwdWHATWGUrl = url.pathToFileURL(pwdFullPath); + +// Test for WHATWG URL instance, legacy URL, and URL-like object +for (const pwdUrl of [ + pwdWHATWGUrl, + new url.URL(pwdWHATWGUrl), + { + href: pwdWHATWGUrl.href, + origin: pwdWHATWGUrl.origin, + protocol: pwdWHATWGUrl.protocol, + username: pwdWHATWGUrl.username, + password: pwdWHATWGUrl.password, + host: pwdWHATWGUrl.host, + hostname: pwdWHATWGUrl.hostname, + port: pwdWHATWGUrl.port, + pathname: pwdWHATWGUrl.pathname, + search: pwdWHATWGUrl.search, + searchParams: new URLSearchParams(pwdWHATWGUrl.searchParams), + hash: pwdWHATWGUrl.hash, + }, +]) { + const pwdCommandAndOptions = isWindows ? + [pwdUrl, ['/d', '/c', 'cd'], { cwd: cwdUrl }] : + [pwdUrl, [], { cwd: cwdUrl }]; + + { + cp.execFile(...pwdCommandAndOptions, mustSucceed((stdout) => { + strictEqual(`${stdout}`.trim(), cwd); + })); + } + + { + const stdout = cp.execFileSync(...pwdCommandAndOptions); + strictEqual(`${stdout}`.trim(), cwd); + } + + { + const pwd = cp.spawn(...pwdCommandAndOptions); + pwd.stdout.on('data', mustCall((stdout) => { + strictEqual(`${stdout}`.trim(), cwd); + })); + pwd.stderr.on('data', mustNotCall()); + pwd.on('close', mustCall((code) => { + strictEqual(code, 0); + })); + } + + { + const stdout = cp.spawnSync(...pwdCommandAndOptions).stdout; + strictEqual(`${stdout}`.trim(), cwd); + } + +} + +// Test for non-URL objects +for (const badFile of [ + Buffer.from(pwdFullPath), + {}, + { href: pwdWHATWGUrl.href }, + { pathname: pwdWHATWGUrl.pathname }, +]) { + const pwdCommandAndOptions = isWindows ? + [badFile, ['/d', '/c', 'cd'], { cwd: cwdUrl }] : + [badFile, [], { cwd: cwdUrl }]; + + // Passing an object that doesn't have shape of WHATWG URL object + // results in TypeError + + throws( + () => cp.execFile(...pwdCommandAndOptions, mustNotCall()), + { code: 'ERR_INVALID_ARG_TYPE' }, + ); + + throws( + () => cp.execFileSync(...pwdCommandAndOptions), + { code: 'ERR_INVALID_ARG_TYPE' }, + ); + + throws( + () => cp.spawn(...pwdCommandAndOptions), + { code: 'ERR_INVALID_ARG_TYPE' }, + ); + + throws( + () => cp.spawnSync(...pwdCommandAndOptions), + { code: 'ERR_INVALID_ARG_TYPE' }, + ); +} + +// Test for non-file: URL objects +for (const badFile of [ + new URL('https://nodejs.org/file:///'), + new url.URL('https://nodejs.org/file:///'), + { + href: 'https://nodejs.org/file:///', + origin: 'https://nodejs.org', + protocol: 'https:', + username: '', + password: '', + host: 'nodejs.org', + hostname: 'nodejs.org', + port: '', + pathname: '/file:///', + search: '', + searchParams: new URLSearchParams(), + hash: '' + }, +]) { + const pwdCommandAndOptions = isWindows ? + [badFile, ['/d', '/c', 'cd'], { cwd: cwdUrl }] : + [badFile, [], { cwd: cwdUrl }]; + + // Passing an URL object with protocol other than `file:` + // results in TypeError + + throws( + () => cp.execFile(...pwdCommandAndOptions, mustNotCall()), + { code: 'ERR_INVALID_URL_SCHEME' }, + ); + + throws( + () => cp.execFileSync(...pwdCommandAndOptions), + { code: 'ERR_INVALID_URL_SCHEME' }, + ); + + throws( + () => cp.spawn(...pwdCommandAndOptions), + { code: 'ERR_INVALID_URL_SCHEME' }, + ); + + throws( + () => cp.spawnSync(...pwdCommandAndOptions), + { code: 'ERR_INVALID_URL_SCHEME' }, + ); +} + +// Test for malformed file URL objects +// On Windows, non-empty host is allowed +if (!isWindows) { + for (const badFile of [ + new URL('file://nodejs.org/file:///'), + new url.URL('file://nodejs.org/file:///'), + { + href: 'file://nodejs.org/file:///', + origin: 'null', + protocol: 'file:', + username: '', + password: '', + host: 'nodejs.org', + hostname: 'nodejs.org', + port: '', + pathname: '/file:///', + search: '', + searchParams: new URLSearchParams(), + hash: '' + }, + ]) { + const pwdCommandAndOptions = [badFile, [], { cwd: cwdUrl }]; + + // Passing an URL object with non-empty host + // results in TypeError + + throws( + () => cp.execFile(...pwdCommandAndOptions, mustNotCall()), + { code: 'ERR_INVALID_FILE_URL_HOST' }, + ); + + throws( + () => cp.execFileSync(...pwdCommandAndOptions), + { code: 'ERR_INVALID_FILE_URL_HOST' }, + ); + + throws( + () => cp.spawn(...pwdCommandAndOptions), + { code: 'ERR_INVALID_FILE_URL_HOST' }, + ); + + throws( + () => cp.spawnSync(...pwdCommandAndOptions), + { code: 'ERR_INVALID_FILE_URL_HOST' }, + ); + } +} + +// Test for file URL objects with %2F in path +const urlWithSlash = new URL(pwdWHATWGUrl); +urlWithSlash.pathname += '%2F'; +for (const badFile of [ + urlWithSlash, + new url.URL(urlWithSlash), + { + href: urlWithSlash.href, + origin: urlWithSlash.origin, + protocol: urlWithSlash.protocol, + username: urlWithSlash.username, + password: urlWithSlash.password, + host: urlWithSlash.host, + hostname: urlWithSlash.hostname, + port: urlWithSlash.port, + pathname: urlWithSlash.pathname, + search: urlWithSlash.search, + searchParams: new URLSearchParams(urlWithSlash.searchParams), + hash: urlWithSlash.hash, + }, +]) { + const pwdCommandAndOptions = isWindows ? + [badFile, ['/d', '/c', 'cd'], { cwd: cwdUrl }] : + [badFile, [], { cwd: cwdUrl }]; + + // Passing an URL object with percent-encoded '/' + // results in TypeError + + throws( + () => cp.execFile(...pwdCommandAndOptions, mustNotCall()), + { code: 'ERR_INVALID_FILE_URL_PATH' }, + ); + + throws( + () => cp.execFileSync(...pwdCommandAndOptions), + { code: 'ERR_INVALID_FILE_URL_PATH' }, + ); + + throws( + () => cp.spawn(...pwdCommandAndOptions), + { code: 'ERR_INVALID_FILE_URL_PATH' }, + ); + + throws( + () => cp.spawnSync(...pwdCommandAndOptions), + { code: 'ERR_INVALID_FILE_URL_PATH' }, + ); +} + +// Test for file URL objects with %00 in path +const urlWithNul = new URL(pwdWHATWGUrl); +urlWithNul.pathname += '%00'; +for (const badFile of [ + urlWithNul, + new url.URL(urlWithNul), + { + href: urlWithNul.href, + origin: urlWithNul.origin, + protocol: urlWithNul.protocol, + username: urlWithNul.username, + password: urlWithNul.password, + host: urlWithNul.host, + hostname: urlWithNul.hostname, + port: urlWithNul.port, + pathname: urlWithNul.pathname, + search: urlWithNul.search, + searchParams: new URLSearchParams(urlWithNul.searchParams), + hash: urlWithNul.hash, + }, +]) { + const pwdCommandAndOptions = isWindows ? + [badFile, ['/d', '/c', 'cd'], { cwd: cwdUrl }] : + [badFile, [], { cwd: cwdUrl }]; + + // Passing an URL object with percent-encoded '\0' + // results in TypeError + + throws( + () => cp.execFile(...pwdCommandAndOptions, mustNotCall()), + { code: 'ERR_INVALID_ARG_VALUE' }, + ); + + throws( + () => cp.execFileSync(...pwdCommandAndOptions), + { code: 'ERR_INVALID_ARG_VALUE' }, + ); + + throws( + () => cp.spawn(...pwdCommandAndOptions), + { code: 'ERR_INVALID_ARG_VALUE' }, + ); + + throws( + () => cp.spawnSync(...pwdCommandAndOptions), + { code: 'ERR_INVALID_ARG_VALUE' }, + ); +}