Skip to content

Commit 72bb441

Browse files
committed
Fix: improve escaping for script arguments
**Summary** Extra command-line arguments to scripts were not being escaped correctly. This patch adds robust shell quoting logic for both Windows and Linux/macOS. **Test plan** On *nix, create a `package.json` containing `"scripts":{"echo":"echo"}`. Run `yarn run -s echo -- '$X \"blah\"'`. Expect to observe ` \blah\` prior to this patch, and `$X \"blah\"` after it. Testing on Windows should be similar, but may require fancier escaping to get the arguments into yarn in the first place.
1 parent f072e96 commit 72bb441

File tree

5 files changed

+86
-7
lines changed

5 files changed

+86
-7
lines changed
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/* @flow */
2+
3+
import makeTemp from '../_temp.js';
4+
import * as fs from '../../src/util/fs.js';
5+
import {run as buildRun} from './_helpers.js';
6+
import {BufferReporter} from '../../src/reporters/index.js';
7+
import {run} from '../../src/cli/commands/run.js';
8+
9+
const path = require('path');
10+
11+
const fixturesLoc = path.join(__dirname, '..', 'fixtures', 'run');
12+
const runRun = buildRun.bind(null, BufferReporter, fixturesLoc, (args, flags, config, reporter): Promise<void> => {
13+
return run(config, reporter, flags, args);
14+
});
15+
16+
jasmine.DEFAULT_TIMEOUT_INTERVAL = 150000;
17+
18+
test('fully quote and escape extra args to run', async () => {
19+
const trickyStrings = ['$X', '%X%', '^', '!', '\\', '>', '<', '|', '&', "'", '"', '`', ' '];
20+
const cases = [];
21+
for (const a of trickyStrings) {
22+
for (const b of trickyStrings) {
23+
cases.push(a + b + b + b);
24+
if (a !== b) {
25+
cases.push(a + a + b + b);
26+
cases.push(a + b + a + b);
27+
cases.push(a + b + b + a);
28+
cases.push(a + a + a + b);
29+
cases.push(a + a + b + a);
30+
cases.push(a + b + a + a);
31+
}
32+
}
33+
}
34+
cases.sort(() => Math.random() - 0.5);
35+
const tempDir = await makeTemp();
36+
const prefix = path.resolve(__dirname, '../..');
37+
const origPrefix = process.env.PREFIX;
38+
process.env.PREFIX = prefix;
39+
try {
40+
const batch = 10;
41+
const jobs = [];
42+
for (let i = 0; i < 30; i += batch) {
43+
const args = cases.slice(i, i + batch);
44+
const outFile = path.join(tempDir, `out${Math.random().toString(16).slice(2)}`);
45+
jobs.push(
46+
runRun(['write-args', outFile, ...args], {}, 'escape-args', async () => {
47+
const output = await fs.readFile(outFile);
48+
expect(output).toBe(args.join(' '));
49+
}),
50+
);
51+
}
52+
await Promise.all(jobs);
53+
} finally {
54+
process.env.PREFIX = origPrefix;
55+
await fs.unlink(tempDir);
56+
}
57+
});

__tests__/commands/run.js

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ jasmine.DEFAULT_TIMEOUT_INTERVAL = 90000;
1313
const execCommand: $FlowFixMe = require('../../src/util/execute-lifecycle-script').execCommand;
1414

1515
const path = require('path');
16+
const q = process.platform === 'win32' ? '"' : "'";
1617

1718
beforeEach(() => execCommand.mockClear());
1819

