@blamejs/core 0.18.43 → 0.18.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -44,6 +44,7 @@ var codepointClass = require("./codepoint-class");
44
44
  var markupTokenizer = require("./markup-tokenizer");
45
45
  var lazyRequire = require("./lazy-require");
46
46
  var gateContract = require("./gate-contract");
47
+ var markupEscape = require("./markup-escape").markupEscape;
47
48
  var C = require("./constants");
48
49
  var { GuardMarkdownError } = require("./framework-error");
49
50
 
@@ -262,11 +263,18 @@ function _autolinks(input) {
262
263
  SCHEME_TAIL_CHARS.indexOf(input.charAt(j)) !== -1) { j += 1; tail += 1; }
263
264
  if (input.charAt(j) !== ":") continue;
264
265
  var b = j + 1;
265
- while (b < input.length && input.charAt(b) !== ">" &&
266
+ // The body stops at "<" as well as at ">" and whitespace. An autolink body
267
+ // cannot contain "<" in CommonMark, and running past one let an outer
268
+ // candidate swallow everything nested inside it: `<a:<javascript:alert(1)>>`
269
+ // recorded a single URL beginning `a:`, a scheme nothing objects to, and
270
+ // resumed past the inner candidate so the scheme filter never saw it.
271
+ while (b < input.length && input.charAt(b) !== ">" && input.charAt(b) !== "<" &&
266
272
  !markupTokenizer.isMarkupSpace(input.charCodeAt(b))) b += 1;
267
273
  // Resume past the scanned body either way. On failure every `<` inside it
268
274
  // would rescan the same span, which is quadratic on a document built of
269
- // `<a:` prefixes.
275
+ // `<a:` prefixes. Stopping at "<" keeps that: the scan still advances to
276
+ // the character that ended it, and that character begins the next
277
+ // candidate rather than being skipped over.
270
278
  if (b === j + 1 || input.charAt(b) !== ">") { i = b - 1; continue; }
271
279
  out.push({ url: input.slice(i + 1, b), index: i });
272
280
  i = b;
@@ -964,6 +972,936 @@ var INTEGRATION_FIXTURES = Object.freeze({
964
972
  // buildProfile / compliancePosture / loadRulePack wiring, plus the
965
973
  // per-guard inspection surface (validate / sanitize). The bespoke `gate`
966
974
  // carries markdown's sanitize-and-reemit chain unchanged.
975
+ // ---- Renderer -------------------------------------------------------------
976
+ //
977
+ // Escape-by-default Markdown to HTML over a deliberately small subset. The
978
+ // three things a hand-rolled emitter gets wrong are the three this closes:
979
+ // author text reaching the output unescaped, a `javascript:` or `data:` URL
980
+ // surviving into an href, and raw HTML passed through in the hope of
981
+ // sanitising it downstream. Each is a stored-XSS hole wherever operator- or
982
+ // author-authored prose is shown to a visitor.
983
+ //
984
+ // The subset is paragraphs, ATX headings, bullet and ordered lists, fenced and
985
+ // indented code, blockquotes, thematic breaks, emphasis, strong, code spans
986
+ // and links. Everything outside it - images, tables, raw HTML, reference
987
+ // links, footnotes - degrades to escaped text. Degrading is the point: an
988
+ // unrecognised construct that renders as its own source is a display bug, and
989
+ // one that renders as markup is a vulnerability.
990
+ //
991
+ // No regex, per the guard-family rule: every scan below is an index walk.
992
+
993
+ // Every text node leaves through here. `apos: "&#39;"` matches guard-html so
994
+ // the two cannot drift on which five characters are escaped.
995
+ // How much larger the rendered output may be than the source it came from.
996
+ //
997
+ // Four, from measurement rather than instinct. Real documents are nowhere near
998
+ // it — this project's README, SECURITY.md and CHANGELOG.md render at 1.22, 1.13
999
+ // and 1.13 — but SHORT constructs carry a fixed markup cost that a small source
1000
+ // cannot absorb: a page of brief fenced samples reaches 3.53, because
1001
+ // `<pre><code class="language-js">` is thirty characters against a fifteen-
1002
+ // character source, and a page of one-line paragraphs reaches 3.00. Three would
1003
+ // have refused a documentation page of code examples, which is worse than the
1004
+ // difference between a 192 MiB and a 256 MiB ceiling on a 64 MiB input.
1005
+ //
1006
+ // The shapes this refuses are further out: a document of nothing but
1007
+ // apostrophes renders at 5.0, and one of nothing but compact links at 7.6.
1008
+ var MAX_OUTPUT_AMPLIFICATION = 4;
1009
+
1010
+ // A SOURCE smaller than this is not bounded by ratio at all.
1011
+ //
1012
+ // A ratio is meaningless on a short document: `hi` renders to `<p>hi</p>\n`,
1013
+ // four times its source, because the fixed cost of a paragraph tag dwarfs two
1014
+ // characters of text. Every small document would be refused. What the ratio
1015
+ // exists to bound is a LARGE input becoming a much larger output, and a small
1016
+ // document's output is small whatever it multiplies by — 20 KiB of apostrophes
1017
+ // renders to 100 KiB, which is nothing to defend against.
1018
+ //
1019
+ // Measured against the SOURCE, not the output allowance. Deriving it from the
1020
+ // allowance instead made the ratio start biting at a quarter of this size,
1021
+ // which is not what the bound says it does.
1022
+ var MIN_SOURCE_FOR_RATIO = C.BYTES.kib(64);
1023
+
1024
+ // Set for the duration of one render() call. Rendering is synchronous from
1025
+ // entry to return, so there is no interleaving to account for; render() clears
1026
+ // it in a finally, including when a cap refuses partway through.
1027
+ var _outputBudget = null;
1028
+
1029
+ // Charge the budget for markup the RENDERER generates, as opposed to author
1030
+ // text, which _escapeText charges. Both count, because both are output: a
1031
+ // six-character `[a](x)` emits a fifty-character anchor with its rel list, so
1032
+ // a document of nothing but compact links amplified 7.6x while the escaped
1033
+ // label and URL inside it stayed tiny. Metering only the author's characters
1034
+ // measured the half that was not growing.
1035
+ function _charge(n) {
1036
+ if (_outputBudget === null) return;
1037
+ _outputBudget.used += n;
1038
+ if (_outputBudget.used > _outputBudget.max) {
1039
+ throw _err("markdown/output-amplification",
1040
+ "b.guardMarkdown.render: rendered output would exceed " +
1041
+ MAX_OUTPUT_AMPLIFICATION + "x the " + _outputBudget.sourceBytes +
1042
+ "-byte source. Both escaping and generated markup expand a document - " +
1043
+ "`'` becomes `&#39;`, and a link becomes an anchor carrying its rel " +
1044
+ "list - so a source written of little else can render to several times " +
1045
+ "its own size.");
1046
+ }
1047
+ }
1048
+
1049
+ // How long `s` will be once escaped, counted without building it.
1050
+ //
1051
+ // markupEscape expands exactly five characters, and each by a known amount:
1052
+ // `&` -> `&amp;` (+4), `<` -> `&lt;` (+3), `>` -> `&gt;` (+3), `"` -> `&quot;`
1053
+ // (+5) and `'` -> `&#39;` (+4). Counting them is O(n) time and O(1) memory,
1054
+ // which is the point: the budget has to be decided BEFORE the escaped string
1055
+ // exists, or the allocation it exists to prevent has already happened.
1056
+ function _escapedLength(s) {
1057
+ var extra = 0;
1058
+ for (var i = 0; i < s.length; i += 1) {
1059
+ var c = s.charAt(i);
1060
+ if (c === "&") extra += 4;
1061
+ else if (c === "<" || c === ">") extra += 3;
1062
+ else if (c === '"') extra += 5;
1063
+ else if (c === "'") extra += 4;
1064
+ }
1065
+ // BYTES, via Buffer.byteLength, because the allowance is in bytes. Counting
1066
+ // `s.length` would count UTF-16 code units against a UTF-8 budget, and the
1067
+ // two diverge on exactly the input most likely to be adversarial: `é` is one
1068
+ // unit and two bytes, an emoji is two units and four bytes. The expansions
1069
+ // added above are all ASCII, so they are the same number either way.
1070
+ return Buffer.byteLength(s, "utf8") + extra;
1071
+ }
1072
+
1073
+ function _escapeText(s) {
1074
+ if (_outputBudget !== null) {
1075
+ // Metered HERE because every character of author text passes through this
1076
+ // one function, and PREDICTED rather than measured, because a single span
1077
+ // can be the whole document: one 64 MiB line of apostrophes would build a
1078
+ // 320 MiB string before any check on the result could run.
1079
+ _charge(_escapedLength(s));
1080
+ }
1081
+ return markupEscape(s, { apos: "&#39;" });
1082
+ }
1083
+
1084
+ // Characters that must never appear inside a quoted attribute value, whatever
1085
+ // the URL parser thinks of them: they end the attribute or open a tag.
1086
+ var _ATTR_UNSAFE = "\"'<>`";
1087
+
1088
+ // A link target is emitted only when it survives BOTH checks: the shared
1089
+ // dangerous-scheme detector (which folds entity and whitespace obfuscation
1090
+ // before testing the scheme) and a literal screen for attribute-breaking
1091
+ // characters. Returns null when the target must not become an href.
1092
+ function _safeHref(url, opts) {
1093
+ if (typeof url !== "string") return null;
1094
+ // Control bytes are screened on the RAW value, BEFORE any trim: trimming
1095
+ // first would strip the leading and trailing C0 / DEL bytes this is here to
1096
+ // refuse, and a URL whose control character sits at the edge is exactly the
1097
+ // one a parser differential exploits.
1098
+ if (codepointClass.firstControlCharOffset(url, { forbidTab: true }) !== -1) return null;
1099
+ var trimmed = url.trim();
1100
+ if (trimmed.length === 0) return null;
1101
+ if (_isDangerousUrl(trimmed, opts) !== null) return null;
1102
+ for (var i = 0; i < trimmed.length; i += 1) {
1103
+ if (_ATTR_UNSAFE.indexOf(trimmed.charAt(i)) !== -1) return null;
1104
+ }
1105
+ // A relative reference carries no scheme and is fine. A target that DOES
1106
+ // carry one is emitted only when that scheme is on the allowlist: an
1107
+ // unrecognised scheme may still be one the browser hands to a registered
1108
+ // protocol handler, so allowlisting is the only safe direction here.
1109
+ var scheme = _schemeOf(trimmed);
1110
+ if (scheme !== null && RENDER_ALLOWED_SCHEMES.indexOf(scheme) === -1) return null;
1111
+ return _escapeText(trimmed);
1112
+ }
1113
+
1114
+ var RENDER_ALLOWED_SCHEMES = ["http", "https", "mailto"];
1115
+
1116
+ // Does the target carry a scheme at all, by the RFC 3986 §3.1 grammar?
1117
+ //
1118
+ // scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
1119
+ //
1120
+ // Reading only the leading LETTER run is not that: it stops at the `+` in
1121
+ // `web+evil:` and concludes there is no scheme, so a registered handler scheme
1122
+ // is treated as a relative path and emitted as a live href. The grammar has to
1123
+ // be read whole before a target can be called relative.
1124
+ //
1125
+ // A relative reference never matches, because RFC 3986 §4.2 requires its first
1126
+ // segment to contain no colon - `a+b.c/d` and `./a+b.c` reach a `/` or `.`
1127
+ // that is not part of a scheme, and the walk stops without a colon.
1128
+ function _hasScheme(s) {
1129
+ if (s.length === 0) return false;
1130
+ if (!codepointClass.isAsciiLetter(s.charCodeAt(0))) return false;
1131
+ for (var i = 1; i < s.length; i += 1) {
1132
+ var cc = s.charCodeAt(i);
1133
+ if (cc === 0x3A) return true; // ":" ends the scheme
1134
+ if (codepointClass.isAsciiAlnum(cc)) continue;
1135
+ if (cc === 0x2B || cc === 0x2D || cc === 0x2E) continue; // "+" "-" "."
1136
+ return false; // anything else: no scheme
1137
+ }
1138
+ return false;
1139
+ }
1140
+
1141
+ // The scheme text itself, lowercased, for an allowlist comparison. Null when
1142
+ // the target carries no scheme.
1143
+ function _schemeOf(s) {
1144
+ if (!_hasScheme(s)) return null;
1145
+ var end = 0;
1146
+ while (s.charCodeAt(end) !== 0x3A) end += 1;
1147
+ return s.slice(0, end).toLowerCase();
1148
+ }
1149
+
1150
+ // ---- Inline ----
1151
+
1152
+ // Emit the inline span `s` (already char-stripped, not yet escaped).
1153
+ // Precedence: code spans first (their contents are literal and are never
1154
+ // re-parsed), then links, then strong, then emphasis.
1155
+ // `rootMatch` and `off` are supplied by the recursive calls below, never by a
1156
+ // caller. The bracket map is derived ONCE for the outermost span and then read
1157
+ // through `_matchIn` at an offset, because both recursion paths hand down a
1158
+ // contiguous slice of the span they were given. Re-deriving it per level made
1159
+ // the work and the live memory scale with nesting DEPTH as well as length:
1160
+ // twenty-four full-length maps for a document inside the balanced profile's
1161
+ // own byte cap, which is how 6.68 MiB of input grew the heap by 1,334 MiB.
1162
+ function _renderInline(s, opts, depth, rootMatch, off) {
1163
+ // Emphasis and link labels recurse. Without a bound, nesting supplied by a
1164
+ // visitor exhausts the call stack, which is a process crash rather than a
1165
+ // refusal - nothing upstream can catch it. Past the bound the span is
1166
+ // emitted as escaped text: the author's characters all survive, they just
1167
+ // stop being markup.
1168
+ var d = depth === undefined ? 0 : depth;
1169
+ if (d > MAX_INLINE_DEPTH) return _escapeText(s);
1170
+
1171
+ var out = "";
1172
+ var i = 0;
1173
+ var textStart = 0;
1174
+ var base = off === undefined ? 0 : off;
1175
+ var match = rootMatch === undefined ? _bracketMap(s) : rootMatch;
1176
+ // One cursor per call, placed at this slice's first opener. The loop below
1177
+ // walks forward only, so the cursor walks with it and every lookup is O(1)
1178
+ // amortised; a nested call gets its own, which costs one search each and is
1179
+ // bounded by MAX_INLINE_DEPTH.
1180
+ var cursor = match ? { i: _lowerBound(match.pos, match.count, base) } : null;
1181
+ function flush(upTo) { out += _escapeText(s.slice(textStart, upTo)); }
1182
+
1183
+ while (i < s.length) {
1184
+ var ch = s.charAt(i);
1185
+
1186
+ // Backslash escape: the next character is literal, never a delimiter.
1187
+ if (ch === "\\" && i + 1 < s.length) {
1188
+ flush(i);
1189
+ out += _escapeText(s.charAt(i + 1));
1190
+ i += 2;
1191
+ textStart = i;
1192
+ continue;
1193
+ }
1194
+
1195
+ // Code span - contents literal. The opening run is capped so a line of
1196
+ // nothing but backticks cannot make the fence length itself O(n).
1197
+ if (ch === "`") {
1198
+ var tickRun = _runLength(s, i, "`", MAX_DELIMITER_RUN);
1199
+ var close = _findRun(s, i + tickRun, "`", tickRun);
1200
+ if (close !== -1) {
1201
+ flush(i);
1202
+ _charge(13); // <code></code>
1203
+ out += "<code>" + _escapeText(s.slice(i + tickRun, close)) + "</code>";
1204
+ i = close + tickRun;
1205
+ textStart = i;
1206
+ continue;
1207
+ }
1208
+ }
1209
+
1210
+ // Image - outside the subset. Emitted as escaped text so an
1211
+ // author-controlled src never becomes an element attribute.
1212
+ if (ch === "!" && s.charAt(i + 1) === "[") {
1213
+ var img = _parseLink(s, i + 1, match, base, cursor);
1214
+ if (img !== null) {
1215
+ flush(i);
1216
+ out += _escapeText(s.slice(i, img.end));
1217
+ i = img.end;
1218
+ textStart = i;
1219
+ continue;
1220
+ }
1221
+ }
1222
+
1223
+ // Link.
1224
+ if (ch === "[") {
1225
+ var link = _parseLink(s, i, match, base, cursor);
1226
+ if (link !== null) {
1227
+ flush(i);
1228
+ var href = _safeHref(link.url, opts);
1229
+ // link.text is s.slice(i + 1, ...), so it starts one past the "[".
1230
+ var label = _renderInline(link.text, opts, d + 1, match, base + i + 1);
1231
+ // A refused target still shows the author's words - dropping them
1232
+ // would hide content rather than neutralise a link.
1233
+ // The fixed wrapper only. `label` is author text charged as it was
1234
+ // escaped, and `href` came back from _safeHref THROUGH _escapeText, so
1235
+ // it was charged there too — subtracting only the label would bill the
1236
+ // target twice and refuse links whose real output is well inside the
1237
+ // bound.
1238
+ if (href !== null) _charge(50); // <a href="" rel="...">…</a>
1239
+ out += href === null ? label
1240
+ : '<a href="' + href + '" rel="nofollow noopener noreferrer">' + label + "</a>";
1241
+ i = link.end;
1242
+ textStart = i;
1243
+ continue;
1244
+ }
1245
+ }
1246
+
1247
+ // Strong, then emphasis. Both delimiters, longest run first. Only the
1248
+ // first two characters of the run decide which, so that is all we count.
1249
+ if (ch === "*" || ch === "_") {
1250
+ var run = _runLength(s, i, ch, 2);
1251
+ var want = run >= 2 ? 2 : 1;
1252
+ var end = _findRun(s, i + want, ch, want);
1253
+ if (end !== -1 && end > i + want) {
1254
+ flush(i);
1255
+ var innerHtml = _renderInline(s.slice(i + want, end), opts, d + 1,
1256
+ match, base + i + want);
1257
+ _charge(want === 2 ? 17 : 9); // <strong></strong> | <em></em>
1258
+ out += want === 2 ? "<strong>" + innerHtml + "</strong>"
1259
+ : "<em>" + innerHtml + "</em>";
1260
+ i = end + want;
1261
+ textStart = i;
1262
+ continue;
1263
+ }
1264
+ }
1265
+
1266
+ i += 1;
1267
+ }
1268
+ flush(s.length);
1269
+ return out;
1270
+ }
1271
+
1272
+ // Length of the run of `ch` starting at `at`, counted no further than `cap`.
1273
+ // The cap is what keeps delimiter scanning linear: every caller only needs to
1274
+ // know whether the run reaches some small length, and walking the whole run to
1275
+ // answer that turns an input of 20k repeated asterisks into a quadratic scan
1276
+ // (measured at ~1s before the cap went in).
1277
+ function _runLength(s, at, ch, cap) {
1278
+ var n = 0;
1279
+ var limit = cap === undefined ? s.length : cap;
1280
+ while (n < limit && at + n < s.length && s.charAt(at + n) === ch) n += 1;
1281
+ return n;
1282
+ }
1283
+
1284
+ // Index of the next run of at least `n` `ch` at or after `from`, or -1.
1285
+ function _findRun(s, from, ch, n) {
1286
+ for (var i = from; i < s.length; i += 1) {
1287
+ if (s.charAt(i) !== ch) continue;
1288
+ if (_runLength(s, i, ch, n) < n) continue;
1289
+ return i;
1290
+ }
1291
+ return -1;
1292
+ }
1293
+
1294
+ // Match every `[` to its `]` and every `(` to its `)` in ONE pass, so a link
1295
+ // parse is a lookup rather than a forward scan. Scanning per delimiter is what
1296
+ // makes an input of 50k unmatched brackets quadratic (measured at ~1.7s before
1297
+ // this map existed); an unmatched opener simply has no entry.
1298
+ // Counts calls, so a test can assert that a document's nesting DEPTH does not
1299
+ // multiply the number of maps built. A time or heap assertion would say the
1300
+ // same thing with a threshold that has to be tuned per machine and flakes
1301
+ // under a loaded runner; the allocation count is exact and depth-independent.
1302
+ var _bracketMapsBuilt = 0;
1303
+ var _bracketArraysAllocated = 0;
1304
+ var _bracketIndexEntries = 0;
1305
+ // Counts index positions examined, so a test can assert the scan stays linear
1306
+ // in the delimiters. A wall-clock assertion would say the same thing with a
1307
+ // threshold that moves with the machine and flakes under a loaded runner.
1308
+ var _bracketLookupSteps = 0;
1309
+ // One per top-level block render. Nesting must not add more - that is the
1310
+ // difference between recording where a quoted line starts and copying it.
1311
+ var _blockOffsetArrays = 0;
1312
+
1313
+ // Three arrays here are sized by the document, and none of them is allocated
1314
+ // until a character needs it.
1315
+ //
1316
+ // A dense `new Array(s.length)` filled with -1 cost one slot per CHARACTER
1317
+ // whatever the document contained, so 64 MiB of ordinary prose carrying no
1318
+ // brackets at all still built 67 million slots before a word was rendered.
1319
+ // Concurrent requests inside the advertised byte cap could exhaust memory on
1320
+ // input with nothing hostile in it.
1321
+ //
1322
+ // Int32Array rather than a plain Array: four bytes a slot instead of eight,
1323
+ // and the platform zeroes it, so there is no O(n) fill loop in JS either. Zero
1324
+ // doubles as "no match" - a closer always sits after its opener, so a recorded
1325
+ // match is never position 0 and the sentinel cannot collide with a real one.
1326
+ //
1327
+ // The opener stacks get the same treatment. They are the reason a run of
1328
+ // unmatched `[` was expensive even though it produces no pairs: every opener
1329
+ // was pushed and none popped. Measured on 8 MiB of `[`, the three-array shape
1330
+ // costs 32 MiB where the plain-array one cost 221 MiB.
1331
+ function _bracketMap(s) {
1332
+ _bracketMapsBuilt += 1;
1333
+ var n = s.length;
1334
+ var i, c;
1335
+
1336
+ // Pass one counts the openers. Nothing here is sized by the DOCUMENT, only
1337
+ // by the delimiters actually in it, so a 64 MiB document carrying a single
1338
+ // "[" pays for one entry rather than for sixty-seven million. Counting
1339
+ // first also means no growth-and-copy: pass two allocates exactly what pass
1340
+ // one found. The escape rule has to be identical in both passes or the
1341
+ // counts and the fill disagree.
1342
+ var nSquare = 0, nRound = 0;
1343
+ for (i = 0; i < n; i += 1) {
1344
+ c = s.charAt(i);
1345
+ if (c === "\\") { i += 1; continue; }
1346
+ if (c === "[") nSquare += 1;
1347
+ else if (c === "(") nRound += 1;
1348
+ }
1349
+ var nOpen = nSquare + nRound;
1350
+ if (nOpen === 0) return null;
1351
+ // A delimiter cap, separate from the byte and line caps.
1352
+ //
1353
+ // The three arrays below are sized by the openers, which is the right unit -
1354
+ // but a document that is NOTHING but openers has as many of them as it has
1355
+ // characters, so at the permissive profile's 64 MiB they would still reserve
1356
+ // hundreds of megabytes. Byte and line caps do not bound this, because a
1357
+ // single 64 MiB line of "[" satisfies both.
1358
+ //
1359
+ // Refused rather than degraded: silently ignoring delimiters past a bound
1360
+ // would make link detection depend on how far into the document a link sits,
1361
+ // which is worse than saying no. The counting pass above is O(1) in memory,
1362
+ // so the refusal happens BEFORE anything document-scale is allocated. Two
1363
+ // million openers is far past any real document - a 64 MiB file would need a
1364
+ // bracket every 32 bytes - and bounds the index at about 24 MiB.
1365
+ if (nOpen > MAX_INLINE_DELIMITERS) {
1366
+ throw _err("markdown/too-many-delimiters",
1367
+ "b.guardMarkdown.render: source contains " + nOpen + " bracket delimiters, " +
1368
+ "over the " + MAX_INLINE_DELIMITERS + " the inline index will hold");
1369
+ }
1370
+
1371
+ // `pos` holds opener positions in ascending order, which they naturally are
1372
+ // - the scan runs left to right - so a position can be found by binary
1373
+ // search rather than by indexing an array the size of the text. `mate` holds
1374
+ // each opener's closer, keyed by the same rank. Zero means unmatched: a
1375
+ // closer always sits after its opener, so a real one is never position 0.
1376
+ var pos = new Int32Array(nOpen);
1377
+ var mate = new Int32Array(nOpen);
1378
+ var sqStack = new Int32Array(nSquare);
1379
+ var rdStack = new Int32Array(nRound);
1380
+ _bracketArraysAllocated += 1;
1381
+ _bracketIndexEntries = nOpen;
1382
+
1383
+ var sqTop = 0, rdTop = 0, k = 0;
1384
+ for (i = 0; i < n; i += 1) {
1385
+ c = s.charAt(i);
1386
+ if (c === "\\") { i += 1; continue; }
1387
+ if (c === "[") { pos[k] = i; sqStack[sqTop] = k; sqTop += 1; k += 1; }
1388
+ else if (c === "(") { pos[k] = i; rdStack[rdTop] = k; rdTop += 1; k += 1; }
1389
+ else if (c === "]") { if (sqTop > 0) { sqTop -= 1; mate[sqStack[sqTop]] = i; } }
1390
+ else if (c === ")") { if (rdTop > 0) { rdTop -= 1; mate[rdStack[rdTop]] = i; } }
1391
+ }
1392
+ return { pos: pos, mate: mate, count: k };
1393
+ }
1394
+
1395
+ // Read one entry of the root map through the window a nested span occupies.
1396
+ //
1397
+ // The map holds absolute positions in the outermost span; `z` is relative to
1398
+ // the slice being rendered. A partner outside the slice reads as no partner,
1399
+ // which is the same answer a map built for the slice alone would give: it
1400
+ // never saw the character that closes the pair. `*a [b* c]` is the shape -
1401
+ // the emphasis run ends before the bracket's partner, so inside the emphasis
1402
+ // the `[` is unmatched either way.
1403
+ // Rank of the first opener at or after `target`.
1404
+ function _lowerBound(pos, count, target) {
1405
+ var lo = 0;
1406
+ var hi = count;
1407
+ while (lo < hi) {
1408
+ var mid = (lo + hi) >> 1;
1409
+ if (pos[mid] < target) lo = mid + 1;
1410
+ else hi = mid;
1411
+ }
1412
+ return lo;
1413
+ }
1414
+
1415
+ // Resolve the closer for the opener at slice-relative `z`.
1416
+ //
1417
+ // `cursor` is the scan position of the caller that is walking this slice left
1418
+ // to right. Because that walk only ever moves forward, the cursor advances
1419
+ // with it and each lookup costs O(1) amortised - the whole scan stays linear
1420
+ // in the delimiters, which is what the profile documentation promises. A
1421
+ // lookup with no cursor falls back to a search, for the one caller that asks
1422
+ // about a position the main scan has already passed.
1423
+ function _matchIn(match, base, len, z, cursor) {
1424
+ // No map at all means the document held no openers - nothing matches.
1425
+ if (match === null || match === undefined) return -1;
1426
+ var target = z + base;
1427
+ var at;
1428
+ if (cursor) {
1429
+ while (cursor.i < match.count && match.pos[cursor.i] < target) {
1430
+ cursor.i += 1;
1431
+ _bracketLookupSteps += 1;
1432
+ }
1433
+ at = cursor.i;
1434
+ } else {
1435
+ at = _lowerBound(match.pos, match.count, target);
1436
+ _bracketLookupSteps += 1;
1437
+ }
1438
+ if (at >= match.count || match.pos[at] !== target) return -1;
1439
+ var m = match.mate[at];
1440
+ if (m === 0) return -1; // recorded no closer
1441
+ m -= base;
1442
+ return (m >= 0 && m < len) ? m : -1;
1443
+ }
1444
+
1445
+ // Parse `[text](url)` starting at the `[`. Returns { text, url, end } or null.
1446
+ // Nesting inside the label and inside the target is handled by the match map,
1447
+ // so `[a [b] c](u)` and `(a(b))` close where a reader expects.
1448
+ function _parseLink(s, at, match, base, cursor) {
1449
+ if (s.charAt(at) !== "[") return null;
1450
+ var off = base === undefined ? 0 : base;
1451
+ var textEnd = _matchIn(match, off, s.length, at, cursor);
1452
+ if (textEnd === -1 || s.charAt(textEnd + 1) !== "(") return null;
1453
+ // No cursor for the target's opening paren. It sits past the position the
1454
+ // caller's scan has reached, and moving the shared cursor there would skip
1455
+ // over openers between the two that the scan has still to visit - if this
1456
+ // parse then fails, those would never be found.
1457
+ var urlEnd = _matchIn(match, off, s.length, textEnd + 1, null);
1458
+ if (urlEnd === -1) return null;
1459
+ var target = s.slice(textEnd + 2, urlEnd);
1460
+ // A title after the target ("url \"title\"") is outside the subset; keep the
1461
+ // target and drop the rest rather than emitting an unvalidated attribute.
1462
+ var sp = _firstSpace(target);
1463
+ if (sp !== -1) target = target.slice(0, sp);
1464
+ return { text: s.slice(at + 1, textEnd), url: target, end: urlEnd + 1 };
1465
+ }
1466
+
1467
+ // ASCII space, tab, and the line terminators - NOT the broad Unicode
1468
+ // whitespace set. A NO-BREAK SPACE is content in Markdown, and treating it as
1469
+ // blank would let U+00A0 end a paragraph or empty a list item.
1470
+ function _isMdSpace(cc) {
1471
+ return cc === 0x20 || (cc >= 0x09 && cc <= 0x0D);
1472
+ }
1473
+
1474
+ function _firstSpace(s) {
1475
+ for (var i = 0; i < s.length; i += 1) {
1476
+ if (_isMdSpace(s.charCodeAt(i))) return i;
1477
+ }
1478
+ return -1;
1479
+ }
1480
+
1481
+ // ---- Block ----
1482
+
1483
+ function _leadingSpaces(line) {
1484
+ var n = 0;
1485
+ while (n < line.length && (line.charAt(n) === " " || line.charAt(n) === "\t")) n += 1;
1486
+ return n;
1487
+ }
1488
+
1489
+ function _isBlank(line) {
1490
+ for (var i = 0; i < line.length; i += 1) {
1491
+ if (!_isMdSpace(line.charCodeAt(i))) return false;
1492
+ }
1493
+ return true;
1494
+ }
1495
+
1496
+ // `---`, `***` or `___`, three or more, nothing else on the line.
1497
+ function _isThematicBreak(line) {
1498
+ var t = line.trim();
1499
+ if (t.length < 3) return false;
1500
+ var ch = t.charAt(0);
1501
+ if (ch !== "-" && ch !== "*" && ch !== "_") return false;
1502
+ for (var i = 0; i < t.length; i += 1) if (t.charAt(i) !== ch) return false;
1503
+ return true;
1504
+ }
1505
+
1506
+ // A bullet marker: "-", "*" or "+" followed by a space.
1507
+ function _bulletAt(line) {
1508
+ var n = _leadingSpaces(line);
1509
+ var ch = line.charAt(n);
1510
+ if (ch !== "-" && ch !== "*" && ch !== "+") return -1;
1511
+ if (line.charAt(n + 1) !== " ") return -1;
1512
+ return n + 2;
1513
+ }
1514
+
1515
+ // An ordered marker: digits followed by "." or ")" and a space.
1516
+ function _orderedAt(line) {
1517
+ var n = _leadingSpaces(line);
1518
+ var d = n;
1519
+ while (d < line.length && codepointClass.isAsciiDigit(line.charCodeAt(d))) d += 1;
1520
+ if (d === n) return -1;
1521
+ var sep = line.charAt(d);
1522
+ if (sep !== "." && sep !== ")") return -1;
1523
+ if (line.charAt(d + 1) !== " ") return -1;
1524
+ return d + 2;
1525
+ }
1526
+
1527
+ // A fence opener: three or more backticks or tildes at the line start.
1528
+ function _fenceAt(line) {
1529
+ var n = _leadingSpaces(line);
1530
+ var ch = line.charAt(n);
1531
+ if (ch !== "`" && ch !== "~") return null;
1532
+ var run = _runLength(line, n, ch);
1533
+ if (run < 3) return null;
1534
+ return { ch: ch, run: run, info: line.slice(n + run).trim() };
1535
+ }
1536
+
1537
+ var MAX_HEADING_LEVEL = 6;
1538
+
1539
+ // An ATX heading, or null. `#` alone is not one, `#no-space` is not one, and
1540
+ // seven hashes is not one - each of those is ordinary text.
1541
+ //
1542
+ // This is a NAMED predicate rather than an inline test because the block
1543
+ // dispatcher and the paragraph terminator both have to agree on what starts a
1544
+ // heading. When they were written separately they disagreed: the paragraph
1545
+ // loop stopped at any line beginning with `#` while the dispatcher accepted
1546
+ // only a well-formed one, so `#not-a-heading` matched no block, ended no
1547
+ // paragraph, and was dropped without a trace.
1548
+ function _headingAt(line) {
1549
+ var at = _leadingSpaces(line);
1550
+ if (line.charAt(at) !== "#") return null;
1551
+ var level = _runLength(line, at, "#", MAX_HEADING_LEVEL + 1);
1552
+ if (level > MAX_HEADING_LEVEL) return null;
1553
+ if (at + level !== line.length && line.charAt(at + level) !== " ") return null;
1554
+ var text = line.slice(at + level).trim();
1555
+ // Trailing closing hashes are decoration, not content.
1556
+ while (text.length > 0 && text.charAt(text.length - 1) === "#") {
1557
+ text = text.slice(0, text.length - 1);
1558
+ }
1559
+ return { level: level, text: text.trim() };
1560
+ }
1561
+
1562
+ // Four spaces (or a tab) of indent opens a code block - but only where a
1563
+ // paragraph or list item is not already in progress, which the caller decides.
1564
+ var INDENTED_CODE_COLUMNS = 4;
1565
+
1566
+ function _isIndentedCode(line) {
1567
+ if (_isBlank(line)) return false;
1568
+ if (line.charAt(0) === "\t") return true;
1569
+ for (var i = 0; i < INDENTED_CODE_COLUMNS; i += 1) {
1570
+ if (line.charAt(i) !== " ") return false;
1571
+ }
1572
+ return true;
1573
+ }
1574
+
1575
+ // The ONE answer to "does this line start a block?", shared by the dispatcher
1576
+ // and the paragraph terminator so the two cannot drift apart again.
1577
+ function _startsBlock(line) {
1578
+ return _fenceAt(line) !== null ||
1579
+ _isThematicBreak(line) ||
1580
+ _headingAt(line) !== null ||
1581
+ line.charAt(_leadingSpaces(line)) === ">" ||
1582
+ _bulletAt(line) !== -1 ||
1583
+ _orderedAt(line) !== -1;
1584
+ }
1585
+
1586
+ // How deep emphasis and link labels may nest before the span is emitted as
1587
+ // escaped text instead of markup. Well past any hand-written document, far
1588
+ // short of the call-stack limit.
1589
+ var MAX_INLINE_DEPTH = 24;
1590
+
1591
+ // The blockquote renderer recurses once per nesting level, so the depth it can
1592
+ // survive is a property of the call stack, not of operator policy.
1593
+ // maxBlockquoteDepth is an operator-settable cap and the profiles set it to
1594
+ // 16 / 64 / 256; nothing stopped a caller passing 10001, at which point a
1595
+ // document of 10,000 "> " prefixes exhausted the stack and threw a native
1596
+ // RangeError - a process-level failure escaping a primitive whose whole job is
1597
+ // to turn hostile input into a refusal. This ceiling sits above the loosest
1598
+ // shipped profile, so it changes no profile's behaviour; it only bounds what a
1599
+ // raised policy limit can ask the implementation to do.
1600
+ var MAX_BLOCKQUOTE_RECURSION = 512;
1601
+
1602
+ // The most bracket delimiters the inline index will hold. See _bracketMap: the
1603
+ // index is sized by openers, so a document made entirely of them is sized by
1604
+ // its length however small the byte cap is set. Two million bounds the index
1605
+ // at roughly 24 MiB and sits far past any real document.
1606
+ var MAX_INLINE_DELIMITERS = 2000000;
1607
+
1608
+ // Longest delimiter run any caller needs to distinguish. Runs longer than this
1609
+ // are still matched; only the COUNTING stops here, which is what keeps a line
1610
+ // of repeated delimiters linear rather than quadratic.
1611
+ var MAX_DELIMITER_RUN = 8;
1612
+
1613
+ // `src`/`off`/`from`/`to` are supplied by the blockquote recursion below and
1614
+ // never by a caller. Stripping a quote level used to build a fresh array of
1615
+ // fresh strings for every line in the run, at every level, with each parent's
1616
+ // array still live - so a document nested to the profile's permitted depth
1617
+ // retained roughly depth x length. Measured at the permissive profile, 3.94 MiB
1618
+ // nested 256 deep grew the heap by 85 MiB, and the cap allows sixteen times
1619
+ // that input.
1620
+ //
1621
+ // Now one offsets array is allocated for the whole document and a level records
1622
+ // where each line's content starts, so a level costs the offsets it advances
1623
+ // rather than a copy of everything below it.
1624
+ function _renderBlocks(lines, opts, depth, off, from, to) {
1625
+ var d = depth === undefined ? 0 : depth;
1626
+ var lo = from === undefined ? 0 : from;
1627
+ var hi = to === undefined ? lines.length : to;
1628
+ var offs = off;
1629
+ if (offs === undefined) { offs = new Int32Array(lines.length); _blockOffsetArrays += 1; }
1630
+ // Reading a line applies its recorded offset. V8 shares the backing store for
1631
+ // a slice of this size, so this is a view rather than a copy of the text.
1632
+ function L(k) {
1633
+ var o = offs[k];
1634
+ return o === 0 ? lines[k] : lines[k].slice(o);
1635
+ }
1636
+ var out = "";
1637
+ var i = lo;
1638
+ while (i < hi) {
1639
+ var line = L(i);
1640
+
1641
+ if (_isBlank(line)) { i += 1; continue; }
1642
+
1643
+ var fence = _fenceAt(line);
1644
+ if (fence !== null) {
1645
+ var body = [];
1646
+ i += 1;
1647
+ while (i < hi) {
1648
+ var f = _fenceAt(L(i));
1649
+ if (f !== null && f.ch === fence.ch && f.run >= fence.run && f.info === "") { i += 1; break; }
1650
+ body.push(L(i));
1651
+ i += 1;
1652
+ }
1653
+ // The info string is author-controlled, so it becomes an escaped class
1654
+ // rather than being interpolated raw.
1655
+ var cls = fence.info.length > 0
1656
+ ? ' class="language-' + _escapeText(_firstWord(fence.info)) + '"' : "";
1657
+ // The fixed markup only. `cls` embeds _escapeText(_firstWord(info)), so
1658
+ // the info token was already charged as it was escaped; adding cls.length
1659
+ // would bill it twice.
1660
+ _charge(fence.info.length > 0 ? 43 : 25); // + ` class="language-"`
1661
+ out += "<pre><code" + cls + ">" + _escapeText(body.join("\n")) + "</code></pre>\n";
1662
+ continue;
1663
+ }
1664
+
1665
+ if (_isThematicBreak(line)) { _charge(5); out += "<hr>\n"; i += 1; continue; }
1666
+
1667
+ // ATX heading.
1668
+ var heading = _headingAt(line);
1669
+ if (heading !== null) {
1670
+ _charge(9); // <hN></hN>\n
1671
+ out += "<h" + heading.level + ">" + _renderInline(heading.text, opts) +
1672
+ "</h" + heading.level + ">\n";
1673
+ i += 1;
1674
+ continue;
1675
+ }
1676
+
1677
+ // Indented code - four spaces or a tab, contents literal. Only reachable
1678
+ // where no paragraph is in progress, because the paragraph loop below
1679
+ // consumes its own continuation lines first.
1680
+ if (_isIndentedCode(line)) {
1681
+ var codeLines = [];
1682
+ while (i < hi && (_isIndentedCode(L(i)) || _isBlank(L(i)))) {
1683
+ // A blank line inside indented code belongs to the block only if more
1684
+ // indented code follows; otherwise it ends it.
1685
+ if (_isBlank(L(i))) {
1686
+ var j = i + 1;
1687
+ while (j < hi && _isBlank(L(j))) j += 1;
1688
+ if (j >= hi || !_isIndentedCode(L(j))) break;
1689
+ codeLines.push("");
1690
+ i += 1;
1691
+ continue;
1692
+ }
1693
+ var codeLine = L(i);
1694
+ codeLines.push(codeLine.charAt(0) === "\t"
1695
+ ? codeLine.slice(1)
1696
+ : codeLine.slice(INDENTED_CODE_COLUMNS));
1697
+ i += 1;
1698
+ }
1699
+ _charge(25); // <pre><code></code></pre>\n
1700
+ out += "<pre><code>" + _escapeText(codeLines.join("\n")) + "</code></pre>\n";
1701
+ continue;
1702
+ }
1703
+
1704
+ // Blockquote - collect the run, strip one "> " level, recurse. The depth
1705
+ // is bounded by the profile's own maxBlockquoteDepth, which validate
1706
+ // already enforces; without it here, nesting supplied by a visitor
1707
+ // exhausts the call stack and takes the process down rather than
1708
+ // producing a refusal something upstream could handle.
1709
+ if (line.charAt(_leadingSpaces(line)) === ">") {
1710
+ var bqCap = opts.maxBlockquoteDepth < MAX_BLOCKQUOTE_RECURSION
1711
+ ? opts.maxBlockquoteDepth
1712
+ : MAX_BLOCKQUOTE_RECURSION;
1713
+ if (d >= bqCap) {
1714
+ throw _err("markdown/blockquote-depth",
1715
+ "b.guardMarkdown.render: blockquote nesting exceeds " +
1716
+ (bqCap === opts.maxBlockquoteDepth
1717
+ ? "maxBlockquoteDepth (" + opts.maxBlockquoteDepth + ")"
1718
+ : "the " + MAX_BLOCKQUOTE_RECURSION + "-level renderer ceiling, below " +
1719
+ "the configured maxBlockquoteDepth of " + opts.maxBlockquoteDepth));
1720
+ }
1721
+ // Record where each line's content starts one level in, rather than
1722
+ // building a level's worth of new strings. The run is a contiguous window
1723
+ // of the SAME line array, so the recursion needs a window rather than a
1724
+ // copy of it.
1725
+ var qStart = i;
1726
+ while (i < hi) {
1727
+ var ql = L(i);
1728
+ if (_isBlank(ql) || ql.charAt(_leadingSpaces(ql)) !== ">") break;
1729
+ var adv = _leadingSpaces(ql) + 1;
1730
+ if (ql.charAt(adv) === " ") adv += 1;
1731
+ offs[i] += adv;
1732
+ i += 1;
1733
+ }
1734
+ _charge(27); // <blockquote>\n</blockquote>\n
1735
+ out += "<blockquote>\n" + _renderBlocks(lines, opts, d + 1, offs, qStart, i) +
1736
+ "</blockquote>\n";
1737
+ continue;
1738
+ }
1739
+
1740
+ // Lists.
1741
+ var bullet = _bulletAt(line);
1742
+ var ordered = bullet === -1 ? _orderedAt(line) : -1;
1743
+ if (bullet !== -1 || ordered !== -1) {
1744
+ var isOrdered = bullet === -1;
1745
+ var tag = isOrdered ? "ol" : "ul";
1746
+ _charge(tag.length + 3); // <ul>\n | <ol>\n
1747
+ out += "<" + tag + ">\n";
1748
+ while (i < hi) {
1749
+ var listLine = L(i);
1750
+ var at = isOrdered ? _orderedAt(listLine) : _bulletAt(listLine);
1751
+ if (at === -1) break;
1752
+ _charge(10); // <li></li>\n
1753
+ out += "<li>" + _renderInline(listLine.slice(at).trim(), opts) + "</li>\n";
1754
+ i += 1;
1755
+ }
1756
+ _charge(tag.length + 4); // </ul>\n | </ol>\n
1757
+ out += "</" + tag + ">\n";
1758
+ continue;
1759
+ }
1760
+
1761
+ // Paragraph - this line plus every following non-blank line that starts no
1762
+ // other block. The FIRST line is taken unconditionally: control only
1763
+ // reaches here when no block matched it, so testing it again could only
1764
+ // disagree with the dispatcher, which is how lines went missing before.
1765
+ var para = [line.trim()];
1766
+ i += 1;
1767
+ while (i < hi) {
1768
+ var paraLine = L(i);
1769
+ if (_isBlank(paraLine) || _startsBlock(paraLine)) break;
1770
+ para.push(paraLine.trim());
1771
+ i += 1;
1772
+ }
1773
+ _charge(8); // <p></p>\n
1774
+ out += "<p>" + _renderInline(para.join("\n"), opts) + "</p>\n";
1775
+ }
1776
+ return out;
1777
+ }
1778
+
1779
+ function _firstWord(s) {
1780
+ var sp = _firstSpace(s);
1781
+ return sp === -1 ? s : s.slice(0, sp);
1782
+ }
1783
+
1784
+ /**
1785
+ * @primitive b.guardMarkdown.render
1786
+ * @signature b.guardMarkdown.render(source, opts?)
1787
+ * @since 0.18.44
1788
+ * @status stable
1789
+ * @compliance hipaa, pci-dss, gdpr, soc2
1790
+ * @related b.guardMarkdown.validate, b.guardMarkdown.sanitize, b.template.escapeHtml
1791
+ *
1792
+ * Render Markdown to an HTML fragment, escaping by default.
1793
+ *
1794
+ * Every text node leaves through the shared markup escaper, every link target
1795
+ * is screened before it can become an `href`, and raw HTML is emitted as
1796
+ * escaped text rather than passed through. Those are the three things a
1797
+ * hand-rolled emitter gets wrong, and each of them is a stored-XSS hole
1798
+ * wherever author-supplied prose is shown to a visitor.
1799
+ *
1800
+ * The subset is deliberate: paragraphs, ATX headings, bullet and ordered
1801
+ * lists, fenced and indented code, blockquotes, thematic breaks, emphasis,
1802
+ * strong, code spans and links. Anything outside it - images, tables,
1803
+ * reference links, footnotes, raw HTML - renders as escaped text. That is a
1804
+ * display limitation by choice: an unrecognised construct that shows its own
1805
+ * source is a formatting bug, while one that becomes markup is a
1806
+ * vulnerability.
1807
+ *
1808
+ * Link targets are limited to `http`, `https`, `mailto` and relative
1809
+ * references. A target carrying any other scheme, an attribute-breaking
1810
+ * character, or a control character is refused - the link's TEXT is still
1811
+ * rendered, so a refusal never silently deletes the author's words. Anchors
1812
+ * carry `rel="nofollow noopener noreferrer"`.
1813
+ *
1814
+ * BIDI, zero-width, C0-control and NUL characters are stripped before parsing
1815
+ * regardless of profile. Unlike validation, where an operator may want to be
1816
+ * told about them and decide, an invisible character reaching rendered HTML is
1817
+ * never what the author meant.
1818
+ *
1819
+ * The output is a fragment, not a document: no wrapper element, no doctype.
1820
+ * It is meant to be inserted into a page whose own Content-Security-Policy is
1821
+ * doing its job, not to replace one.
1822
+ *
1823
+ * What the profile changes is worth stating, because the two halves differ.
1824
+ * The SAFETY floor is profile-independent — escaping, the link-target
1825
+ * allowlist and raw-HTML-as-text are identical at every profile, since there
1826
+ * is no safe way to loosen them. What varies is the SIZE budget, enforced
1827
+ * before anything is parsed: `maxBytes` (1 MiB / 8 MiB / 64 MiB, measured in
1828
+ * BYTES so a non-ASCII document is not silently allowed several times the
1829
+ * stated size), `maxLines` (4,096 / 32,768 / 262,144) and
1830
+ * `maxBlockquoteDepth` (16 / 64 / 256). Choose the profile for the document
1831
+ * sizes you intend to accept, not for how much escaping you want.
1832
+ *
1833
+ * A `maxBlockquoteDepth` raised past 512 is bounded at 512, because the
1834
+ * renderer recurses once per level and what the call stack survives is not an
1835
+ * operator setting. Nesting beyond the effective bound is refused with
1836
+ * `markdown/blockquote-depth`, which is a verdict a caller can handle, rather
1837
+ * than the stack overflow it would otherwise become.
1838
+ *
1839
+ * @opts
1840
+ * profile: "strict"|"balanced"|"permissive",
1841
+ * compliancePosture: "hipaa"|"pci-dss"|"gdpr"|"soc2",
1842
+ * ...: same shape as b.guardMarkdown.validate opts,
1843
+ *
1844
+ * @example
1845
+ * b.guardMarkdown.render("# Title\n\nA [link](https://example.com).");
1846
+ * // -> "<h1>Title</h1>\n<p>A <a href=\"https://example.com\" rel=\"nofollow noopener noreferrer\">link</a>.</p>\n"
1847
+ *
1848
+ * b.guardMarkdown.render("[x](javascript:alert(1))");
1849
+ * // -> "<p>x</p>\n" (the target is refused, the text survives)
1850
+ */
1851
+ function render(source, opts) {
1852
+ if (typeof source !== "string") {
1853
+ throw _err("markdown/bad-input",
1854
+ "b.guardMarkdown.render: source must be a string; got " + typeof source);
1855
+ }
1856
+ // resolveOpts refuses an unknown profile / posture, so a typo is a boot
1857
+ // error rather than a silent fall back to the loosest behaviour.
1858
+ var resolved = module.exports.resolveOpts(opts);
1859
+
1860
+ // The profile's input caps bind here exactly as they do in validate. Without
1861
+ // them the documented options would be decorative on this path and an
1862
+ // attacker could hand over an arbitrarily large document to strip and parse
1863
+ // — and this input is untrusted by definition, which is the whole reason the
1864
+ // caps exist.
1865
+ //
1866
+ // BYTES, via Buffer.byteLength, not `source.length`. The cap is named
1867
+ // maxBytes and a character count is up to 4x short of it on non-ASCII input,
1868
+ // so measuring the wrong representation would leave the cap silently wrong
1869
+ // for exactly the documents most likely to be hostile.
1870
+ var byteLen = Buffer.byteLength(source, "utf8");
1871
+ if (byteLen > resolved.maxBytes) {
1872
+ throw _err("markdown/too-large",
1873
+ "b.guardMarkdown.render: source is " + byteLen + " bytes, over the " +
1874
+ resolved.maxBytes + "-byte maxBytes for this profile");
1875
+ }
1876
+ var lineCount = _markdownLines(source).length;
1877
+ if (lineCount > resolved.maxLines) {
1878
+ throw _err("markdown/too-many-lines",
1879
+ "b.guardMarkdown.render: source has " + lineCount + " lines, over the " +
1880
+ resolved.maxLines + "-line maxLines for this profile");
1881
+ }
1882
+
1883
+ var text = codepointClass.applyCharStripPolicies(source, {
1884
+ bidiPolicy: "strip",
1885
+ controlPolicy: "strip",
1886
+ nullBytePolicy: "strip",
1887
+ zeroWidthPolicy: "strip",
1888
+ });
1889
+ _outputBudget = {
1890
+ used: 0,
1891
+ max: byteLen < MIN_SOURCE_FOR_RATIO
1892
+ ? Infinity
1893
+ : byteLen * MAX_OUTPUT_AMPLIFICATION,
1894
+ sourceBytes: byteLen,
1895
+ };
1896
+ try {
1897
+ return _renderBlocks(_markdownLines(text), resolved);
1898
+ } finally {
1899
+ // Cleared whether the render returned or a cap refused, so one call's
1900
+ // budget can never be inherited by the next.
1901
+ _outputBudget = null;
1902
+ }
1903
+ }
1904
+
967
1905
  module.exports = gateContract.defineGuard({
968
1906
  name: "markdown",
969
1907
  kind: "content",
@@ -986,7 +1924,13 @@ module.exports = gateContract.defineGuard({
986
1924
  "maxRefDefs", "maxListDepth", "maxBlockquoteDepth"],
987
1925
  gate: gate,
988
1926
  extra: {
1927
+ render: render,
989
1928
  _gateDispositionForTest: _gateDispositionFor,
1929
+ _bracketMapsBuiltForTest: function () { return _bracketMapsBuilt; },
1930
+ _bracketArraysAllocatedForTest: function () { return _bracketArraysAllocated; },
1931
+ _bracketIndexEntriesForTest: function () { return _bracketIndexEntries; },
1932
+ _bracketLookupStepsForTest: function () { return _bracketLookupSteps; },
1933
+ _blockOffsetArraysForTest: function () { return _blockOffsetArrays; },
990
1934
  // The extractors and shape screens, exposed so the test can compare each
991
1935
  // against the pattern it replaced rather than only through a whole-document
992
1936
  // scan.