Skip to content

Commit c0842c8

Browse files
committed
[Fix] parse: match nested ${...} braces so nested parameter expansion is consumed as one substitution
1 parent 100e96e commit c0842c8

2 files changed

Lines changed: 26 additions & 2 deletions

File tree

parse.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,10 +150,21 @@ function parseInternal(string, env, opts) {
150150
if (s.charAt(i) === '}') {
151151
throw new Error('Bad substitution: ' + s.slice(i - 2, i + 1));
152152
}
153-
varend = s.indexOf('}', i);
154-
if (varend < 0) {
153+
// match braces by depth so a nested `${` keeps its inner `}` from ending the outer substitution
154+
var depth = 1;
155+
varend = i;
156+
while (depth > 0 && varend < s.length) {
157+
if (s.charAt(varend) === '{' && s.charAt(varend - 1) === '$') {
158+
depth += 1;
159+
} else if (s.charAt(varend) === '}') {
160+
depth -= 1;
161+
}
162+
varend += 1;
163+
}
164+
if (depth !== 0) {
155165
throw new Error('Bad substitution: ' + s.slice(i));
156166
}
167+
varend -= 1;
157168
varname = s.slice(i, varend);
158169
i = varend;
159170
} else if ((/[*@#?$!_-]/).test(char)) {

test/parse.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,19 @@ test('unmatched single quotes', function (t) {
7777
t.end();
7878
});
7979

80+
test('nested parameter expansion', function (t) {
81+
t.same(parse('${a${b}c}'), [''], 'a nested ${} is consumed as one substitution, not split at the first }');
82+
t.same(parse('${a${b}}'), [''], 'a nested ${} at the end is consumed as one substitution');
83+
t.same(parse('${foo{bar}'), [''], 'a lone { that is not part of a nested ${} does not change brace depth');
84+
t.same(
85+
parse('level=${levels[$RANDOM%${#levels[@]}]}'),
86+
['level='],
87+
'a nested array-index expansion is consumed whole, without leaking a partial token'
88+
);
89+
90+
t.end();
91+
});
92+
8093
test('parse stays linear in token count (GHSA-395f-4hp3-45gv)', function (t) {
8194
// the old concat-in-reduce finalizer was O(n^2): this many tokens took
8295
// ~minutes, so under the unfixed code this test hangs rather than passes

0 commit comments

Comments
 (0)