"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.EXPANSION_MAX_STEPS = exports.EXPANSION_MAX_REWRITES = exports.EXPANSION_MAX_DEPTH = exports.EXPANSION_MAX_LENGTH = exports.EXPANSION_MAX = void 0; exports.expand = expand; const balanced_match_1 = require("balanced-match"); const escSlash = '\0SLASH' + Math.random().toFixed(16) + '\0'; const escOpen = '\0OPEN' + Math.random().toFixed(16) + '\0'; const escClose = '\0CLOSE' + Math.random().toFixed(16) + '\0'; const escComma = '\0COMMA' + Math.random().toFixed(16) + '\0'; const escPeriod = '\0PERIOD' + Math.random().toFixed(16) + '\0'; const escSlashPattern = new RegExp(escSlash, 'g'); const escOpenPattern = new RegExp(escOpen, 'g'); const escClosePattern = new RegExp(escClose, 'g'); const escCommaPattern = new RegExp(escComma, 'g'); const escPeriodPattern = new RegExp(escPeriod, 'g'); const slashPattern = /\\\\/g; const openPattern = /\\{/g; const closePattern = /\\}/g; const commaPattern = /\\,/g; const periodPattern = /\\\./g; exports.EXPANSION_MAX = 100_000; // `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. exports.EXPANSION_MAX_LENGTH = 4_000_000; // `expand_` recurses once per level of brace *nesting* - both when expanding a // set's comma members and when re-wrapping a set whose body is a single part. // The CVE-2026-14257 fix made the *tail* iterative (recursion on `m.post`, one // level per chained group), which left nesting depth unbounded: about 3,100 // levels of `{{{...a,b...}}}` - only ~6KB of input - exhausted the native stack // and crashed the process. `EXPANSION_MAX_DEPTH` bounds how deep the parser // will follow nesting. It sits far above any realistic pattern and well below // the depth at which the stack runs out. exports.EXPANSION_MAX_DEPTH = 1_000; // Bash keeps a quirk where a brace group followed by a comma set still expands // (`{a},b}`). The parser implements it by rewriting the string and restarting // the scan, absorbing one `}` per pass. `n` trailing braces therefore cost `n` // full passes over a string that itself grows by one `escClose` sentinel each // time - quadratic in `n`, with a ~26x constant from the sentinel's length. // 128KB of `'{a}' + '}'.repeat(n) + ',z}'` blocked the event loop for 27 // seconds to produce two results. `EXPANSION_MAX_REWRITES` bounds how many // times the scan may restart. Real `{a},b}` input needs a handful. exports.EXPANSION_MAX_REWRITES = 1_000; // `EXPANSION_MAX_STEPS` bounds the work one `expand` call does, which `max`, // `maxLength` and `maxDepth` don't: nested sets are rescanned at every level // and chained groups each rebuild up to `max` results, so 2KB of input could // block the event loop for over 30 seconds. Each character scanned, result // built and sequence value generated is a step, and each `expand_` call costs // `CALL_STEPS` more. Past the budget, the set being expanded and everything // after it stay literal. A glob needs a few hundred steps, and nesting all the // way to `EXPANSION_MAX_DEPTH` about 4M. exports.EXPANSION_MAX_STEPS = 20_000_000; // What one `expand_` call costs on top of what it scans and builds. Setting // one up takes about as long as scanning 16 characters, and a set whose // results are wrapped to be expanded again makes one call per result, each // scanning and building next to nothing: 64KB of // `('{{' + ','.repeat(316) + '}{' + ','.repeat(316) + '}}').repeat(n)` made // 4M such calls, which spent the budget three times as slowly as anything // else. const CALL_STEPS = 16; function numeric(str) { return !isNaN(str) ? parseInt(str, 10) : str.charCodeAt(0); } function escapeBraces(str) { return str .replace(slashPattern, escSlash) .replace(openPattern, escOpen) .replace(closePattern, escClose) .replace(commaPattern, escComma) .replace(periodPattern, escPeriod); } function unescapeBraces(str) { return str .replace(escSlashPattern, '\\') .replace(escOpenPattern, '{') .replace(escClosePattern, '}') .replace(escCommaPattern, ',') .replace(escPeriodPattern, '.'); } // Like `target.push(...items)` but doesn't overflow the stack function pushAll(target, items) { for (let i = 0; i < items.length; i++) { target.push(items[i]); } } // `balanced`, run on `masked` (see `maskUnmatchedOpens`), sliced from `str` // and charged for the characters it walks: up to the group's `}`, or the // whole string when there is no match or unmatched `{` come first, which // `unmatched` then flags for masking. Once a call overdraws the budget, it // and every later one return undefined, so the rest stays literal. function balancedWithBudget(str, masked, budget) { if (budget.remaining < 0) { return undefined; } const r = (0, balanced_match_1.range)('{', '}', masked); const unmatched = r !== undefined && masked.indexOf('{') < r[0]; budget.remaining -= r && !unmatched ? r[1] + 1 : str.length; if (!r || budget.remaining < 0) { return undefined; } return { end: r[1], pre: str.slice(0, r[0]), body: str.slice(r[0] + 1, r[1]), post: str.slice(r[1] + 1), unmatched, }; } // `masked` with every `{` that has no matching `}` swapped for a `\0`, which // keeps the length, so a scan of it still lines up with the string it was // made from. Such a `{` is literal either way, but `balanced` only learns that // one is unmatched by reading to the end of the string, so with one at the // head of every group (`'{a,{b,c}'.repeat(n)`) each scan read the rest of the // input. That was quadratic, and once the step budget charged for it, it ran // out partway through and left the rest literal. A scan of the masked copy // stops at the group's `}`. It is one pass, charged as such. function maskUnmatchedOpens(masked, budget) { budget.remaining -= masked.length; const open = []; for (let i = 0; i < masked.length; i++) { const c = masked.charCodeAt(i); if (c === 0x7b) { open.push(i); } else if (c === 0x7d && open.length) { open.pop(); } } let out = ''; let last = 0; for (let i = 0; i < open.length; i++) { const at = open[i]; out += masked.slice(last, at) + '\0'; last = at + 1; } return out + masked.slice(last); } // Whether `str` has a `,` not followed by another `,`, with a `}` after it on // the same line - what `/,(?!,).*\}/.test(str)` asks, answered in one pass from // the end. The regex tries every `,` and backtracks through the rest of its // line from each, which is quadratic, and the `{a},b}` rewrite asks again on // every pass. Like a scan, it is charged for the whole string and refused if // that overdraws the budget. function commaSetFollows(str, budget) { budget.remaining -= str.length; if (budget.remaining < 0) { return false; } // Whether a `}` follows the current position before the line ends let close = false; for (let i = str.length - 1; i >= 0; i--) { const c = str.charCodeAt(i); if (c === 0x7d) { close = true; } else if (c === 0x0a || c === 0x0d || c === 0x2028 || c === 0x2029) { // A line terminator, which `.` does not match close = false; } else if (c === 0x2c && close && str.charCodeAt(i + 1) !== 0x2c) { return true; } } return false; } /** * 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} * * Returns `undefined` if the step budget runs out before the whole set is * split, since splitting what is left on every comma would tear apart the * nested sets it still holds. */ function parseCommaParts(str, budget) { const parts = []; // Walk the brace groups iteratively. Recursing on `post` once per group let a // chain of them exhaust the stack - the parsing-side counterpart to // the `expand_` overflow fixed for CVE-2026-14257, and not something `max` or // `maxLength` can bound, since it happens before expansion. // // The part the next chunk continues let carry = ''; for (;;) { // A set's body holds no unmatched `{`, so there is nothing to mask. const m = balancedWithBudget(str, str, budget); if (budget.remaining < 0) { return undefined; } if (!m) { const tail = str.split(','); tail[0] = carry + tail[0]; pushAll(parts, tail); return parts; } const { pre, body, post } = m; const p = pre.split(','); p[0] = carry + p[0]; p[p.length - 1] += '{' + body + '}'; if (!post.length) { pushAll(parts, p); return parts; } carry = p.pop(); pushAll(parts, p); str = post; } } // An option that is left out, or that is NaN, gets its default. Every // comparison against NaN is false, so a NaN bound would never be reached: // `{ maxSteps: NaN }` let 15KB of input block the event loop for 16 seconds, // and `{ maxDepth: NaN }` overflowed the stack again (CVE-2026-102278). Pass // `Infinity` to lift a bound. function bound(value, fallback) { return value === undefined || isNaN(value) ? fallback : value; } function expand(str, options = {}) { if (!str) { return []; } const max = bound(options.max, exports.EXPANSION_MAX); const maxLength = bound(options.maxLength, exports.EXPANSION_MAX_LENGTH); const maxDepth = bound(options.maxDepth, exports.EXPANSION_MAX_DEPTH); const maxRewrites = bound(options.maxRewrites, exports.EXPANSION_MAX_REWRITES); const maxSteps = bound(options.maxSteps, exports.EXPANSION_MAX_STEPS); // 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.slice(0, 2) === '{}') { str = '\\{\\}' + str.slice(2); } return expand_(escapeBraces(str), max, maxLength, maxDepth, 0, maxRewrites, { remaining: maxSteps }, 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). Each result built is a step, since `max` and // `maxLength` bound one combination, not how many brace groups are combined. function combine(acc, pre, values, max, maxLength, dropEmpties, budget) { const out = []; let length = 0; // Bash drops empty results at the top level, and they don't count against // `max`, so `max` bounds the number of *kept* results. Only an empty // `acc[a] + pre` can make one, so for those the empty values are skipped // without being visited. Visiting them one at a time let a set of empties // cost `acc.length * values.length` while keeping nothing. let nonEmpty; outer: for (let a = 0; a < acc.length; a++) { const prefix = acc[a] + pre; let vs = values; if (dropEmpties && !prefix) { if (!nonEmpty) nonEmpty = values.filter(v => v); vs = nonEmpty; } for (let v = 0; v < vs.length; v++) { if (out.length >= max) break outer; const expansion = prefix + vs[v]; if (length + expansion.length > maxLength) break outer; out.push(expansion); length += expansion.length; } } budget.remaining -= out.length; return out; } // The expansion values of a single numeric (`1..5`) or alphabetic (`a..e..2`) // sequence body, or `undefined` if the step budget runs out generating them. function expandSequence(body, isAlphaSequence, max, maxLength, budget) { const n = body.split(/\.\./); const 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 */ const x = numeric(n[0]); const y = numeric(n[1]); const width = Math.max(n[0].length, n[1].length); let incr = n.length === 3 && n[2] !== undefined ? Math.max(Math.abs(numeric(n[2])), 1) : 1; let test = lte; const reverse = y < x; if (reverse) { incr *= -1; test = gte; } const pad = n.some(isPadded); // A padded value is built a character at a time to `width`, which the input // sets, so it costs one step per character: 64KB of // `('{' + '0'.repeat(100) + '..99999}').repeat(n)` took 7 seconds charged // one step per value. const cost = pad ? width : 1; let length = 0; for (let i = x; test(i, y) && N.length < max; i += incr) { // Each value is a step. `max` and `maxLength` bound one sequence, but a // chain of them still generated up to `max` values each, however few // combining could keep. budget.remaining -= cost; if (budget.remaining < 0) { return undefined; } let c; if (isAlphaSequence) { c = String.fromCharCode(i); if (c === '\\') { c = ''; } } else { c = String(i); if (pad) { const need = width - c.length; if (need > 0) { const 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, maxDepth, depth, maxRewrites, budget, isTop) { // An overdraft here makes the first scan refuse, so `str` stays literal. budget.remaining -= CALL_STEPS; // Too deeply nested to keep following: treat the rest as literal, the same // way a group that cannot expand is already handled. Truncating rather than // throwing keeps `expand` total, matching `max` and `maxLength`. if (depth > maxDepth) { return [str]; } // 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). let 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). // How many times the `{a},b}` rewrite below has restarted the scan. Each pass // re-reads the whole string, so leaving this unbounded is quadratic. let rewrites = 0; let dropEmpties = false; let firstGroup = true; // `str` with its unmatched `{` masked, once a scan has run into them let masked = str; for (;;) { const m = balancedWithBudget(str, masked, budget); // No brace set left, or no step budget left to look for one: the rest of // the string is literal. if (!m) { return combine(acc, str, [''], max, maxLength, dropEmpties, budget); } if (m.unmatched) { masked = maskUnmatchedOpens(masked, budget); } // no need to expand pre, since it is guaranteed to be free of brace-sets const pre = m.pre; // This set and the rest of the string, literal: for a set that cannot // expand, and for one the step budget runs out on. Once it is overdrawn, // every set still being expanded, at every level, stays literal like this - // otherwise each level above would go on combining, and wrapping and // expanding again, results the budget was too small for. const literal = () => combine(acc, pre + '{' + m.body + '}' + m.post, [''], max, maxLength, dropEmpties, budget); if (/\$$/.test(pre)) { acc = combine(acc, pre + '{' + m.body + '}', [''], max, maxLength, dropEmpties && !m.post.length, budget); firstGroup = false; if (!m.post.length) break; str = m.post; masked = masked.slice(m.end + 1); continue; } const isNumericSequence = /^-?\d+\.\.-?\d+(?:\.\.-?\d+)?$/.test(m.body); const isAlphaSequence = /^[a-zA-Z]\.\.[a-zA-Z](?:\.\.-?\d+)?$/.test(m.body); const isSequence = isNumericSequence || isAlphaSequence; const isOptions = m.body.indexOf(',') >= 0; if (!isSequence && !isOptions) { // {a},b} if (rewrites < maxRewrites && commaSetFollows(m.post, budget)) { rewrites++; str = m.pre + '{' + m.body + escClose + m.post; // Taking a `}` away never gives an unmatched `{` a partner, so what // is masked stays unmatched. The `{` it closed can be left without // one, and the next scan masks it. masked = masked.slice(0, m.end) + escClose + masked.slice(m.end + 1); isTop = true; continue; } // Nothing here expands, so the whole remaining string is literal. return literal(); } if (firstGroup) { dropEmpties = isTop && !isSequence; firstGroup = false; } let values; if (isSequence) { values = expandSequence(m.body, isAlphaSequence, max, maxLength, budget); if (!values) { return literal(); } } else { let n = parseCommaParts(m.body, budget); // The step budget ran out partway through the set. if (!n) { return literal(); } if (n.length === 1 && n[0] !== undefined) { // x{{a,b}}y ==> x{a}y x{b}y n = expand_(n[0], max, maxLength, maxDepth, depth + 1, maxRewrites, budget, false).map(embrace); // The step budget ran out expanding the set's only part. if (budget.remaining < 0) { return literal(); } //XXX is this necessary? Can't seem to hit it in tests. /* c8 ignore start */ if (n.length === 1) { const next = combine(acc, pre + n[0], [''], max, maxLength, dropEmpties && !m.post.length, budget); if (budget.remaining < 0) { return literal(); } acc = next; if (!m.post.length) break; str = m.post; masked = masked.slice(m.end + 1); 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. let dropsEmpties = dropEmpties && !m.post.length && !pre; for (let d = 0; dropsEmpties && d < acc.length; d++) { if (acc[d]) { dropsEmpties = false; } } values = []; let valuesLength = 0; outer: for (let j = 0; j < n.length; j++) { const expanded = expand_(n[j], max, maxLength, maxDepth, depth + 1, maxRewrites, budget, false); // The step budget ran out expanding a member. if (budget.remaining < 0) { return literal(); } for (let k = 0; k < expanded.length; k++) { const v = expanded[k]; if (dropsEmpties && !v) continue; if (values.length >= max || valuesLength + v.length > maxLength) { break outer; } values.push(v); valuesLength += v.length; } } } const next = combine(acc, pre, values, max, maxLength, dropEmpties && !m.post.length, budget); // The step budget ran out combining the set's values in. if (budget.remaining < 0) { return literal(); } acc = next; if (!m.post.length) break; str = m.post; masked = masked.slice(m.end + 1); } return acc; } //# sourceMappingURL=index.js.map