Merge remote-tracking branch 'upstream/main'

This commit is contained in:
2026-08-26 09:31:34 +08:00
parent 9b8c4bfab6
commit 394eb0285d
34137 changed files with 3589424 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
'use strict';
var test = require('tape');
var parse = require('../').parse;
test('comment', function (t) {
t.same(parse('beep#boop'), ['beep', { comment: 'boop' }]);
t.same(parse('beep #boop'), ['beep', { comment: 'boop' }]);
t.same(parse('beep # boop'), ['beep', { comment: ' boop' }]);
t.same(parse('beep # > boop'), ['beep', { comment: ' > boop' }]);
t.same(parse('beep # "> boop"'), ['beep', { comment: ' "> boop"' }]);
t.same(parse('beep "#"'), ['beep', '#']);
t.same(parse('beep #"#"#'), ['beep', { comment: '"#"#' }]);
t.same(parse('beep > boop # > foo'), ['beep', { op: '>' }, 'boop', { comment: ' > foo' }]);
t.end();
});
+52
View File
@@ -0,0 +1,52 @@
'use strict';
var test = require('tape');
var parse = require('../').parse;
test('expand environment variables', function (t) {
t.same(parse('a $XYZ c', { XYZ: 'b' }), ['a', 'b', 'c']);
t.same(parse('a${XYZ}c', { XYZ: 'b' }), ['abc']);
t.same(parse('a${XYZ}c $XYZ', { XYZ: 'b' }), ['abc', 'b']);
t.same(parse('"-$X-$Y-"', { X: 'a', Y: 'b' }), ['-a-b-']);
t.same(parse("'-$X-$Y-'", { X: 'a', Y: 'b' }), ['-$X-$Y-']);
t.same(parse('qrs"$zzz"wxy', { zzz: 'tuv' }), ['qrstuvwxy']);
t.same(parse("qrs'$zzz'wxy", { zzz: 'tuv' }), ['qrs$zzzwxy']);
t.same(parse('qrs${zzz}wxy'), ['qrswxy']);
t.same(parse('qrs$wxy $'), ['qrs', '$']);
t.same(parse('grep "xy$"'), ['grep', 'xy$']);
t.same(parse('ab$x', { x: 'c' }), ['abc']);
t.same(parse('ab\\$x', { x: 'c' }), ['ab$x']);
t.same(parse('ab${x}def', { x: 'c' }), ['abcdef']);
t.same(parse('ab\\${x}def', { x: 'c' }), ['ab${x}def']);
t.same(parse('"ab\\${x}def"', { x: 'c' }), ['ab${x}def']);
t.end();
});
test('expand environment variables within here-strings', function (t) {
t.same(parse('a <<< $x', { x: 'Joe' }), ['a', { op: '<<<' }, 'Joe']);
t.same(parse('a <<< ${x}', { x: 'Joe' }), ['a', { op: '<<<' }, 'Joe']);
t.same(parse('a <<< "$x"', { x: 'Joe' }), ['a', { op: '<<<' }, 'Joe']);
t.same(parse('a <<< "${x}"', { x: 'Joe' }), ['a', { op: '<<<' }, 'Joe']);
t.end();
});
test('environment variables with metacharacters', function (t) {
t.same(parse('a $XYZ c', { XYZ: '"b"' }), ['a', '"b"', 'c']);
t.same(parse('a $XYZ c', { XYZ: '$X', X: 5 }), ['a', '$X', 'c']);
t.same(parse('a"$XYZ"c', { XYZ: "'xyz'" }), ["a'xyz'c"]);
t.end();
});
test('special shell parameters', function (t) {
var chars = '*@#?-$!0_'.split('');
t.plan(chars.length);
chars.forEach(function (c) {
var env = {};
env[c] = 'xxx';
t.same(parse('a $' + c + ' c', env), ['a', 'xxx', 'c']);
});
});
+21
View File
@@ -0,0 +1,21 @@
'use strict';
var test = require('tape');
var parse = require('../').parse;
function getEnv() {
return 'xxx';
}
function getEnvObj() {
return { op: '@@' };
}
test('functional env expansion', function (t) {
t.plan(4);
t.same(parse('a $XYZ c', getEnv), ['a', 'xxx', 'c']);
t.same(parse('a $XYZ c', getEnvObj), ['a', { op: '@@' }, 'c']);
t.same(parse('a${XYZ}c', getEnvObj), ['a', { op: '@@' }, 'c']);
t.same(parse('"a $XYZ c"', getEnvObj), ['a ', { op: '@@' }, ' c']);
});
+102
View File
@@ -0,0 +1,102 @@
'use strict';
var test = require('tape');
var parse = require('../').parse;
test('single operators', function (t) {
t.same(parse('beep | boop'), ['beep', { op: '|' }, 'boop']);
t.same(parse('beep|boop'), ['beep', { op: '|' }, 'boop']);
t.same(parse('beep \\| boop'), ['beep', '|', 'boop']);
t.same(parse('beep "|boop"'), ['beep', '|boop']);
t.same(parse('echo zing &'), ['echo', 'zing', { op: '&' }]);
t.same(parse('echo zing&'), ['echo', 'zing', { op: '&' }]);
t.same(parse('echo zing\\&'), ['echo', 'zing&']);
t.same(parse('echo "zing\\&"'), ['echo', 'zing\\&']);
t.same(parse('beep;boop'), ['beep', { op: ';' }, 'boop']);
t.same(parse('(beep;boop)'), [
{ op: '(' }, 'beep', { op: ';' }, 'boop', { op: ')' }
]);
t.same(parse('beep>boop'), ['beep', { op: '>' }, 'boop']);
t.same(parse('beep 2>boop'), ['beep', '2', { op: '>' }, 'boop']);
t.same(parse('beep<boop'), ['beep', { op: '<' }, 'boop']);
t.end();
});
test('double operators', function (t) {
t.same(parse('beep || boop'), ['beep', { op: '||' }, 'boop']);
t.same(parse('beep||boop'), ['beep', { op: '||' }, 'boop']);
t.same(parse('beep ||boop'), ['beep', { op: '||' }, 'boop']);
t.same(parse('beep|| boop'), ['beep', { op: '||' }, 'boop']);
t.same(parse('beep || boop'), ['beep', { op: '||' }, 'boop']);
t.same(parse('beep && boop'), ['beep', { op: '&&' }, 'boop']);
t.same(
parse('beep && boop || byte'),
['beep', { op: '&&' }, 'boop', { op: '||' }, 'byte']
);
t.same(
parse('beep&&boop||byte'),
['beep', { op: '&&' }, 'boop', { op: '||' }, 'byte']
);
t.same(
parse('beep\\&\\&boop||byte'),
['beep&&boop', { op: '||' }, 'byte']
);
t.same(
parse('beep\\&&boop||byte'),
['beep&', { op: '&' }, 'boop', { op: '||' }, 'byte']
);
t.same(
parse('beep;;boop|&byte>>blip'),
['beep', { op: ';;' }, 'boop', { op: '|&' }, 'byte', { op: '>>' }, 'blip']
);
t.same(parse('beep 2>&1'), ['beep', '2', { op: '>&' }, '1']);
t.same(
parse('beep<(boop)'),
['beep', { op: '<(' }, 'boop', { op: ')' }]
);
t.same(
parse('beep<<(boop)'),
['beep', { op: '<' }, { op: '<(' }, 'boop', { op: ')' }]
);
t.end();
});
test('duplicating input file descriptors', function (t) {
// duplicating stdout to file descriptor 3
t.same(parse('beep 3<&1'), ['beep', '3', { op: '<&' }, '1']);
// duplicating stdout to file descriptor 0, i.e. stdin
t.same(parse('beep <&1'), ['beep', { op: '<&' }, '1']);
// closes stdin
t.same(parse('beep <&-'), ['beep', { op: '<&' }, '-']);
t.end();
});
test('here strings', function (t) {
t.same(parse('cat <<< "hello world"'), ['cat', { op: '<<<' }, 'hello world']);
t.same(parse('cat <<< hello'), ['cat', { op: '<<<' }, 'hello']);
t.same(parse('cat<<<hello'), ['cat', { op: '<<<' }, 'hello']);
t.same(parse('cat<<<"hello world"'), ['cat', { op: '<<<' }, 'hello world']);
t.end();
});
test('glob patterns', function (t) {
t.same(
parse('tap test/*.test.js'),
['tap', { op: 'glob', pattern: 'test/*.test.js' }]
);
t.same(parse('tap "test/*.test.js"'), ['tap', 'test/*.test.js']);
t.end();
});
+149
View File
@@ -0,0 +1,149 @@
'use strict';
var test = require('tape');
var parse = require('../').parse;
var quote = require('../').quote;
test('parse shell commands', function (t) {
t.same(parse(''), [], 'parses an empty string');
t['throws'](
function () { parse('${}'); },
Error,
'empty substitution throws'
);
t['throws'](
function () { parse('${'); },
Error,
'incomplete substitution throws'
);
t.same(parse('a \'b\' "c"'), ['a', 'b', 'c']);
t.same(
parse('beep "boop" \'foo bar baz\' "it\'s \\"so\\" groovy"'),
['beep', 'boop', 'foo bar baz', 'it\'s "so" groovy']
);
t.same(parse('a b\\ c d'), ['a', 'b c', 'd']);
t.same(parse('\\$beep bo\\`op'), ['$beep', 'bo`op']);
t.same(parse('echo "foo = \\"foo\\""'), ['echo', 'foo = "foo"']);
t.same(parse(''), []);
t.same(parse(' '), []);
t.same(parse('\t'), []);
t.same(parse('a"b c d"e'), ['ab c de']);
t.same(parse('a\\ b"c d"\\ e f'), ['a bc d e', 'f']);
t.same(parse('a\\ b"c d"\\ e\'f g\' h'), ['a bc d ef g', 'h']);
t.same(parse("x \"bl'a\"'h'"), ['x', "bl'ah"]);
// inside single quotes everything is literal, so a backslash does not escape
// the closing quote and a quoted token must end at its first closing quote
t.same(parse("'\\' '\\'"), ['\\', '\\'], 'single-quoted backslashes parse as two separate tokens');
t.same(parse("'\\'\\''"), ["\\'"], 'single-quoted backslash joined with an escaped quote');
t.same(parse(quote(['\\', '\\'])), ['\\', '\\'], 'quote/parse round-trips a pair of backslashes');
t.same(parse("x bl^'a^'h'", {}, { escape: '^' }), ['x', "bl'a'h"]);
t.same(parse('abcH def', {}, { escape: 'H' }), ['abc def']);
t.deepEqual(parse('# abc def ghi'), [{ comment: ' abc def ghi' }], 'start-of-line comment content is unparsed');
t.deepEqual(parse('xyz # abc def ghi'), ['xyz', { comment: ' abc def ghi' }], 'comment content is unparsed');
t.deepEqual(parse('-x "" -y'), ['-x', '', '-y'], 'empty string is preserved');
t.same(
parse('2;b', {}, { escape: 'd' }),
[{ op: '2;b' }],
'control char in unquoted context mid-token with regex-special escape returns op'
);
t.end();
});
test('single quotes are literal', function (t) {
t.same(parse("'\\'\\'"), ["\\'"], 'close-escape-reopen produces a quote after a quoted backslash');
t.same(parse("'a'\\''b'"), ["a'b"], 'close-escape-reopen embeds a quote mid-word');
t.same(parse("'\\'x"), ['\\x'], 'bareword joins a preceding quoted backslash');
t.same(parse("a'\\'b"), ['a\\b'], 'quoted backslash joins surrounding barewords');
t.same(parse("''"), [''], 'empty single quotes produce an empty token');
t.same(parse("''a''"), ['a'], 'empty single quotes join adjacent content');
t.same(parse("'*'"), ['*'], 'quoted glob char is a plain string, not a glob');
t.end();
});
test('unmatched single quotes', function (t) {
// real shells reject unterminated quotes; parse is lenient, and these pin the shape of that leniency
t.same(parse("'"), [], 'a lone quote is dropped');
t.same(parse("'a"), ['a'], 'an unterminated quote keeps its content');
t.same(parse("a'b"), ['a', 'b'], 'an unmatched quote mid-word splits the token');
t.end();
});
test('nested parameter expansion', function (t) {
t.same(parse('${a${b}c}'), [''], 'a nested ${} is consumed as one substitution, not split at the first }');
t.same(parse('${a${b}}'), [''], 'a nested ${} at the end is consumed as one substitution');
t.same(parse('${foo{bar}'), [''], 'a lone { that is not part of a nested ${} does not change brace depth');
t.same(
parse('level=${levels[$RANDOM%${#levels[@]}]}'),
['level='],
'a nested array-index expansion is consumed whole, without leaking a partial token'
);
t.end();
});
test('splitUnquoted: field-splits unquoted variable expansion (#1)', function (t) {
var env = { T: 'c d', E: '', S: ' c d ', W: ' ' };
var opts = { splitUnquoted: true };
t.same(parse('test a b $T', env, opts), ['test', 'a', 'b', 'c', 'd'], 'unquoted expansion splits into separate tokens');
t.same(parse('a$T', env, opts), ['ac', 'd'], 'the first field joins the preceding text');
t.same(parse('$T x', env, opts), ['c', 'd', 'x'], 'the last field is a token of its own');
t.same(parse('x${T}y', env, opts), ['xc', 'dy'], 'fields join text on both sides');
t.same(parse('$S', env, opts), ['c', 'd'], 'leading, trailing, and repeated whitespace collapses');
t.same(parse('a$S', env, opts), ['a', 'c', 'd'], 'leading whitespace closes the preceding field');
t.same(parse('$W', env, opts), [], 'an all-whitespace expansion produces no tokens');
t.same(parse('a$W b', env, opts), ['a', 'b'], 'an all-whitespace expansion just separates fields');
t.same(parse('$E', env, opts), [], 'an empty unquoted expansion produces no token');
t.same(parse('"$T"', env, opts), ['c d'], 'a quoted expansion is never split');
t.same(parse('-x "" -y', env, opts), ['-x', '', '-y'], 'a quoted empty string is still preserved');
t.same(parse('a $F b', function () { return 'c d'; }, opts), ['a', 'c', 'd', 'b'], 'the env-function path splits too');
t.same(parse('test a b $T', env), ['test', 'a', 'b', 'c d'], 'without the option, unquoted expansion is not split');
t.end();
});
test('splitUnquoted: a string value is a custom IFS (#1)', function (t) {
function o(ifs) { return { splitUnquoted: ifs }; }
t.same(parse('${V}', { V: 'a:b' }, o(':')), ['a', 'b'], 'a non-whitespace IFS char splits fields');
t.same(parse('${V}', { V: 'a::b' }, o(':')), ['a', '', 'b'], 'adjacent non-whitespace delimiters yield an empty field');
t.same(parse('${V}', { V: ':a:' }, o(':')), ['', 'a'], 'a leading delimiter yields a leading empty; a trailing one does not');
t.same(parse('${V}', { V: 'a::' }, o(':')), ['a', ''], 'a trailing double delimiter yields one empty field');
t.same(parse('${V}', { V: ':' }, o(':')), [''], 'a lone delimiter yields a single empty field');
t.same(parse('${V}', { V: '::' }, o(':')), ['', ''], 'two delimiters yield two empty fields');
t.same(parse('${V}${W}', { V: 'a:', W: ':b' }, o(':')), ['a', '', 'b'], 'delimiters spanning an expansion boundary merge into one run');
t.same(parse('a${V}${W}z', { V: ':x:', W: ':y:' }, o(':')), ['a', 'x', '', 'y', 'z'], 'fields join literal text on both sides across expansions');
t.same(parse('${V}', { V: 'a : b' }, o(' :')), ['a', 'b'], 'whitespace around a non-whitespace delimiter is absorbed');
t.same(parse('${V}', { V: 'a b:c' }, o(' :')), ['a', 'b', 'c'], 'mixed IFS: whitespace and non-whitespace each delimit');
t.same(parse('${V}', { V: 'a,b' }, o(',')), ['a', 'b'], 'any character can be the IFS');
t.same(parse('${V}', { V: 'a b' }, o('')), ['a b'], 'an empty IFS string disables splitting');
t.end();
});
test('parse stays linear in token count (GHSA-395f-4hp3-45gv)', function (t) {
// the old concat-in-reduce finalizer was O(n^2): this many tokens took
// ~minutes, so under the unfixed code this test hangs rather than passes
var n = 2e5;
var input = new Array(n + 1).join('x '); // avoid String#repeat for old engines
var words = parse(input);
t.equal(words.length, n, 'every token is returned');
t.equal(words[0], 'x', 'first token is correct');
t.equal(words[n - 1], 'x', 'last token is correct');
var withEnv = parse(input, function () { return 'v'; });
t.equal(withEnv.length, n, 'env-function path returns every token');
t.end();
});
+145
View File
@@ -0,0 +1,145 @@
'use strict';
var test = require('tape');
var quote = require('../').quote;
test('quote', function (t) {
t.equal(quote(['a', 'b', 'c d']), 'a b \'c d\'');
t.equal(
quote(['a', 'b', "it's a \"neat thing\""]),
'a b "it\'s a \\"neat thing\\""'
);
t.equal(
quote(['$', '`', '\'']),
'\\$ \\` "\'"'
);
t.equal(quote([]), '');
t.equal(quote(['a\nb']), "'a\nb'");
t.equal(quote([' #(){}*|][!']), "' #(){}*|][!'");
t.equal(quote(["'#(){}*|][!"]), '"\'#(){}*|][\\!"');
t.equal(quote(['X#(){}*|][!']), 'X\\#\\(\\)\\{\\}\\*\\|\\]\\[\\!');
t.equal(quote(['a\n#\nb']), "'a\n#\nb'");
t.equal(quote(['><;{}']), '\\>\\<\\;\\{\\}');
t.equal(quote(['a', 1, true, false]), 'a 1 true false');
t.equal(quote(['a', 1, null, undefined]), 'a 1 null undefined');
t.equal(quote(['a\\x']), "'a\\x'");
t.equal(quote(['a"b']), '\'a"b\'');
t.equal(quote(['"a"b"']), '\'"a"b"\'');
t.equal(quote(['a\\"b']), '\'a\\"b\'');
t.equal(quote(['a\\b']), '\'a\\b\'');
t.end();
});
test('quote tilde (escapes every ~ to prevent shell tilde-expansion)', function (t) {
t.equal(quote(['~']), '\\~');
t.equal(quote(['~/foo']), '\\~/foo');
t.equal(quote(['~root']), '\\~root');
t.equal(quote(['~root/x']), '\\~root/x');
t.equal(quote(['~+']), '\\~+');
t.equal(quote(['~-']), '\\~-');
t.equal(quote(['a~b']), 'a\\~b');
t.equal(quote(['x~']), 'x\\~');
t.end();
});
test('backslash with whitespace is not doubled in single quotes (#14)', function (t) {
t.equal(quote(['foo \\ bar']), "'foo \\ bar'", 'a backslash between spaces stays a single literal backslash');
t.equal(quote(['foo \\\\ bar']), "'foo \\\\ bar'", 'a double backslash is preserved, not quadrupled');
t.equal(quote(['foo\\\nbar']), "'foo\\\nbar'", 'a backslash before a newline is preserved');
t.end();
});
test('escapes shell-special characters conservatively (issue #11)', function (t) {
t.equal(quote(['make', 'CFLAGS=-DRELEASE']), 'make CFLAGS\\=-DRELEASE', 'escapes = so a leading word is not read as an assignment');
t.equal(quote(['a@b']), 'a\\@b', 'escapes @ (zsh globbing)');
t.equal(quote(['a^b']), 'a\\^b', 'escapes ^ (zsh extendedglob, csh)');
t.equal(quote(['a:b']), 'a\\:b', 'escapes :');
t.equal(quote(['a,b']), 'a\\,b', 'escapes , (brace expansion)');
t.equal(quote(['a!b']), 'a\\!b', 'escapes ! (history expansion / pipeline negation)');
t.end();
});
test('quote ops', function (t) {
t.equal(quote(['a', { op: '|' }, 'b']), 'a \\| b');
t.equal(
quote(['a', { op: '&&' }, 'b', { op: ';' }, 'c']),
'a \\&\\& b \\; c'
);
t.end();
});
test('quote windows paths', { skip: 'breaking change, disabled until 2.x' }, function (t) {
var path = 'C:\\projects\\node-shell-quote\\index.js';
t.equal(quote([path, 'b', 'c d']), 'C:\\projects\\node-shell-quote\\index.js b \'c d\'');
t.end();
});
test("chars for windows paths don't break out", function (t) {
var x = '`:\\a\\b';
t.equal(quote([x]), "'`:\\a\\b'");
t.end();
});
test('empty strings', function (t) {
t.equal(quote(['-x', '', 'y']), '-x \'\' y');
t.end();
});
test('quote ops: allowlist', function (t) {
var ops = ['||', '&&', ';;', '|&', '<(', '<<<', '>>', '>&', '<&', '&', ';', '(', ')', '|', '<', '>'];
for (var i = 0; i < ops.length; i++) {
var op = ops[i];
var expected = '';
for (var j = 0; j < op.length; j++) { expected += '\\' + op.charAt(j); }
t.equal(quote([{ op: op }]), expected, 'op ' + op);
}
t.end();
});
test('quote ops: rejects line terminators (GHSA-w7jw-789q-3m8p)', function (t) {
t['throws'](function () { quote([{ op: ';\nid' }]); }, TypeError, 'newline in op');
t['throws'](function () { quote([{ op: ';\rid' }]); }, TypeError, 'carriage return in op');
t['throws'](function () { quote([{ op: ';\u2028id' }]); }, TypeError, 'U+2028 in op');
t['throws'](function () { quote([{ op: ';\u2029id' }]); }, TypeError, 'U+2029 in op');
t.end();
});
test('quote ops: rejects non-allowlisted values', function (t) {
t['throws'](function () { quote([{ op: '' }]); }, TypeError, 'empty op');
t['throws'](function () { quote([{ op: 'foo' }]); }, TypeError, 'arbitrary string');
t['throws'](function () { quote([{ op: '|||' }]); }, TypeError, 'near-miss');
t['throws'](function () { quote([{ op: 42 }]); }, TypeError, 'non-string op');
t.end();
});
test('quote glob pattern', function (t) {
t.equal(quote([{ op: 'glob', pattern: 'test/*.test.js' }]), 'test/*.test.js');
t.equal(quote([{ op: 'glob', pattern: '?ab' }]), '?ab');
t.equal(quote([{ op: 'glob', pattern: '[ab]c' }]), '[ab]c');
t.equal(quote([{ op: 'glob', pattern: '{a,b}' }]), '{a,b}');
t.equal(quote([{ op: 'glob', pattern: 'my dir/*.txt' }]), 'my\\ dir/*.txt');
t.equal(quote([{ op: 'glob', pattern: 'a$b' }]), 'a\\$b');
t['throws'](function () { quote([{ op: 'glob' }]); }, TypeError, 'missing pattern');
t['throws'](function () { quote([{ op: 'glob', pattern: 'a\nb' }]); }, TypeError, 'newline in pattern');
t['throws'](function () { quote([{ op: 'glob', pattern: 'a\u2028b' }]); }, TypeError, 'U+2028 in pattern');
t.end();
});
test('quote comment', function (t) {
t.equal(quote(['echo', 'hi', { comment: ' a comment' }]), 'echo hi # a comment');
t.equal(quote([{ comment: '' }]), '#');
t['throws'](function () { quote([{ comment: 'a\nb' }]); }, TypeError, 'newline in comment');
t['throws'](function () { quote([{ comment: 'a\rb' }]); }, TypeError, 'CR in comment');
t['throws'](function () { quote([{ comment: 'a\u2028b' }]); }, TypeError, 'U+2028 in comment');
t.end();
});
test('quote rejects unrecognized object shapes', function (t) {
t['throws'](function () { quote([{}]); }, TypeError, 'empty object');
t['throws'](function () { quote([{ foo: 'bar' }]); }, TypeError, 'unknown key');
t['throws'](function () { quote([{ op: null }]); }, TypeError, 'null op');
t.end();
});
+31
View File
@@ -0,0 +1,31 @@
'use strict';
var test = require('tape');
var parse = require('../').parse;
test('set env vars', function (t) {
t.same(
parse('ABC=444 x y z'),
['ABC=444', 'x', 'y', 'z']
);
t.same(
parse('ABC=3\\ 4\\ 5 x y z'),
['ABC=3 4 5', 'x', 'y', 'z']
);
t.same(
parse('X="7 8 9" printx'),
['X=7 8 9', 'printx']
);
t.same(
parse('X="7 8 9"; printx'),
['X=7 8 9', { op: ';' }, 'printx']
);
t.same(
parse('X="7 8 9"; printx', function () {
t.fail('should not have matched any keys');
}),
['X=7 8 9', { op: ';' }, 'printx']
);
t.end();
});