Skip to content

Commit b2870e7

Browse files
committed
fix(vscode): avoid shell splitting in subprocesses
Signed-off-by: Vamsi-klu <nrvamsi13@gmail.com>
1 parent 5f1911d commit b2870e7

3 files changed

Lines changed: 168 additions & 32 deletions

File tree

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
import { afterEach, describe, expect, it, vi } from 'vitest'
4+
import fs from 'node:fs'
5+
import path from 'node:path'
6+
import os from 'node:os'
7+
import { execAsync } from './exec'
8+
9+
const { traceInfoMock } = vi.hoisted(() => ({ traceInfoMock: vi.fn() }))
10+
11+
vi.mock('./common/log', () => ({ traceInfo: traceInfoMock }))
12+
13+
/** Create a temporary directory for a test executable. */
14+
function makeTmpDir(): string {
15+
return fs.mkdtempSync(path.join(os.tmpdir(), 'exec-test-'))
16+
}
17+
18+
describe('execAsync', () => {
19+
const tmpdirs: string[] = []
20+
21+
afterEach(() => {
22+
traceInfoMock.mockClear()
23+
for (const dir of tmpdirs.splice(0)) {
24+
fs.rmSync(dir, { recursive: true, force: true })
25+
}
26+
})
27+
28+
it('executes an executable whose path contains a space', async () => {
29+
// Create a temp directory whose name contains a space.
30+
const baseDir = makeTmpDir()
31+
tmpdirs.push(baseDir)
32+
const spacedDir = path.join(baseDir, 'dir with spaces')
33+
fs.mkdirSync(spacedDir)
34+
35+
// Copy the current Node.js binary into the spaced directory.
36+
const isWindows = process.platform === 'win32'
37+
const ext = isWindows ? '.exe' : ''
38+
const destExe = path.join(spacedDir, `node${ext}`)
39+
fs.copyFileSync(process.execPath, destExe)
40+
41+
// Ensure the copied binary is executable on POSIX.
42+
if (!isWindows) {
43+
fs.chmodSync(destExe, 0o755)
44+
}
45+
46+
// The path must contain a space for this test to be meaningful.
47+
expect(destExe).toContain(' ')
48+
49+
const result = await execAsync(destExe, ['--version'])
50+
51+
expect(result.exitCode).toBe(0)
52+
expect(result.stderr).toBe('')
53+
expect(result.stdout.trim()).toBe(process.version)
54+
})
55+
56+
it('passes arguments with spaces and shell metacharacters literally', async () => {
57+
const argument = 'value with spaces; $(not-a-command) & more'
58+
const result = await execAsync(process.execPath, [
59+
'-e',
60+
'process.stdout.write(process.argv[1])',
61+
argument,
62+
])
63+
64+
expect(result.exitCode).toBe(0)
65+
expect(result.stdout).toBe(argument)
66+
expect(result.stderr).toBe('')
67+
})
68+
69+
it('logs an unambiguous argv representation', async () => {
70+
const argument = 'value with spaces\nand "quotes"'
71+
72+
await execAsync(process.execPath, ['-e', '', argument])
73+
74+
expect(traceInfoMock).toHaveBeenCalledWith(
75+
`Executing command: ${JSON.stringify([
76+
process.execPath,
77+
'-e',
78+
'',
79+
argument,
80+
])} in undefined`,
81+
)
82+
})
83+
84+
it('resolves with a nonzero ExecResult instead of rejecting', async () => {
85+
// Run a Node.js one-liner that writes to stdout/stderr and exits nonzero.
86+
const result = await execAsync(process.execPath, [
87+
'-e',
88+
"process.stdout.write('out'); process.stderr.write('err'); process.exit(42)",
89+
])
90+
91+
expect(result.exitCode).toBe(42)
92+
expect(result.stdout).toBe('out')
93+
expect(result.stderr).toBe('err')
94+
})
95+
96+
it('preserves empty stderr for a nonzero exit with no child output', async () => {
97+
const result = await execAsync(process.execPath, ['-e', 'process.exit(7)'])
98+
99+
expect(result.exitCode).toBe(7)
100+
expect(result.stdout).toBe('')
101+
expect(result.stderr).toBe('')
102+
})
103+
104+
it('resolves spawn failures with a useful nonzero ExecResult', async () => {
105+
const missingExecutable = path.join(
106+
os.tmpdir(),
107+
`missing-executable-${process.pid}-${Date.now()}`,
108+
)
109+
expect(fs.existsSync(missingExecutable)).toBe(false)
110+
111+
const result = await execAsync(missingExecutable)
112+
113+
expect(result.exitCode).not.toBe(0)
114+
expect(result.stdout).toBe('')
115+
expect(result.stderr).toContain(path.basename(missingExecutable))
116+
})
117+
118+
it('rejects with AbortError when the signal is aborted', async () => {
119+
const controller = new AbortController()
120+
121+
// Start a long-running child process.
122+
const promise = execAsync(
123+
process.execPath,
124+
['-e', 'setTimeout(() => {}, 60_000)'],
125+
{ signal: controller.signal },
126+
)
127+
128+
// Abort immediately.
129+
controller.abort()
130+
131+
await expect(promise).rejects.toMatchObject({ name: 'AbortError' })
132+
})
133+
})
Lines changed: 27 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { exec, ExecOptions } from 'node:child_process'
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
import { execFile, ExecOptions } from 'node:child_process'
24
import { traceInfo } from './common/log'
35

