Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .verb.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,19 @@ console.log(braces.expand('a{b}c'));
console.log(braces('a/{b,c}/d', { maxLength: 3 })); //=> throws an error
```

### options.maxDepth

**Type**: `Number`

**Default**: `256`

**Description**: Limit how deeply braces and parentheses can be nested. Anything nested deeper than the limit is treated as literal text, so very deep patterns can't exhaust the call stack. Values higher than the default are capped at the default.

```js
console.log(braces.expand('{{a,b},c}', { maxDepth: 1 }));
//=> [ '{a,b}', 'c' ]
```

### options.expand

**Type**: `Boolean`
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,19 @@ console.log(braces.expand('a{b}c'));
console.log(braces('a/{b,c}/d', { maxLength: 3 })); //=> throws an error
```

### options.maxDepth

**Type**: `Number`

**Default**: `256`

**Description**: Limit how deeply braces and parentheses can be nested. Anything nested deeper than the limit is treated as literal text, so very deep patterns can't exhaust the call stack. Values higher than the default are capped at the default.

```js
console.log(braces.expand('{{a,b},c}', { maxDepth: 1 }));
//=> [ '{a,b}', 'c' ]
```

### options.expand

**Type**: `Boolean`
Expand Down
1 change: 1 addition & 0 deletions lib/constants.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';

