Skip to content

Commit bbc3947

Browse files
committed
fix: correctly double escape when script runs a known .cmd file
1 parent 2520f32 commit bbc3947

File tree

2 files changed

+193
-13
lines changed

2 files changed

+193
-13
lines changed

lib/make-spawn-args.js

+49-13
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ const isWindows = require('./is-windows.js')
33
const setPATH = require('./set-path.js')
44
const { chmodSync: chmod, unlinkSync: unlink, writeFileSync: writeFile } = require('fs')
55
const { tmpdir } = require('os')
6-
const { resolve } = require('path')
6+
const { isAbsolute, resolve } = require('path')
77
const which = require('which')
88
const npm_config_node_gyp = require.resolve('node-gyp/bin/node-gyp.js')
99
const escape = require('./escape.js')
@@ -20,35 +20,71 @@ const makeSpawnArgs = options => {
2020
stdioString = false,
2121
} = options
2222

23+
const spawnEnv = setPATH(path, {
24+
// we need to at least save the PATH environment var
25+
...process.env,
26+
...env,
27+
npm_package_json: resolve(path, 'package.json'),
28+
npm_lifecycle_event: event,
29+
npm_lifecycle_script: cmd,
30+
npm_config_node_gyp,
31+
})
32+
2333
let scriptFile
2434
let script = ''
35+
2536
const isCmd = /(?:^|\\)cmd(?:\.exe)?$/i.test(scriptShell)
2637
if (isCmd) {
38+
let initialCmd = ''
39+
let insideQuotes = false
40+
for (let i = 0; i < cmd.length; ++i) {
41+
const char = cmd.charAt(i)
42+
if (char === ' ' && !insideQuotes) {
43+
break
44+
}
45+
46+
initialCmd += char
47+
if (char === '"' || char === "'") {
48+
insideQuotes = !insideQuotes
49+
}
50+
}
51+
52+
let pathToInitial
53+
try {
54+
pathToInitial = which.sync(initialCmd, { path: spawnEnv.path, pathext: spawnEnv.pathext })
55+
} catch (err) {
56+
pathToInitial = initialCmd
57+
}
58+
59+
const doubleEscape = pathToInitial.toLowerCase().endsWith('.cmd')
60+
2761
scriptFile = resolve(tmpdir(), `${event}-${Date.now()}.cmd`)
2862
script += '@echo off\n'
29-
script += `${cmd} ${args.map((arg) => escape.cmd(arg)).join(' ')}`
63+
const escapedArgs = args.map((arg) => {
64+
let result = escape.cmd(arg)
65+
if (doubleEscape) {
66+
result = escape.cmd(result)
67+
}
68+
return result
69+
})
70+
script += `${cmd} ${escapedArgs.join(' ')}`
3071
} else {
31-
const shellPath = which.sync(scriptShell)
72+
const shebang = isAbsolute(scriptShell)
73+
? `#!${scriptShell}`
74+
: `#!/usr/bin/env ${scriptShell}`
3275
scriptFile = resolve(tmpdir(), `${event}-${Date.now()}.sh`)
33-
script += `#!${shellPath}\n`
76+
script += `${shebang}\n`
3477
script += `${cmd} ${args.map((arg) => escape.sh(arg)).join(' ')}`
3578
}
79+
3680
writeFile(scriptFile, script)
3781
if (!isCmd) {
3882
chmod(scriptFile, '0775')
3983
}
4084
const spawnArgs = isCmd ? ['/d', '/s', '/c', scriptFile] : ['-c', scriptFile]
4185

4286
const spawnOpts = {
43-
env: setPATH(path, {
44-
// we need to at least save the PATH environment var
45-
...process.env,
46-
...env,
47-
npm_package_json: resolve(path, 'package.json'),
48-
npm_lifecycle_event: event,
49-
npm_lifecycle_script: cmd,
50-
npm_config_node_gyp,
51-
}),
87+
env: spawnEnv,
5288
stdioString,
5389
stdio,
5490
cwd: path,

test/make-spawn-args.js

+144
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ if (isWindows) {
8181
windowsVerbatimArguments: true,
8282
}, 'got expected options')
8383

