diff --git a/lib/arguments/escape.js b/lib/arguments/escape.js index a60f37094..48ae3c244 100644 --- a/lib/arguments/escape.js +++ b/lib/arguments/escape.js @@ -38,7 +38,23 @@ const escapeControlCharacter = character => { // 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; +const getSpecialCharRegExp = () => { + try { + // This throws when using Node.js without ICU support. + // When using a RegExp literal, this would throw at parsing-time, instead of runtime. + // eslint-disable-next-line prefer-regex-literals + return new RegExp('\\p{Separator}|\\p{Other}', 'gu'); + } catch { + // Similar to the above RegExp, but works even when Node.js has been built without ICU support. + // Unlike the above RegExp, it only covers whitespaces and C0/C1 control characters. + // It does not cover some edge cases, such as Unicode reserved characters. + // See https://github.com/sindresorhus/execa/issues/1143 + // eslint-disable-next-line no-control-regex + return /[\s\u0000-\u001F\u007F-\u009F\u00AD]/g; + } +}; + +const SPECIAL_CHAR_REGEXP = getSpecialCharRegExp(); // Accepted by $'...' in Bash. // Exclude \a \e \v which are accepted in Bash but not in JavaScript (except \v) and JSON. diff --git a/test/arguments/escape-no-icu.js b/test/arguments/escape-no-icu.js new file mode 100644 index 000000000..424abb5d3 --- /dev/null +++ b/test/arguments/escape-no-icu.js @@ -0,0 +1,16 @@ +// Mimics Node.js when built without ICU support +// See https://github.com/sindresorhus/execa/issues/1143 +globalThis.RegExp = class extends RegExp { + constructor(regExpString, flags) { + if (flags?.includes('u') && regExpString.includes('\\p{')) { + throw new Error('Invalid property name'); + } + + super(regExpString, flags); + } + + static isMocked = true; +}; + +// Execa computes the RegExp when first loaded, so we must delay this import +await import('./escape.js'); diff --git a/test/arguments/escape.js b/test/arguments/escape.js index e6ab994a6..f2e2d7560 100644 --- a/test/arguments/escape.js +++ b/test/arguments/escape.js @@ -21,8 +21,11 @@ test(testResultCommand, ' foo bar', 'foo', 'bar'); test(testResultCommand, ' baz quz', 'baz', 'quz'); test(testResultCommand, ''); -const testEscapedCommand = async (t, commandArguments, expectedUnix, expectedWindows) => { - const expected = isWindows ? expectedWindows : expectedUnix; +// eslint-disable-next-line max-params +const testEscapedCommand = async (t, commandArguments, expectedUnix, expectedWindows, expectedUnixNoIcu = expectedUnix, expectedWindowsNoIcu = expectedWindows) => { + const expected = RegExp.isMocked + ? (isWindows ? expectedWindowsNoIcu : expectedUnixNoIcu) + : (isWindows ? expectedWindows : expectedUnix); t.like( await t.throwsAsync(execa('fail.js', commandArguments)), @@ -89,12 +92,12 @@ test('result.escapedCommand - \\x01', testEscapedCommand, ['\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 - \\u200E', testEscapedCommand, ['\u200E'], '\'\\u200e\'', '"\\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 - \\uD800', testEscapedCommand, ['\uD800'], '\'\\ud800\'', '"\\ud800"', '\'\uD800\'', '"\uD800"'); +test('result.escapedCommand - \\uE000', testEscapedCommand, ['\uE000'], '\'\\ue000\'', '"\\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('result.escapedCommand - \\U1D173', testEscapedCommand, ['\u{1D173}'], '\'\\U1d173\'', '"\\U1d173"', '\'\u{1D173}\'', '"\u{1D173}"'); +test('result.escapedCommand - \\U10FFFD', testEscapedCommand, ['\u{10FFFD}'], '\'\\U10fffd\'', '"\\U10fffd"', '\'\u{10FFFD}\'', '"\u{10FFFD}"');