Skip to content

Commit

Permalink
Better escaping of result.escapedCommand (#875)
Browse files Browse the repository at this point in the history
  • Loading branch information
ehmicky committed Mar 2, 2024
1 parent 146fa07 commit 7b044bb
Show file tree
Hide file tree
Showing 6 changed files with 149 additions and 31 deletions.
4 changes: 3 additions & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,7 +654,9 @@ type ExecaCommonReturnValue<IsSync extends boolean = boolean, OptionsType extend
/**
Same as `command` but escaped.
This is meant to be copied and pasted into a shell, for debugging purposes.
Unlike `command`, control characters are escaped, which makes it safe to print in a terminal.
This can also be copied and pasted into a shell, for debugging purposes.
Since the escaping is fairly basic, this should not be executed directly as a process, including using `execa()` or `execaCommand()`.
*/
escapedCommand: string;
Expand Down
62 changes: 57 additions & 5 deletions lib/arguments/escape.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,64 @@
import {platform} from 'node:process';

export const joinCommand = (filePath, rawArgs) => {
const fileAndArgs = [filePath, ...rawArgs];
const command = fileAndArgs.join(' ');
const escapedCommand = fileAndArgs.map(arg => escapeArg(arg)).join(' ');
const escapedCommand = fileAndArgs.map(arg => quoteString(escapeControlCharacters(arg))).join(' ');
return {command, escapedCommand};
};

const escapeArg = arg => typeof arg === 'string' && !NO_ESCAPE_REGEXP.test(arg)
? `"${arg.replaceAll('"', '\\"')}"`
: arg;
const escapeControlCharacters = arg => typeof arg === 'string'
? arg.replaceAll(SPECIAL_CHAR_REGEXP, character => escapeControlCharacter(character))
: String(arg);

const escapeControlCharacter = character => {
const commonEscape = COMMON_ESCAPES[character];
if (commonEscape !== undefined) {
return commonEscape;
}

const codepoint = character.codePointAt(0);
const codepointHex = codepoint.toString(16);
return codepoint <= ASTRAL_START
? `\\u${codepointHex.padStart(4, '0')}`
: `\\U${codepointHex}`;
};

// Characters that would create issues when printed are escaped using the \u or \U notation.
// Those include control characters and newlines.
// The \u and \U notation is Bash specific, but there is no way to do this in a shell-agnostic way.
// Some shells do not even have a way to print those characters in an escaped fashion.
// Therefore, we prioritize printing those safely, instead of allowing those to be copy-pasted.
// List of Unicode character categories: https://www.fileformat.info/info/unicode/category/index.htm
const SPECIAL_CHAR_REGEXP = /\p{Separator}|\p{Other}/gu;

// Accepted by $'...' in Bash.
// Exclude \a \e \v which are accepted in Bash but not in JavaScript (except \v) and JSON.
const COMMON_ESCAPES = {
' ': ' ',
'\b': '\\b',
'\f': '\\f',
'\n': '\\n',
'\r': '\\r',
'\t': '\\t',
};

// Up until that codepoint, \u notation can be used instead of \U
const ASTRAL_START = 65_535;

// Some characters are shell-specific, i.e. need to be escaped when the command is copy-pasted then run.
// Escaping is shell-specific. We cannot know which shell is used: `process.platform` detection is not enough.
// For example, Windows users could be using `cmd.exe`, Powershell or Bash for Windows which all use different escaping.
// We use '...' on Unix, which is POSIX shell compliant and escape all characters but ' so this is fairly safe.
// On Windows, we assume cmd.exe is used and escape with "...", which also works with Powershell.
const quoteString = escapedArg => {
if (NO_ESCAPE_REGEXP.test(escapedArg)) {
return escapedArg;
}

return platform === 'win32'
? `"${escapedArg.replaceAll('"', '""')}"`
: `'${escapedArg.replaceAll('\'', '\'\\\'\'')}'`;
};

const NO_ESCAPE_REGEXP = /^[\w.-]+$/;
const NO_ESCAPE_REGEXP = /^[\w./-]+$/;
4 changes: 3 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,9 @@ Type: `string`

Same as [`command`](#command-1) but escaped.

This is meant to be copied and pasted into a shell, for debugging purposes.
Unlike `command`, control characters are escaped, which makes it safe to print in a terminal.

This can also be copied and pasted into a shell, for debugging purposes.
Since the escaping is fairly basic, this should not be executed directly as a process, including using [`execa()`](#execafile-arguments-options) or [`execaCommand()`](#execacommandcommand-options).

#### cwd
Expand Down
90 changes: 74 additions & 16 deletions test/arguments/escape.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import {platform} from 'node:process';
import test from 'ava';
import {execa, execaSync} from '../../index.js';
import {setFixtureDir} from '../helpers/fixtures-dir.js';

setFixtureDir();

const isWindows = platform === 'win32';

const testResultCommand = async (t, expected, ...args) => {
const {command: failCommand} = await t.throwsAsync(execa('fail.js', args));
t.is(failCommand, `fail.js${expected}`);
Expand All @@ -18,28 +21,83 @@ test(testResultCommand, ' foo bar', 'foo', 'bar');
test(testResultCommand, ' baz quz', 'baz', 'quz');
test(testResultCommand, '');

const testEscapedCommand = async (t, expected, args) => {
const {escapedCommand: failEscapedCommand} = await t.throwsAsync(execa('fail.js', args));
t.is(failEscapedCommand, `fail.js ${expected}`);
const testEscapedCommand = async (t, args, expectedUnix, expectedWindows) => {
const expected = isWindows ? expectedWindows : expectedUnix;

t.like(
await t.throwsAsync(execa('fail.js', args)),
{escapedCommand: `fail.js ${expected}`},
);

const {escapedCommand: failEscapedCommandSync} = t.throws(() => {
t.like(t.throws(() => {
execaSync('fail.js', args);
});
t.is(failEscapedCommandSync, `fail.js ${expected}`);
}), {escapedCommand: `fail.js ${expected}`});

const {escapedCommand} = await execa('noop.js', args);
t.is(escapedCommand, `noop.js ${expected}`);
t.like(
await execa('noop.js', args),
{escapedCommand: `noop.js ${expected}`},
);

const {escapedCommand: escapedCommandSync} = execaSync('noop.js', args);
t.is(escapedCommandSync, `noop.js ${expected}`);
t.like(
execaSync('noop.js', args),
{escapedCommand: `noop.js ${expected}`},
);
};

testEscapedCommand.title = (message, expected) => `result.escapedCommand is: ${JSON.stringify(expected)}`;

test(testEscapedCommand, 'foo bar', ['foo', 'bar']);
test(testEscapedCommand, '"foo bar"', ['foo bar']);
test(testEscapedCommand, '"\\"foo\\""', ['"foo"']);
test(testEscapedCommand, '"*"', ['*']);
test('result.escapedCommand - foo bar', testEscapedCommand, ['foo', 'bar'], 'foo bar', 'foo bar');
test('result.escapedCommand - foo\\ bar', testEscapedCommand, ['foo bar'], '\'foo bar\'', '"foo bar"');
test('result.escapedCommand - "foo"', testEscapedCommand, ['"foo"'], '\'"foo"\'', '"""foo"""');
test('result.escapedCommand - \'foo\'', testEscapedCommand, ['\'foo\''], '\'\'\\\'\'foo\'\\\'\'\'', '"\'foo\'"');
test('result.escapedCommand - "0"', testEscapedCommand, ['0'], '0', '0');
test('result.escapedCommand - 0', testEscapedCommand, [0], '0', '0');
test('result.escapedCommand - *', testEscapedCommand, ['*'], '\'*\'', '"*"');
test('result.escapedCommand - .', testEscapedCommand, ['.'], '.', '.');
test('result.escapedCommand - -', testEscapedCommand, ['-'], '-', '-');
test('result.escapedCommand - _', testEscapedCommand, ['_'], '_', '_');
test('result.escapedCommand - /', testEscapedCommand, ['/'], '/', '/');
test('result.escapedCommand - ,', testEscapedCommand, [','], '\',\'', '","');
test('result.escapedCommand - :', testEscapedCommand, [':'], '\':\'', '":"');
test('result.escapedCommand - ;', testEscapedCommand, [';'], '\';\'', '";"');
test('result.escapedCommand - ~', testEscapedCommand, ['~'], '\'~\'', '"~"');
test('result.escapedCommand - %', testEscapedCommand, ['%'], '\'%\'', '"%"');
test('result.escapedCommand - $', testEscapedCommand, ['$'], '\'$\'', '"$"');
test('result.escapedCommand - !', testEscapedCommand, ['!'], '\'!\'', '"!"');
test('result.escapedCommand - ?', testEscapedCommand, ['?'], '\'?\'', '"?"');
test('result.escapedCommand - #', testEscapedCommand, ['#'], '\'#\'', '"#"');
test('result.escapedCommand - &', testEscapedCommand, ['&'], '\'&\'', '"&"');
test('result.escapedCommand - =', testEscapedCommand, ['='], '\'=\'', '"="');
test('result.escapedCommand - @', testEscapedCommand, ['@'], '\'@\'', '"@"');
test('result.escapedCommand - ^', testEscapedCommand, ['^'], '\'^\'', '"^"');
test('result.escapedCommand - `', testEscapedCommand, ['`'], '\'`\'', '"`"');
test('result.escapedCommand - |', testEscapedCommand, ['|'], '\'|\'', '"|"');
test('result.escapedCommand - +', testEscapedCommand, ['+'], '\'+\'', '"+"');
test('result.escapedCommand - \\', testEscapedCommand, ['\\'], '\'\\\'', '"\\"');
test('result.escapedCommand - ()', testEscapedCommand, ['()'], '\'()\'', '"()"');
test('result.escapedCommand - {}', testEscapedCommand, ['{}'], '\'{}\'', '"{}"');
test('result.escapedCommand - []', testEscapedCommand, ['[]'], '\'[]\'', '"[]"');
test('result.escapedCommand - <>', testEscapedCommand, ['<>'], '\'<>\'', '"<>"');
test('result.escapedCommand - ã', testEscapedCommand, ['ã'], '\'ã\'', '"ã"');
test('result.escapedCommand - \\a', testEscapedCommand, ['\u0007'], '\'\\u0007\'', '"\\u0007"');
test('result.escapedCommand - \\b', testEscapedCommand, ['\b'], '\'\\b\'', '"\\b"');
test('result.escapedCommand - \\e', testEscapedCommand, ['\u001B'], '\'\\u001b\'', '"\\u001b"');
test('result.escapedCommand - \\f', testEscapedCommand, ['\f'], '\'\\f\'', '"\\f"');
test('result.escapedCommand - \\n', testEscapedCommand, ['\n'], '\'\\n\'', '"\\n"');
test('result.escapedCommand - \\r\\n', testEscapedCommand, ['\r\n'], '\'\\r\\n\'', '"\\r\\n"');
test('result.escapedCommand - \\t', testEscapedCommand, ['\t'], '\'\\t\'', '"\\t"');
test('result.escapedCommand - \\v', testEscapedCommand, ['\v'], '\'\\u000b\'', '"\\u000b"');
test('result.escapedCommand - \\x01', testEscapedCommand, ['\u0001'], '\'\\u0001\'', '"\\u0001"');
test('result.escapedCommand - \\x7f', testEscapedCommand, ['\u007F'], '\'\\u007f\'', '"\\u007f"');
test('result.escapedCommand - \\u0085', testEscapedCommand, ['\u0085'], '\'\\u0085\'', '"\\u0085"');
test('result.escapedCommand - \\u2000', testEscapedCommand, ['\u2000'], '\'\\u2000\'', '"\\u2000"');
test('result.escapedCommand - \\u200E', testEscapedCommand, ['\u200E'], '\'\\u200e\'', '"\\u200e"');
test('result.escapedCommand - \\u2028', testEscapedCommand, ['\u2028'], '\'\\u2028\'', '"\\u2028"');
test('result.escapedCommand - \\u2029', testEscapedCommand, ['\u2029'], '\'\\u2029\'', '"\\u2029"');
test('result.escapedCommand - \\u5555', testEscapedCommand, ['\u5555'], '\'\u5555\'', '"\u5555"');
test('result.escapedCommand - \\uD800', testEscapedCommand, ['\uD800'], '\'\\ud800\'', '"\\ud800"');
test('result.escapedCommand - \\uE000', testEscapedCommand, ['\uE000'], '\'\\ue000\'', '"\\ue000"');
test('result.escapedCommand - \\U1D172', testEscapedCommand, ['\u{1D172}'], '\'\u{1D172}\'', '"\u{1D172}"');
test('result.escapedCommand - \\U1D173', testEscapedCommand, ['\u{1D173}'], '\'\\U1d173\'', '"\\U1d173"');
test('result.escapedCommand - \\U10FFFD', testEscapedCommand, ['\u{10FFFD}'], '\'\\U10fffd\'', '"\\U10fffd"');

test('allow commands with spaces and no array arguments', async t => {
const {stdout} = await execa('command with space.js');
Expand Down
16 changes: 10 additions & 6 deletions test/arguments/verbose.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import {platform} from 'node:process';
import test from 'ava';
import {execa} from '../../index.js';
import {setFixtureDir} from '../helpers/fixtures-dir.js';

setFixtureDir();

const normalizeTimestamp = output => output.replaceAll(/\d/g, '0');
const isWindows = platform === 'win32';

const normalizeTimestamp = stderr => stderr.replaceAll(/^\[\d{2}:\d{2}:\d{2}.\d{3}]/gm, testTimestamp);
const testTimestamp = '[00:00:00.000]';

test('Prints command when "verbose" is true', async t => {
Expand All @@ -21,13 +24,14 @@ test('Prints command with NODE_DEBUG=execa', async t => {

test('Escape verbose command', async t => {
const {stderr} = await execa('nested.js', [JSON.stringify({verbose: true, stdio: 'inherit'}), 'noop.js', 'one two', '"'], {all: true});
t.true(stderr.endsWith('"one two" "\\""'));
t.true(stderr.endsWith(isWindows ? '"one two" """"' : '\'one two\' \'"\''));
});

test('Verbose option works with inherit', async t => {
const {all} = await execa('verbose-script.js', {all: true, env: {NODE_DEBUG: 'execa'}});
t.is(normalizeTimestamp(all), `${testTimestamp} node -e "console.error(\\"one\\")"
one
${testTimestamp} node -e "console.error(\\"two\\")"
two`);
const quote = isWindows ? '"' : '\'';
t.is(normalizeTimestamp(all), `${testTimestamp} node -e ${quote}console.error(1)${quote}
1
${testTimestamp} node -e ${quote}console.error(2)${quote}
2`);
});
4 changes: 2 additions & 2 deletions test/fixtures/verbose-script.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
import {$} from '../../index.js';

const $$ = $({stdio: 'inherit'});
await $$`node -e console.error("one")`;
await $$`node -e console.error("two")`;
await $$`node -e console.error(1)`;
await $$`node -e console.error(2)`;

0 comments on commit 7b044bb

Please sign in to comment.