46
export interface ExecResult {
@@ -12,7 +14,7 @@ export async function execAsync(
1214
args: string[] = [],
1315
options: ExecOptions & { signal?: AbortSignal } = {},
1416
): Promise<ExecResult> {
15-
const fullCmd = `${command} ${args.join(' ')}`
17+
const fullCmd = JSON.stringify([command, ...args])
1618
traceInfo(`Executing command: ${fullCmd} in ${options.cwd}`)
1719

1820
try {
@@ -37,33 +39,30 @@ function execAsyncCore(
3739
options: ExecOptions & { signal?: AbortSignal } = {},
3840
): Promise<ExecResult> {
3941
return new Promise<ExecResult>((resolve, reject) => {
40-
const child = exec(
41-
`${command} ${args.join(' ')}`,
42-
options,
43-
(error, stdout, stderr) => {
44-
if (error) {
45-
// Forward AbortError unchanged so callers can detect cancellation
46-
if ((error as NodeJS.ErrnoException).name === 'AbortError') {
47-
reject(error)
48-
} else {
49-
resolve({
50-
exitCode: typeof error.code === 'number' ? error.code : 1,
51-
stdout,
52-
stderr,
53-
})
54-
}
55-
return
42+
execFile(command, args, options, (error, stdout, stderr) => {
43+
if (error) {
44+
// Forward AbortError unchanged so callers can detect cancellation
45+
if ((error as NodeJS.ErrnoException).name === 'AbortError') {
46+
reject(error as Error)
47+
} else {
48+
const exitCode = typeof error.code === 'number' ? error.code : 1
49+
resolve({
50+
exitCode,
51+
stdout,
52+
stderr:
53+
typeof error.code === 'number'
54+
? stderr
55+
: stderr || error.message,
56+
})
5657
}
58+
return
59+
}
5760

58-
resolve({
59-
exitCode: child.exitCode ?? 0,
60-
stdout,
61-
stderr,
62-
})
63-
},
64-
)
65-
66-
// surface “spawn failed” errors that occur before the callback
67-
child.once('error', reject)
61+
resolve({
62+
exitCode: 0,
63+
stdout,
64+
stderr,
65+
})
66+
})
6867
})
6968
}

‎vscode/extension/tests/utils.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export const createVirtualEnvironment = async (
5555
venvDir: string,
5656
): Promise<PythonEnvironment> => {
5757
// Try to use uv first, fallback to python -m venv
58-
const { exitCode, stderr } = await execAsync(`uv venv "${venvDir}"`)
58+
const { exitCode, stderr } = await execAsync('uv', ['venv', venvDir])
5959
if (exitCode !== 0) {
6060
throw new Error(`Failed to create venv with uv: ${stderr}`)
6161
}
@@ -81,9 +81,13 @@ export const pipInstall = async (
8181
pythonDetails: PythonEnvironment,
8282
packagePaths: string[],
8383
): Promise<void> => {
84-
const packages = packagePaths.map(pkg => `-e "${pkg}"`).join(' ')
85-
const execString = `uv pip install --python "${pythonDetails.pythonPath}" ${packages}`
86-
const { stderr, exitCode } = await execAsync(execString)
84+
const { stderr, exitCode } = await execAsync('uv', [
85+
'pip',
86+
'install',
87+
'--python',
88+
pythonDetails.pythonPath,
89+
...packagePaths.flatMap(pkg => ['-e', pkg]),
90+
])
8791
if (exitCode !== 0) {
8892
throw new Error(`Failed to install package with uv: ${stderr}`)
8993
}

0 commit comments

Comments
 (0)