| 1 | "use strict";
|
|---|
| 2 | Object.defineProperty(exports, "__esModule", { value: true });
|
|---|
| 3 | exports.EXPANSION_MAX_REWRITES = exports.EXPANSION_MAX_DEPTH = exports.EXPANSION_MAX_LENGTH = exports.EXPANSION_MAX = void 0;
|
|---|
| 4 | exports.expand = expand;
|
|---|
| 5 | const balanced_match_1 = require("balanced-match");
|
|---|
| 6 | const escSlash = '\0SLASH' + Math.random() + '\0';
|
|---|
| 7 | const escOpen = '\0OPEN' + Math.random() + '\0';
|
|---|
| 8 | const escClose = '\0CLOSE' + Math.random() + '\0';
|
|---|
| 9 | const escComma = '\0COMMA' + Math.random() + '\0';
|
|---|
| 10 | const escPeriod = '\0PERIOD' + Math.random() + '\0';
|
|---|
| 11 | const escSlashPattern = new RegExp(escSlash, 'g');
|
|---|
| 12 | const escOpenPattern = new RegExp(escOpen, 'g');
|
|---|
| 13 | const escClosePattern = new RegExp(escClose, 'g');
|
|---|
| 14 | const escCommaPattern = new RegExp(escComma, 'g');
|
|---|
| 15 | const escPeriodPattern = new RegExp(escPeriod, 'g');
|
|---|
| 16 | const slashPattern = /\\\\/g;
|
|---|
| 17 | const openPattern = /\\{/g;
|
|---|
| 18 | const closePattern = /\\}/g;
|
|---|
| 19 | const commaPattern = /\\,/g;
|
|---|
| 20 | const periodPattern = /\\\./g;
|
|---|
| 21 | exports.EXPANSION_MAX = 100_000;
|
|---|
| 22 | // `EXPANSION_MAX` caps the *number* of expansions, but not their length. An
|
|---|
| 23 | // input like `'{a,b}'.repeat(1500)` stays under that count - its output is
|
|---|
| 24 | // truncated to 100k results - while making every result ~1500 characters
|
|---|
| 25 | // long. The result set, and the intermediate arrays built while combining
|
|---|
| 26 | // brace sets, then grow large enough to exhaust memory and crash the process
|
|---|
| 27 | // (CVE-2026-14257). `EXPANSION_MAX_LENGTH` bounds the total number of
|
|---|
| 28 | // characters the accumulator may hold at any point, so memory stays flat no
|
|---|
| 29 | // matter how many brace groups are chained. The limit sits well above any
|
|---|
| 30 | // realistic expansion (100k results hitting `EXPANSION_MAX` measure ~1M
|
|---|
| 31 | // characters) so legitimate input is unaffected.
|
|---|
| 32 | exports.EXPANSION_MAX_LENGTH = 4_000_000;
|
|---|
| 33 | // `expand_` recurses once per level of brace *nesting* - both when expanding a
|
|---|
| 34 | // set's comma members and when re-wrapping a set whose body is a single part.
|
|---|
| 35 | // The CVE-2026-14257 fix made the *tail* iterative (recursion on `m.post`, one
|
|---|
| 36 | // level per chained group), which left nesting depth unbounded: about 3,100
|
|---|
| 37 | // levels of `{{{...a,b...}}}` - only ~6KB of input - exhausted the native stack
|
|---|
| 38 | // and crashed the process. `EXPANSION_MAX_DEPTH` bounds how deep the parser
|
|---|
| 39 | // will follow nesting. It sits far above any realistic pattern and well below
|
|---|
| 40 | // the depth at which the stack runs out.
|
|---|
| 41 | exports.EXPANSION_MAX_DEPTH = 1_000;
|
|---|
| 42 | // Bash keeps a quirk where a brace group followed by a comma set still expands
|
|---|
| 43 | // (`{a},b}`). The parser implements it by rewriting the string and restarting
|
|---|
| 44 | // the scan, absorbing one `}` per pass. `n` trailing braces therefore cost `n`
|
|---|
| 45 | // full passes over a string that itself grows by one `escClose` sentinel each
|
|---|
| 46 | // time - quadratic in `n`, with a ~26x constant from the sentinel's length.
|
|---|
| 47 | // 128KB of `'{a}' + '}'.repeat(n) + ',z}'` blocked the event loop for 27
|
|---|
| 48 | // seconds to produce two results. `EXPANSION_MAX_REWRITES` bounds how many
|
|---|
| 49 | // times the scan may restart. Real `{a},b}` input needs a handful.
|
|---|
| 50 | exports.EXPANSION_MAX_REWRITES = 1_000;
|
|---|
| 51 | function numeric(str) {
|
|---|
| 52 | return !isNaN(str) ? parseInt(str, 10) : str.charCodeAt(0);
|
|---|
| 53 | }
|
|---|
| 54 | function escapeBraces(str) {
|
|---|
| 55 | return str
|
|---|
| 56 | .replace(slashPattern, escSlash)
|
|---|
| 57 | .replace(openPattern, escOpen)
|
|---|
| 58 | .replace(closePattern, escClose)
|
|---|
| 59 | .replace(commaPattern, escComma)
|
|---|
| 60 | .replace(periodPattern, escPeriod);
|
|---|
| 61 | }
|
|---|
| 62 | function unescapeBraces(str) {
|
|---|
| 63 | return str
|
|---|
| 64 | .replace(escSlashPattern, '\\')
|
|---|
| 65 | .replace(escOpenPattern, '{')
|
|---|
| 66 | .replace(escClosePattern, '}')
|
|---|
| 67 | .replace(escCommaPattern, ',')
|
|---|
| 68 | .replace(escPeriodPattern, '.');
|
|---|
| 69 | }
|
|---|
| 70 | // Like `target.push(...items)` but doesn't overflow the stack
|
|---|
| 71 | function pushAll(target, items) {
|
|---|
| 72 | for (let i = 0; i < items.length; i++) {
|
|---|
| 73 | target.push(items[i]);
|
|---|
| 74 | }
|
|---|
| 75 | }
|
|---|
| 76 | /**
|
|---|
| 77 | * Basically just str.split(","), but handling cases
|
|---|
| 78 | * where we have nested braced sections, which should be
|
|---|
| 79 | * treated as individual members, like {a,{b,c},d}
|
|---|
| 80 | */
|
|---|
| 81 | function parseCommaParts(str) {
|
|---|
| 82 | const parts = [];
|
|---|
| 83 | // Walk the brace groups iteratively. Recursing on `post` once per group let a
|
|---|
| 84 | // chain of them exhaust the stack - the parsing-side counterpart to
|
|---|
| 85 | // the `expand_` overflow fixed for CVE-2026-14257, and not something `max` or
|
|---|
| 86 | // `maxLength` can bound, since it happens before expansion.
|
|---|
| 87 | //
|
|---|
| 88 | // The part the next chunk continues
|
|---|
| 89 | let carry = '';
|
|---|
| 90 | for (;;) {
|
|---|
| 91 | const m = (0, balanced_match_1.balanced)('{', '}', str);
|
|---|
| 92 | if (!m) {
|
|---|
| 93 | const tail = str.split(',');
|
|---|
| 94 | tail[0] = carry + tail[0];
|
|---|
| 95 | pushAll(parts, tail);
|
|---|
| 96 | return parts;
|
|---|
| 97 | }
|
|---|
| 98 | const { pre, body, post } = m;
|
|---|
| 99 | const p = pre.split(',');
|
|---|
| 100 | p[0] = carry + p[0];
|
|---|
| 101 | p[p.length - 1] += '{' + body + '}';
|
|---|
| 102 | if (!post.length) {
|
|---|
| 103 | pushAll(parts, p);
|
|---|
| 104 | return parts;
|
|---|
| 105 | }
|
|---|
| 106 | carry = p.pop();
|
|---|
| 107 | pushAll(parts, p);
|
|---|
| 108 | str = post;
|
|---|
| 109 | }
|
|---|
| 110 | }
|
|---|
| 111 | function expand(str, options = {}) {
|
|---|
| 112 | if (!str) {
|
|---|
| 113 | return [];
|
|---|
| 114 | }
|
|---|
| 115 | const { max = exports.EXPANSION_MAX, maxLength = exports.EXPANSION_MAX_LENGTH, maxDepth = exports.EXPANSION_MAX_DEPTH, maxRewrites = exports.EXPANSION_MAX_REWRITES, } = options;
|
|---|
| 116 | // I don't know why Bash 4.3 does this, but it does.
|
|---|
| 117 | // Anything starting with {} will have the first two bytes preserved
|
|---|
| 118 | // but *only* at the top level, so {},a}b will not expand to anything,
|
|---|
| 119 | // but a{},b}c will be expanded to [a}c,abc].
|
|---|
| 120 | // One could argue that this is a bug in Bash, but since the goal of
|
|---|
| 121 | // this module is to match Bash's rules, we escape a leading {}
|
|---|
| 122 | if (str.slice(0, 2) === '{}') {
|
|---|
| 123 | str = '\\{\\}' + str.slice(2);
|
|---|
| 124 | }
|
|---|
| 125 | return expand_(escapeBraces(str), max, maxLength, maxDepth, 0, maxRewrites, true).map(unescapeBraces);
|
|---|
| 126 | }
|
|---|
| 127 | function embrace(str) {
|
|---|
| 128 | return '{' + str + '}';
|
|---|
| 129 | }
|
|---|
| 130 | function isPadded(el) {
|
|---|
| 131 | return /^-?0\d/.test(el);
|
|---|
| 132 | }
|
|---|
| 133 | function lte(i, y) {
|
|---|
| 134 | return i <= y;
|
|---|
| 135 | }
|
|---|
| 136 | function gte(i, y) {
|
|---|
| 137 | return i >= y;
|
|---|
| 138 | }
|
|---|
| 139 | // Build `{ acc[a] + pre + values[v] }` for every combination, capping the
|
|---|
| 140 | // number of results at `max` and the total number of characters at `maxLength`.
|
|---|
| 141 | // This is the one place output grows, so bounding it here keeps the single
|
|---|
| 142 | // accumulator - and therefore memory - flat regardless of how many brace groups
|
|---|
| 143 | // are combined (CVE-2026-14257).
|
|---|
| 144 | function combine(acc, pre, values, max, maxLength, dropEmpties) {
|
|---|
| 145 | const out = [];
|
|---|
| 146 | let length = 0;
|
|---|
| 147 | for (let a = 0; a < acc.length; a++) {
|
|---|
| 148 | for (let v = 0; v < values.length; v++) {
|
|---|
| 149 | if (out.length >= max)
|
|---|
| 150 | return out;
|
|---|
| 151 | const expansion = acc[a] + pre + values[v];
|
|---|
| 152 | // Bash drops empty results at the top level. Skip them before they count
|
|---|
| 153 | // against `max`, so `max` bounds the number of *kept* results.
|
|---|
| 154 | if (dropEmpties && !expansion)
|
|---|
| 155 | continue;
|
|---|
| 156 | if (length + expansion.length > maxLength)
|
|---|
| 157 | return out;
|
|---|
| 158 | out.push(expansion);
|
|---|
| 159 | length += expansion.length;
|
|---|
| 160 | }
|
|---|
| 161 | }
|
|---|
| 162 | return out;
|
|---|
| 163 | }
|
|---|
| 164 | // The expansion values of a single numeric (`1..5`) or alphabetic (`a..e..2`)
|
|---|
| 165 | // sequence body.
|
|---|
| 166 | function expandSequence(body, isAlphaSequence, max, maxLength) {
|
|---|
| 167 | const n = body.split(/\.\./);
|
|---|
| 168 | const N = [];
|
|---|
| 169 | // A sequence body always splits into two or three parts, but the compiler
|
|---|
| 170 | // can't know that.
|
|---|
| 171 | /* c8 ignore start */
|
|---|
| 172 | if (n[0] === undefined || n[1] === undefined) {
|
|---|
| 173 | return N;
|
|---|
| 174 | }
|
|---|
| 175 | /* c8 ignore stop */
|
|---|
| 176 | const x = numeric(n[0]);
|
|---|
| 177 | const y = numeric(n[1]);
|
|---|
| 178 | const width = Math.max(n[0].length, n[1].length);
|
|---|
| 179 | let incr = n.length === 3 && n[2] !== undefined ?
|
|---|
| 180 | Math.max(Math.abs(numeric(n[2])), 1)
|
|---|
| 181 | : 1;
|
|---|
| 182 | let test = lte;
|
|---|
| 183 | const reverse = y < x;
|
|---|
| 184 | if (reverse) {
|
|---|
| 185 | incr *= -1;
|
|---|
| 186 | test = gte;
|
|---|
| 187 | }
|
|---|
| 188 | const pad = n.some(isPadded);
|
|---|
| 189 | let length = 0;
|
|---|
| 190 | for (let i = x; test(i, y) && N.length < max; i += incr) {
|
|---|
| 191 | let c;
|
|---|
| 192 | if (isAlphaSequence) {
|
|---|
| 193 | c = String.fromCharCode(i);
|
|---|
| 194 | if (c === '\\') {
|
|---|
| 195 | c = '';
|
|---|
| 196 | }
|
|---|
| 197 | }
|
|---|
| 198 | else {
|
|---|
| 199 | c = String(i);
|
|---|
| 200 | if (pad) {
|
|---|
| 201 | const need = width - c.length;
|
|---|
| 202 | if (need > 0) {
|
|---|
| 203 | const z = new Array(need + 1).join('0');
|
|---|
| 204 | if (i < 0) {
|
|---|
| 205 | c = '-' + z + c.slice(1);
|
|---|
| 206 | }
|
|---|
| 207 | else {
|
|---|
| 208 | c = z + c;
|
|---|
| 209 | }
|
|---|
| 210 | }
|
|---|
| 211 | }
|
|---|
| 212 | }
|
|---|
| 213 | if (length + c.length > maxLength)
|
|---|
| 214 | break;
|
|---|
| 215 | N.push(c);
|
|---|
| 216 | length += c.length;
|
|---|
| 217 | }
|
|---|
| 218 | return N;
|
|---|
| 219 | }
|
|---|
| 220 | function expand_(str, max, maxLength, maxDepth, depth, maxRewrites, isTop) {
|
|---|
| 221 | // Too deeply nested to keep following: treat the rest as literal, the same
|
|---|
| 222 | // way a group that cannot expand is already handled. Truncating rather than
|
|---|
| 223 | // throwing keeps `expand` total, matching `max` and `maxLength`.
|
|---|
| 224 | if (depth > maxDepth) {
|
|---|
| 225 | return [str];
|
|---|
| 226 | }
|
|---|
| 227 | // Consume the string's top-level brace groups left to right, threading a
|
|---|
| 228 | // running set of combined prefixes (`acc`). Expanding the tail iteratively -
|
|---|
| 229 | // rather than recursing on `m.post` once per group - keeps the native stack
|
|---|
| 230 | // depth constant, so deeply chained input (`'{a,b}'.repeat(3000)`) can no
|
|---|
| 231 | // longer overflow the stack, and leaves a single accumulator whose size
|
|---|
| 232 | // `maxLength` bounds directly (CVE-2026-14257).
|
|---|
| 233 | let acc = [''];
|
|---|
| 234 | // Bash drops empty results, but only when the *first* top-level group is a
|
|---|
| 235 | // comma set - a sequence like `{a..\}` may legitimately yield ''. The drop
|
|---|
| 236 | // is on the final strings, so it is applied to whichever `combine` produces
|
|---|
| 237 | // them (the one with no brace set left in the tail).
|
|---|
| 238 | // How many times the `{a},b}` rewrite below has restarted the scan. Each pass
|
|---|
| 239 | // re-reads the whole string, so leaving this unbounded is quadratic.
|
|---|
| 240 | let rewrites = 0;
|
|---|
| 241 | let dropEmpties = false;
|
|---|
| 242 | let firstGroup = true;
|
|---|
| 243 | for (;;) {
|
|---|
| 244 | const m = (0, balanced_match_1.balanced)('{', '}', str);
|
|---|
| 245 | // No brace set left: the rest of the string is literal.
|
|---|
| 246 | if (!m) {
|
|---|
| 247 | return combine(acc, str, [''], max, maxLength, dropEmpties);
|
|---|
| 248 | }
|
|---|
| 249 | // no need to expand pre, since it is guaranteed to be free of brace-sets
|
|---|
| 250 | const pre = m.pre;
|
|---|
| 251 | if (/\$$/.test(pre)) {
|
|---|
| 252 | acc = combine(acc, pre + '{' + m.body + '}', [''], max, maxLength, dropEmpties && !m.post.length);
|
|---|
| 253 | firstGroup = false;
|
|---|
| 254 | if (!m.post.length)
|
|---|
| 255 | break;
|
|---|
| 256 | str = m.post;
|
|---|
| 257 | continue;
|
|---|
| 258 | }
|
|---|
| 259 | const isNumericSequence = /^-?\d+\.\.-?\d+(?:\.\.-?\d+)?$/.test(m.body);
|
|---|
| 260 | const isAlphaSequence = /^[a-zA-Z]\.\.[a-zA-Z](?:\.\.-?\d+)?$/.test(m.body);
|
|---|
| 261 | const isSequence = isNumericSequence || isAlphaSequence;
|
|---|
| 262 | const isOptions = m.body.indexOf(',') >= 0;
|
|---|
| 263 | if (!isSequence && !isOptions) {
|
|---|
| 264 | // {a},b}
|
|---|
| 265 | if (rewrites < maxRewrites && m.post.match(/,(?!,).*\}/)) {
|
|---|
| 266 | rewrites++;
|
|---|
| 267 | str = m.pre + '{' + m.body + escClose + m.post;
|
|---|
| 268 | isTop = true;
|
|---|
| 269 | continue;
|
|---|
| 270 | }
|
|---|
| 271 | // Nothing here expands, so the whole remaining string is literal.
|
|---|
| 272 | return combine(acc, pre + '{' + m.body + '}' + m.post, [''], max, maxLength, dropEmpties);
|
|---|
| 273 | }
|
|---|
| 274 | if (firstGroup) {
|
|---|
| 275 | dropEmpties = isTop && !isSequence;
|
|---|
| 276 | firstGroup = false;
|
|---|
| 277 | }
|
|---|
| 278 | let values;
|
|---|
| 279 | if (isSequence) {
|
|---|
| 280 | values = expandSequence(m.body, isAlphaSequence, max, maxLength);
|
|---|
| 281 | }
|
|---|
| 282 | else {
|
|---|
| 283 | let n = parseCommaParts(m.body);
|
|---|
| 284 | if (n.length === 1 && n[0] !== undefined) {
|
|---|
| 285 | // x{{a,b}}y ==> x{a}y x{b}y
|
|---|
| 286 | n = expand_(n[0], max, maxLength, maxDepth, depth + 1, maxRewrites, false).map(embrace);
|
|---|
| 287 | //XXX is this necessary? Can't seem to hit it in tests.
|
|---|
| 288 | /* c8 ignore start */
|
|---|
| 289 | if (n.length === 1) {
|
|---|
| 290 | acc = combine(acc, pre + n[0], [''], max, maxLength, dropEmpties && !m.post.length);
|
|---|
| 291 | if (!m.post.length)
|
|---|
| 292 | break;
|
|---|
| 293 | str = m.post;
|
|---|
| 294 | continue;
|
|---|
| 295 | }
|
|---|
| 296 | /* c8 ignore stop */
|
|---|
| 297 | }
|
|---|
| 298 | // Values that `combine` is going to drop as empty produce no result, so
|
|---|
| 299 | // they must not count against `max` - otherwise `{a,,b}` with `max: 2`
|
|---|
| 300 | // would stop at `['a', '']` and yield one result instead of two. Skipping
|
|---|
| 301 | // them outright keeps `values` bounded while leaving `max` a bound on
|
|---|
| 302 | // *kept* results.
|
|---|
| 303 | let dropsEmpties = dropEmpties && !m.post.length && !pre;
|
|---|
| 304 | for (let d = 0; dropsEmpties && d < acc.length; d++) {
|
|---|
| 305 | if (acc[d]) {
|
|---|
| 306 | dropsEmpties = false;
|
|---|
| 307 | }
|
|---|
| 308 | }
|
|---|
| 309 | values = [];
|
|---|
| 310 | let valuesLength = 0;
|
|---|
| 311 | outer: for (let j = 0; j < n.length; j++) {
|
|---|
| 312 | const expanded = expand_(n[j], max, maxLength, maxDepth, depth + 1, maxRewrites, false);
|
|---|
| 313 | for (let k = 0; k < expanded.length; k++) {
|
|---|
| 314 | const v = expanded[k];
|
|---|
| 315 | if (dropsEmpties && !v)
|
|---|
| 316 | continue;
|
|---|
| 317 | if (values.length >= max ||
|
|---|
| 318 | valuesLength + v.length > maxLength) {
|
|---|
| 319 | break outer;
|
|---|
| 320 | }
|
|---|
| 321 | values.push(v);
|
|---|
| 322 | valuesLength += v.length;
|
|---|
| 323 | }
|
|---|
| 324 | }
|
|---|
| 325 | }
|
|---|
| 326 | acc = combine(acc, pre, values, max, maxLength, dropEmpties && !m.post.length);
|
|---|
| 327 | if (!m.post.length)
|
|---|
| 328 | break;
|
|---|
| 329 | str = m.post;
|
|---|
| 330 | }
|
|---|
| 331 | return acc;
|
|---|
| 332 | }
|
|---|
| 333 | //# sourceMappingURL=index.js.map |
|---|