84+
const contents = fs.readFileSync(args[args.length - 1], { encoding: 'utf8' })
85+
// the contents will have a trailing space if no args are passed
86+
t.equal(contents, `@echo off\nscript "quoted parameter"; second command `)
8487
t.ok(fs.existsSync(args[args.length - 1]), 'script file was written')
8588
cleanup()
8689
t.not(fs.existsSync(args[args.length - 1]), 'cleanup removes script file')
@@ -93,6 +96,7 @@ if (isWindows) {
9396
whichPaths.set('blrorp', '/bin/blrorp')
9497
t.teardown(() => {
9598
whichPaths.delete('blrorp')
99+
delete process.env.ComSpec
96100
})
97101
const [shell, args, opts, cleanup] = makeSpawnArgs({
98102
event: 'event',
@@ -147,6 +151,114 @@ if (isWindows) {
147151
t.end()
148152
})
149153

154+
t.test('single escapes when initial command is not a batch file', (t) => {
155+
whichPaths.set('script', '/path/script.exe')
156+
t.teardown(() => whichPaths.delete('script'))
157+
158+
const [shell, args, opts, cleanup] = makeSpawnArgs({
159+
event: 'event',
160+
path: 'path',
161+
cmd: 'script',
162+
args: ['"quoted parameter";', 'second command'],
163+
})
164+
t.equal(shell, 'cmd', 'default shell applies')
165+
t.match(args, ['/d', '/s', '/c', /\.cmd$/], 'got expected args')
166+
t.match(opts, {
167+
env: {
168+
npm_package_json: /package\.json$/,
169+
npm_lifecycle_event: 'event',
170+
npm_lifecycle_script: 'script',
171+
npm_config_node_gyp: require.resolve('node-gyp/bin/node-gyp.js'),
172+
},
173+
stdio: undefined,
174+
cwd: 'path',
175+
windowsVerbatimArguments: true,
176+
}, 'got expected options')
177+
178+
const contents = fs.readFileSync(args[args.length - 1], { encoding: 'utf8' })
179+
t.equal(contents, `@echo off\nscript ^"\\^"quoted parameter\\^";^" ^"second command^"`)
180+
t.ok(fs.existsSync(args[args.length - 1]), 'script file was written')
181+
cleanup()
182+
t.not(fs.existsSync(args[args.length - 1]), 'cleanup removes script file')
183+
184+
t.end()
185+
})
186+
187+
t.test('double escapes when initial command is a batch file', (t) => {
188+
whichPaths.set('script', '/path/script.cmd')
189+
t.teardown(() => whichPaths.delete('script'))
190+
191+
const [shell, args, opts, cleanup] = makeSpawnArgs({
192+
event: 'event',
193+
path: 'path',
194+
cmd: 'script',
195+
args: ['"quoted parameter";', 'second command'],
196+
})
197+
t.equal(shell, 'cmd', 'default shell applies')
198+
t.match(args, ['/d', '/s', '/c', /\.cmd$/], 'got expected args')
199+
t.match(opts, {
200+
env: {
201+
npm_package_json: /package\.json$/,
202+
npm_lifecycle_event: 'event',
203+
npm_lifecycle_script: 'script',
204+
npm_config_node_gyp: require.resolve('node-gyp/bin/node-gyp.js'),
205+
},
206+
stdio: undefined,
207+
cwd: 'path',
208+
windowsVerbatimArguments: true,
209+
}, 'got expected options')
210+
211+
const contents = fs.readFileSync(args[args.length - 1], { encoding: 'utf8' })
212+
t.equal(contents, [
213+
'@echo off',
214+
`script ^"^^\\^"\\^^\\^"quoted parameter\\^^\\^";^^\\^"^" ^"^^\\^"second command^^\\^"^"`,
215+
].join('\n'))
216+
t.ok(fs.existsSync(args[args.length - 1]), 'script file was written')
217+
cleanup()
218+
t.not(fs.existsSync(args[args.length - 1]), 'cleanup removes script file')
219+
220+
t.end()
221+
})
222+
223+
t.test('correctly identifies initial cmd with spaces', (t) => {
224+
// we do blind lookups in our test fixture here, however node-which
225+
// will remove surrounding quotes
226+
whichPaths.set('"my script"', '/path/script.cmd')
227+
t.teardown(() => whichPaths.delete('my script'))
228+
229+
const [shell, args, opts, cleanup] = makeSpawnArgs({
230+
event: 'event',
231+
path: 'path',
232+
cmd: '"my script"',
233+
args: ['"quoted parameter";', 'second command'],
234+
})
235+
t.equal(shell, 'cmd', 'default shell applies')
236+
t.match(args, ['/d', '/s', '/c', /\.cmd$/], 'got expected args')
237+
t.match(opts, {
238+
env: {
239+
npm_package_json: /package\.json$/,
240+
npm_lifecycle_event: 'event',
241+
npm_lifecycle_script: 'script',
242+
npm_config_node_gyp: require.resolve('node-gyp/bin/node-gyp.js'),
243+
},
244+
stdio: undefined,
245+
cwd: 'path',
246+
windowsVerbatimArguments: true,
247+
}, 'got expected options')
248+
249+
const contents = fs.readFileSync(args[args.length - 1], { encoding: 'utf8' })
250+
t.equal(contents, [
251+
'@echo off',
252+
// eslint-disable-next-line max-len
253+
`"my script" ^"^^\\^"\\^^\\^"quoted parameter\\^^\\^";^^\\^"^" ^"^^\\^"second command^^\\^"^"`,
254+
].join('\n'))
255+
t.ok(fs.existsSync(args[args.length - 1]), 'script file was written')
256+
cleanup()
257+
t.not(fs.existsSync(args[args.length - 1]), 'cleanup removes script file')
258+
259+
t.end()
260+
})
261+
150262
t.end()
151263
})
152264
} else {
@@ -176,6 +288,38 @@ if (isWindows) {
176288
windowsVerbatimArguments: undefined,
177289
}, 'got expected options')
178290