@@ -58,7 +59,7 @@ test('properly handles extra arguments and pre/post scripts', (): Promise<void>
5859
const pkg = await fs.readJson(path.join(config.cwd, 'package.json'));
5960
const poststart = ['poststart', config, pkg.scripts.poststart, config.cwd];
6061
const prestart = ['prestart', config, pkg.scripts.prestart, config.cwd];
61-
const start = ['start', config, pkg.scripts.start + ' "--hello"', config.cwd];
62+
const start = ['start', config, pkg.scripts.start + ` ${q}--hello${q}`, config.cwd];
6263

6364
expect(execCommand.mock.calls[0]).toEqual(prestart);
6465
expect(execCommand.mock.calls[1]).toEqual(start);
@@ -69,7 +70,7 @@ test('properly handles extra arguments and pre/post scripts', (): Promise<void>
6970
test('properly handle bin scripts', (): Promise<void> => {
7071
return runRun(['cat-names'], {}, 'bin', config => {
7172
const script = path.join(config.cwd, 'node_modules', '.bin', 'cat-names');
72-
const args = ['cat-names', config, `"${script}"`, config.cwd];
73+
const args = ['cat-names', config, `${q}${script}${q}`, config.cwd];
7374

7475
expect(execCommand).toBeCalledWith(...args);
7576
});
@@ -88,7 +89,7 @@ test('properly handle env command', (): Promise<void> => {
8889
test('retains string delimiters if args have spaces', (): Promise<void> => {
8990
return runRun(['cat-names', '--filter', 'cat names'], {}, 'bin', config => {
9091
const script = path.join(config.cwd, 'node_modules', '.bin', 'cat-names');
91-
const args = ['cat-names', config, `"${script}" "--filter" "cat names"`, config.cwd];
92+
const args = ['cat-names', config, `${q}${script}${q} ${q}--filter${q} ${q}cat names${q}`, config.cwd];
9293

9394
expect(execCommand).toBeCalledWith(...args);
9495
});
@@ -97,7 +98,9 @@ test('retains string delimiters if args have spaces', (): Promise<void> => {
9798
test('retains quotes if args have spaces and quotes', (): Promise<void> => {
9899
return runRun(['cat-names', '--filter', '"cat names"'], {}, 'bin', config => {
99100
const script = path.join(config.cwd, 'node_modules', '.bin', 'cat-names');
100-
const args = ['cat-names', config, `"${script}" "--filter" "\\"cat names\\""`, config.cwd];
101+
const cq = q === '"' ? '^"' : q;
102+
const dq = q === '"' ? '\\^"' : '"';
103+
const args = ['cat-names', config, `${q}${script}${q} ${q}--filter${q} ${cq}${dq}cat names${dq}${cq}`, config.cwd];
101104

102105
expect(execCommand).toBeCalledWith(...args);
103106
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"scripts": {
3+
"write-args": "node write-args.js"
4+
}
5+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
require('fs').writeFileSync(process.argv[2], process.argv.slice(3).join(' '));

src/cli/commands/run.js

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,22 @@ import map from '../../util/map.js';
1111
const leven = require('leven');
1212
const path = require('path');
1313

14-
// Copied from https://github.com/npm/npm/blob/63f153c743f9354376bfb9dad42bd028a320fd1f/lib/run-script.js#L175
14+
function quoteForCmd(arg: string): string {
15+
// See the below blog post for an explanation of what's going on here:
16+
// eslint-disable-next-line max-len
17+
// https://blogs.msdn.microsoft.com/twistylittlepassagesallalike/2011/04/23/everyone-quotes-command-line-arguments-the-wrong-way/
18+
const caretEscape = /"/.test(arg);
19+
arg = `"${arg.replace(/\\+(?=$|")/g, '$&$&').replace(/"/g, '\\"')}"`;
20+
if (caretEscape) {
21+
arg = arg.replace(/[()%!^"<>&|]/g, '^$&');
22+
}
23+
return arg;
24+
}
25+
26+
const quoteForShell = process.platform === 'win32' ? quoteForCmd : arg => `'${arg.replace(/'/g, "'\\''")}'`;
27+
1528
function joinArgs(args: Array<string>): string {
16-
return args.reduce((joinedArgs, arg) => joinedArgs + ' "' + arg.replace(/"/g, '\\"') + '"', '');
29+
return args.length ? ' ' + args.map(quoteForShell).join(' ') : '';
1730
}
1831

1932
export function setFlags(commander: Object) {}
@@ -35,7 +48,7 @@ export async function run(config: Config, reporter: Reporter, flags: Object, arg
3548
if (await fs.exists(binFolder)) {
3649
for (const name of await fs.readdir(binFolder)) {
3750
binCommands.push(name);
38-
scripts[name] = `"${path.join(binFolder, name)}"`;
51+
scripts[name] = quoteForShell(path.join(binFolder, name));
3952
}
4053
}
4154
visitedBinFolders.add(binFolder);

0 commit comments

Comments
 (0)