| var balanced = require('balanced-match'); |
| |
| module.exports = expandTop; |
| |
| var escSlash = '\0SLASH'+Math.random()+'\0'; |
| var escOpen = '\0OPEN'+Math.random()+'\0'; |
| var escClose = '\0CLOSE'+Math.random()+'\0'; |
| var escComma = '\0COMMA'+Math.random()+'\0'; |
| var escPeriod = '\0PERIOD'+Math.random()+'\0'; |
| |
| var EXPANSION_MAX = 100000 |
| |
| // `EXPANSION_MAX` caps the *number* of expansions, but not their length. An |
| // input like `'{a,b}'.repeat(1500)` stays under that count - its output is |
| // truncated to 100k results - while making every result ~1500 characters |
| // long. The result set, and the intermediate arrays built while combining |
| // brace sets, then grow large enough to exhaust memory and crash the process |
| // (CVE-2026-14257). `EXPANSION_MAX_LENGTH` bounds the total number of |
| // characters the accumulator may hold at any point, so memory stays flat no |
| // matter how many brace groups are chained. The limit sits well above any |
| // realistic expansion (100k results hitting `EXPANSION_MAX` measure ~1M |
| // characters) so legitimate input is unaffected. |
| var EXPANSION_MAX_LENGTH = 4000000 |
| |
| function numeric(str) { |
| return parseInt(str, 10) == str |
| ? parseInt(str, 10) |
| : str.charCodeAt(0); |
| } |
| |
| function escapeBraces(str) { |
| return str.split('\\\\').join(escSlash) |
| .split('\\{').join(escOpen) |
| .split('\\}').join(escClose) |
| .split('\\,').join(escComma) |
| .split('\\.').join(escPeriod); |
| } |
| |
| function unescapeBraces(str) { |
| return str.split(escSlash).join('\\') |
| .split(escOpen).join('{') |
| .split(escClose).join('}') |
| .split(escComma).join(',') |
| .split(escPeriod).join('.'); |
| } |
| |
| |
| // Basically just str.split(","), but handling cases |
| // where we have nested braced sections, which should be |
| // treated as individual members, like {a,{b,c},d} |
| function parseCommaParts(str) { |
| if (!str) |
| return ['']; |
| |
| var parts = []; |
| var m = balanced('{', '}', str); |
| |
| if (!m) |
| return str.split(','); |
| |
| var pre = m.pre; |
| var body = m.body; |
| var post = m.post; |
| var p = pre.split(','); |
| |
| p[p.length-1] += '{' + body + '}'; |
| var postParts = parseCommaParts(post); |
| if (post.length) { |
| p[p.length-1] += postParts.shift(); |
| p.push.apply(p, postParts); |
| } |
| |
| parts.push.apply(parts, p); |
| |
| return parts; |
| } |
| |
| function expandTop(str, options) { |
| if (!str) |
| return []; |
| |
| options = options || {}; |
| var max = options.max == null ? EXPANSION_MAX : options.max; |
| var maxLength = options.maxLength == null ? EXPANSION_MAX_LENGTH : options.maxLength; |
| |
| // I don't know why Bash 4.3 does this, but it does. |
| // Anything starting with {} will have the first two bytes preserved |
| // but *only* at the top level, so {},a}b will not expand to anything, |
| // but a{},b}c will be expanded to [a}c,abc]. |
| // One could argue that this is a bug in Bash, but since the goal of |
| // this module is to match Bash's rules, we escape a leading {} |
| if (str.substr(0, 2) === '{}') { |
| str = '\\{\\}' + str.substr(2); |
| } |
| |
| return expand(escapeBraces(str), max, maxLength, true).map(unescapeBraces); |
| } |
| |
| function embrace(str) { |
| return '{' + str + '}'; |
| } |
| function isPadded(el) { |
| return /^-?0\d/.test(el); |
| } |
| |
| function lte(i, y) { |
| return i <= y; |
| } |
| function gte(i, y) { |
| return i >= y; |
| } |
| |
| // Build `{ acc[a] + pre + values[v] }` for every combination, capping the |
| // number of results at `max` and the total number of characters at `maxLength`. |
| // This is the one place output grows, so bounding it here keeps the single |
| // accumulator - and therefore memory - flat regardless of how many brace groups |
| // are combined (CVE-2026-14257). |
| function combine( |
| acc, |
| pre, |
| values, |
| max, |
| maxLength, |
| dropEmpties |
| ) { |
| var out = [] |
| var length = 0 |
| for (var a = 0; a < acc.length; a++) { |
| for (var v = 0; v < values.length; v++) { |
| if (out.length >= max) return out |
| var expansion = acc[a] + pre + values[v] |
| // Bash drops empty results at the top level. Skip them before they count |
| // against `max`, so `max` bounds the number of *kept* results. |
| if (dropEmpties && !expansion) continue |
| if (length + expansion.length > maxLength) return out |
| out.push(expansion) |
| length += expansion.length |
| } |
| } |
| return out |
| } |
| |
| // The expansion values of a single numeric (`1..5`) or alphabetic (`a..e..2`) |
| // sequence body. |
| function expandSequence( |
| body, |
| isAlphaSequence, |
| max, |
| maxLength |
| ) { |
| var n = body.split(/\.\./) |
| var N = [] |
| // A sequence body always splits into two or three parts, but the compiler |
| // can't know that. |
| /* c8 ignore start */ |
| if (n[0] === undefined || n[1] === undefined) { |
| return N |
| } |
| /* c8 ignore stop */ |
| var x = numeric(n[0]) |
| var y = numeric(n[1]) |
| var width = Math.max(n[0].length, n[1].length) |
| var incr = |
| n.length === 3 && n[2] !== undefined ? |
| Math.max(Math.abs(numeric(n[2])), 1) |
| : 1 |
| var test = lte |
| var reverse = y < x |
| if (reverse) { |
| incr *= -1 |
| test = gte |
| } |
| var pad = n.some(isPadded) |
| |
| var length = 0 |
| for (var i = x; test(i, y) && N.length < max; i += incr) { |
| var c |
| if (isAlphaSequence) { |
| c = String.fromCharCode(i) |
| if (c === '\\') { |
| c = '' |
| } |
| } else { |
| c = String(i) |
| if (pad) { |
| var need = width - c.length |
| if (need > 0) { |
| var z = new Array(need + 1).join('0') |
| if (i < 0) { |
| c = '-' + z + c.slice(1) |
| } else { |
| c = z + c |
| } |
| } |
| } |
| } |
| if (length + c.length > maxLength) break |
| N.push(c) |
| length += c.length |
| } |
| return N |
| } |
| |
| function expand( |
| str, |
| max, |
| maxLength, |
| isTop |
| ) { |
| // Consume the string's top-level brace groups left to right, threading a |
| // running set of combined prefixes (`acc`). Expanding the tail iteratively - |
| // rather than recursing on `m.post` once per group - keeps the native stack |
| // depth constant, so deeply chained input (`'{a,b}'.repeat(3000)`) can no |
| // longer overflow the stack, and leaves a single accumulator whose size |
| // `maxLength` bounds directly (CVE-2026-14257). |
| var acc = [''] |
| |
| // Bash drops empty results, but only when the *first* top-level group is a |
| // comma set - a sequence like `{a..\}` may legitimately yield ''. The drop |
| // is on the final strings, so it is applied to whichever `combine` produces |
| // them (the one with no brace set left in the tail). |
| var dropEmpties = false |
| var firstGroup = true |
| |
| for (;;) { |
| const m = balanced('{', '}', str) |
| |
| // No brace set left: the rest of the string is literal. |
| if (!m) { |
| return combine(acc, str, [''], max, maxLength, dropEmpties) |
| } |
| |
| // no need to expand pre, since it is guaranteed to be free of brace-sets |
| const pre = m.pre |
| |
| if (/\$$/.test(pre)) { |
| acc = combine( |
| acc, |
| pre + '{' + m.body + '}', |
| [''], |
| max, |
| maxLength, |
| dropEmpties && !m.post.length |
| ) |
| firstGroup = false |
| if (!m.post.length) break |
| str = m.post |
| continue |
| } |
| |
| var isNumericSequence = /^-?\d+\.\.-?\d+(?:\.\.-?\d+)?$/.test(m.body); |
| var isAlphaSequence = /^[a-zA-Z]\.\.[a-zA-Z](?:\.\.-?\d+)?$/.test(m.body); |
| var isSequence = isNumericSequence || isAlphaSequence; |
| var isOptions = m.body.indexOf(',') >= 0; |
| if (!isSequence && !isOptions) { |
| // {a},b} |
| if (m.post.match(/,(?!,).*\}/)) { |
| str = m.pre + '{' + m.body + escClose + m.post; |
| isTop = true; |
| continue; |
| } |
| // Nothing here expands, so the whole remaining string is literal. |
| return combine( |
| acc, |
| pre + '{' + m.body + '}' + m.post, |
| [''], |
| max, |
| maxLength, |
| dropEmpties |
| ) |
| } |
| |
| if (firstGroup) { |
| dropEmpties = isTop && !isSequence |
| firstGroup = false |
| } |
| |
| var values; |
| if (isSequence) { |
| values = expandSequence(m.body, isAlphaSequence, max, maxLength); |
| } else { |
| var n = parseCommaParts(m.body); |
| if (n.length === 1 && n[0] !== undefined) { |
| // x{{a,b}}y ==> x{a}y x{b}y |
| n = expand(n[0], max, maxLength, false).map(embrace); |
| //XXX is this necessary? Can't seem to hit it in tests. |
| /* c8 ignore start */ |
| if (n.length === 1) { |
| acc = combine( |
| acc, |
| pre + n[0], |
| [''], |
| max, |
| maxLength, |
| dropEmpties && !m.post.length |
| ) |
| if (!m.post.length) break |
| str = m.post |
| continue |
| } |
| /* c8 ignore stop */ |
| } |
| |
| // Values that `combine` is going to drop as empty produce no result, so |
| // they must not count against `max` - otherwise `{a,,b}` with `max: 2` |
| // would stop at `['a', '']` and yield one result instead of two. Skipping |
| // them outright keeps `values` bounded while leaving `max` a bound on |
| // *kept* results. |
| var dropsEmpties = dropEmpties && !m.post.length && !pre |
| for (var d = 0; dropsEmpties && d < acc.length; d++) { |
| if (acc[d]) { |
| dropsEmpties = false |
| } |
| } |
| |
| values = [] |
| var valuesLength = 0 |
| outer: for (var j = 0; j < n.length; j++) { |
| var expanded = expand(n[j], max, maxLength, false) |
| for (var k = 0; k < expanded.length; k++) { |
| var v = expanded[k] |
| if (dropsEmpties && !v) continue |
| if (values.length >= max || valuesLength + v.length > maxLength) { |
| break outer |
| } |
| values.push(v) |
| valuesLength += v.length |
| } |
| } |
| } |
| |
| acc = combine(acc, pre, values, max, maxLength, dropEmpties && !m.post.length) |
| if (!m.post.length) break |
| str = m.post |
| } |
| |
| return acc |
| } |