diff --git a/lib/internal/child_process.js b/lib/internal/child_process.js index 45ae95614a88b5..e66a09d0a7762f 100644 --- a/lib/internal/child_process.js +++ b/lib/internal/child_process.js @@ -38,6 +38,8 @@ const { const EventEmitter = require('events'); const net = require('net'); const dgram = require('dgram'); +const fs = require('fs'); +const path = require('path'); const inspect = require('internal/util/inspect').inspect; const assert = require('internal/assert'); @@ -73,6 +75,32 @@ const { UV_ESRCH, } = internalBinding('uv'); +function verifyENOENT(file, envPairs) { + if (file === '.' || file.includes(path.sep)) return false; + let envPath; + if (envPairs) { + for (let i = 0; i < envPairs.length; i++) { + const pair = envPairs[i]; + if (pair.startsWith('PATH=')) { + envPath = pair.slice(5); + break; + } + } + } + envPath ||= process.env.PATH; + if (!envPath) return false; + const paths = envPath.split(path.delimiter); + for (let i = 0; i < paths.length; i++) { + const p = paths[i]; + if (!p) continue; + const fullPath = path.resolve(p, file); + if (fs.existsSync(fullPath)) { + return false; + } + } + return true; +} + const { SocketListSend, SocketListReceive } = SocketList; // Lazy loaded for startup performance and to allow monkey patching of @@ -394,8 +422,9 @@ ChildProcess.prototype.spawn = function spawn(options) { const err = this._handle.spawn(options); - // Run-time errors should emit an error, not throw an exception. - if (err === UV_EACCES || + if (err === UV_EACCES && verifyENOENT(this.spawnfile, options.envPairs)) { + process.nextTick(onErrorNT, this, UV_ENOENT); + } else if (err === UV_EACCES || err === UV_EAGAIN || err === UV_EMFILE || err === UV_ENFILE || @@ -1088,6 +1117,10 @@ function maybeClose(subprocess) { function spawnSync(options) { const result = spawn_sync.spawn(options); + if (result.error === UV_EACCES && verifyENOENT(options.file, options.envPairs)) { + result.error = UV_ENOENT; + } + if (result.output && options.encoding && options.encoding !== 'buffer') { for (let i = 0; i < result.output.length; i++) { if (!result.output[i]) diff --git a/test/parallel/test-child-process-spawn-path-access.js b/test/parallel/test-child-process-spawn-path-access.js new file mode 100644 index 00000000000000..6b3da445f5f308 --- /dev/null +++ b/test/parallel/test-child-process-spawn-path-access.js @@ -0,0 +1,105 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const cp = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const tmpdir = require('../common/tmpdir'); +tmpdir.refresh(); + +// Scenario: PATH contains directories that are inaccessible or are actually files. +// The system spawn (execvp) might return EACCES in these cases on some platforms. +// We want to ensure Node.js consistently reports ENOENT if the command is truly missing. + +const noPermDir = path.join(tmpdir.path, 'no-perm-dir'); +fs.mkdirSync(noPermDir); + +const fileInPath = path.join(tmpdir.path, 'file-in-path'); +fs.writeFileSync(fileInPath, ''); + +if (!common.isWindows) { + try { + fs.chmodSync(noPermDir, '000'); + } catch (e) { + // If we can't chmod (e.g. root or weird fs), skip the permission part of the test + // but keep the structure. + console.log('# Skipped chmod 000 on no-perm-dir due to error:', e.message); + } +} + +// Ensure cleanup restores permissions so tmpdir can be removed +process.prependListener('exit', () => { + if (!common.isWindows && fs.existsSync(noPermDir)) { + try { + fs.chmodSync(noPermDir, '777'); + } catch { + // Ignore cleanup errors during exit + } + } +}); + +const env = { ...process.env }; +const sep = path.delimiter; + +// Prepend the problematic entries to PATH +env.PATH = `${noPermDir}${sep}${fileInPath}${sep}${env.PATH}`; + +const command = 'command-that-does-not-exist-at-all-' + Date.now(); + +const child = cp.spawn(command, { env }); + +child.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, 'ENOENT'); + assert.strictEqual(err.syscall, `spawn ${command}`); +})); + +// Also test sync +try { + cp.spawnSync(command, { env }); +} catch (err) { + assert.strictEqual(err.code, 'ENOENT'); +} + +// Case: File exists but is not executable. Should NOT be normalized to ENOENT. +if (!common.isWindows) { + const nonExecFile = path.join(tmpdir.path, 'non-executable'); + fs.writeFileSync(nonExecFile, 'echo "should not run"'); + fs.chmodSync(nonExecFile, '644'); + + const env2 = { ...process.env, PATH: tmpdir.path }; + const child2 = cp.spawn('non-executable', { env: env2 }); + child2.on('error', common.mustCall((err) => { + // It should stay EACCES because the file actually exists + assert.strictEqual(err.code, 'EACCES'); + })); + + // Also test empty PATH entry + const env3 = { ...process.env, PATH: `${path.delimiter}${env.PATH}` }; + const child3 = cp.spawn(command, { env: env3 }); + child3.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, 'ENOENT'); + })); +} + +// Case: No PATH in envPairs +const env4 = { ...process.env }; +delete env4.PATH; +const child4 = cp.spawn(command, { env: env4 }); +child4.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, 'ENOENT'); +})); + +// Case: envPath ||= process.env.PATH (no env passed) +if (!common.isWindows) { + const oldPath = process.env.PATH; + process.env.PATH = `${noPermDir}${path.delimiter}${oldPath}`; + try { + const child5 = cp.spawn(command); + child5.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, 'ENOENT'); + })); + } finally { + process.env.PATH = oldPath; + } +}