module.exports = {
MAX_DEPTH: 256,
MAX_LENGTH: 10000,

// Digits
Expand Down
28 changes: 28 additions & 0 deletions lib/parse.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const stringify = require('./stringify');
*/

const {
MAX_DEPTH,
MAX_LENGTH,
CHAR_BACKSLASH, /* \ */
CHAR_BACKTICK, /* ` */
Expand Down Expand Up @@ -39,6 +40,7 @@ const parse = (input, options = {}) => {
throw new SyntaxError(`Input length (${input.length}), exceeds max characters (${max})`);
}

const maxDepth = Number.isFinite(opts.maxDepth) && opts.maxDepth >= 0 ? Math.min(MAX_DEPTH, opts.maxDepth) : MAX_DEPTH;
const ast = { type: 'root', input, nodes: [] };
const stack = [ast];
let block = ast;
Expand Down Expand Up @@ -71,6 +73,20 @@ const parse = (input, options = {}) => {
return node;
};

/**
* Groups nested deeper than maxDepth are parsed as usual, then replaced with
* their source text once they close, so the recursive walkers (and the
* `stringify` call below) never see a tree deeper than maxDepth.
*/

const collapse = node => {
const parent = stack[stack.length - 1];
parent.nodes.pop();
block = parent;
prev = parent.nodes[parent.nodes.length - 1];
push({ type: 'text', value: input.slice(node.literalStart, index) });
};

push({ type: 'bos' });

while (index < length) {
Expand Down Expand Up @@ -144,6 +160,7 @@ const parse = (input, options = {}) => {

if (value === CHAR_LEFT_PARENTHESES) {
block = push({ type: 'paren', nodes: [] });
if (stack.length > maxDepth) block.literalStart = index - 1;
stack.push(block);
push({ type: 'text', value });
continue;
Expand All @@ -156,6 +173,7 @@ const parse = (input, options = {}) => {
}
block = stack.pop();
push({ type: 'text', value });
if (block.literalStart !== undefined) collapse(block);
block = stack[stack.length - 1];
continue;
}
Expand Down Expand Up @@ -218,6 +236,7 @@ const parse = (input, options = {}) => {
};

block = push(brace);
if (stack.length > maxDepth) block.literalStart = index - 1;
stack.push(block);
push({ type: 'open', value });
continue;
Expand All @@ -239,6 +258,7 @@ const parse = (input, options = {}) => {

push({ type, value });
depth--;
if (block.literalStart !== undefined) collapse(block);

block = stack[stack.length - 1];
continue;
Expand Down Expand Up @@ -311,6 +331,14 @@ const parse = (input, options = {}) => {
push({ type: 'text', value });
}

// An unclosed group past maxDepth keeps the rest of the input as text
const literalIndex = stack.findIndex(node => node.literalStart !== undefined);
if (literalIndex !== -1) {
const node = stack[literalIndex];
stack.length = literalIndex;
collapse(node);
}

// Mark imbalanced braces and brackets as invalid
do {
block = stack.pop();
Expand Down
75 changes: 75 additions & 0 deletions test/nesting-depth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
'use strict';

require('mocha');
const assert = require('assert').strict;
const braces = require('..');

const nested = (open, close, depth) => open.repeat(depth) + 'a,b' + close.repeat(depth);

describe('nesting depth', () => {
describe('deeply nested patterns', () => {
// 4,995 levels stays under the default maxLength of 10,000
const deepBraces = nested('{', '}', 4995);
const deepParens = nested('(', ')', 4995);

it('should not overflow the stack when compiling', () => {
assert.equal(typeof braces(deepBraces)[0], 'string');
assert.equal(typeof braces(deepParens)[0], 'string');
});

it('should not overflow the stack when expanding', () => {
assert.ok(Array.isArray(braces.expand(deepBraces)));
assert.ok(Array.isArray(braces.expand(deepParens)));
});

it('should not overflow the stack when stringifying', () => {
assert.equal(braces.stringify(deepBraces), deepBraces);
assert.equal(braces.stringify(deepParens), deepParens);
});
});

describe('options.maxDepth', () => {
it('should not change results for patterns within the limit', () => {
assert.deepEqual(braces.expand('a{b,{c,{d,e}}}f'), ['abf', 'acf', 'adf', 'aef']);
});

it('should treat nesting beyond the limit as literal text', () => {
assert.deepEqual(braces.expand('{{a,b},c}', { maxDepth: 1 }), ['{a,b}', 'c']);
assert.deepEqual(braces.expand('{{a,b},c}', { maxDepth: 2 }), ['a', 'b', 'c']);
});

it('should keep escaped characters beyond the limit', () => {
assert.deepEqual(braces.expand('{{a,\\}b},c}', { maxDepth: 1 }), ['{a,\\}b}', 'c']);
});

it('should only close a literal group with its own delimiter type', () => {
assert.deepEqual(braces.expand('{{a),b},c}', { maxDepth: 1 }), ['{a),b}', 'c']);
assert.deepEqual(braces.expand('{(a},b),c}', { maxDepth: 1 }), ['(a},b)', 'c']);
});

it('should keep brackets and quotes intact beyond the limit', () => {
assert.deepEqual(braces.expand('{{[}],a},b}', { maxDepth: 1 }), ['{[}],a}', 'b']);
assert.deepEqual(braces.expand('{{a,"}"},b}', { maxDepth: 1 }), ['{a,"}"}', 'b']);
});

it('should treat an unclosed group beyond the limit as text to the end', () => {
assert.deepEqual(braces.expand('{{a(,b},c}', { maxDepth: 1 }), ['{{a(,b},c}']);
assert.deepEqual(braces.expand('{a,{b', { maxDepth: 1 }), ['{a,{b']);
});

it('should apply the same rules at the default limit', () => {
const wrap = str => '('.repeat(255) + str + ')'.repeat(255);
assert.deepEqual(braces.expand(wrap('{{a),b},c}')), [wrap('{a),b}'), wrap('c')]);
});

it('should not allow the limit to be raised above the default', () => {
assert.ok(Array.isArray(braces.expand(nested('{', '}', 4995), { maxDepth: Infinity })));
});

it('should fall back to the default when the value is invalid', () => {
for (const maxDepth of [NaN, -1, '1']) {
assert.deepEqual(braces.expand('{{a,b},c}', { maxDepth }), ['a', 'b', 'c']);
}
});
});
});