291+
const contents = fs.readFileSync(args[args.length - 1], { encoding: 'utf8' })
292+
t.equal(contents, `#!/usr/bin/env sh\nscript '"quoted parameter";' 'second command'`)
293+
t.ok(fs.existsSync(args[args.length - 1]), 'script file was written')
294+
cleanup()
295+
t.not(fs.existsSync(args[args.length - 1]), 'cleanup removes script file')
296+
297+
t.end()
298+
})
299+
300+
t.test('skips /usr/bin/env if scriptShell is absolute', (t) => {
301+
const [shell, args, opts, cleanup] = makeSpawnArgs({
302+
event: 'event',
303+
path: 'path',
304+
cmd: 'script',
305+
args: ['"quoted parameter";', 'second command'],
306+
scriptShell: '/bin/sh',
307+
})
308+
t.equal(shell, '/bin/sh', 'kept provided setting')
309+
t.match(args, ['-c', /\.sh$/], 'got expected args')
310+
t.match(opts, {
311+
env: {
312+
npm_package_json: /package\.json$/,
313+
npm_lifecycle_event: 'event',
314+
npm_lifecycle_script: 'script',
315+
},
316+
stdio: undefined,
317+
cwd: 'path',
318+
windowsVerbatimArguments: undefined,
319+
}, 'got expected options')
320+
321+
const contents = fs.readFileSync(args[args.length - 1], { encoding: 'utf8' })
322+
t.equal(contents, `#!/bin/sh\nscript '"quoted parameter";' 'second command'`)
179323
t.ok(fs.existsSync(args[args.length - 1]), 'script file was written')
180324
cleanup()
181325
t.not(fs.existsSync(args[args.length - 1]), 'cleanup removes script file')

0 commit comments

Comments
 (0)