@blamejs/core 0.18.36 → 0.18.37

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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,28 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.18.x
10
10
 
11
+ - v0.18.37 (2026-08-19) — **The no-regex rule for content-safety primitives never covered lib/parsers, where 55 patterns were screening adversarial input.** SECURITY.md states that no `b.guard*` or `b.safe*` primitive contains a regular expression, so a screen costs the length of its input and the byte cap that bounds the input bounds the screen. The build gate enforcing that selected files with `lib/(safe-|guard-)[^/]+\.js` — top level only. Every nested primitive was therefore outside it, and all five of them are the ones that parse untrusted bytes: `b.parsers.yaml`, `b.parsers.toml`, `b.parsers.ini`, `b.parsers.env` and `b.parsers.xml`. Between them they carried 55 pattern literals.
12
+
13
+ All 55 are gone, and the gate now covers nested paths. Nothing about the parsers' behaviour changes: each screen was differential-tested against the pattern it replaced before the call site was swapped.
14
+
15
+ Upgrade if you parse YAML, TOML, INI, `.env` or XML from anything you do not control. **Changed:** *The no-regex gate asks a parser instead of walking the source itself* — Deciding whether a `/` opens a regular expression or divides means implementing the ECMAScript lexical grammar, and the previous scanner kept meeting parts of it that were not implemented yet — template substitutions nest, `of` is contextual, a labelled `break` ends at the terminator after its label, `<!--` is a comment, a shebang is not JavaScript.
16
+
17
+ The check now runs as an ESLint rule, which sees the file after it has already been parsed. All of those questions are answered before the rule is asked. It needs no new dependency: ESLint already runs over the repository as the first static gate, and a flat config takes an inline rule.
18
+
19
+ Suppression, if a case ever warrants it, is the standard `// eslint-disable-next-line blamejs/no-regex-in-content-safety` with the reason on the line above, replacing a bespoke marker that was never used.
20
+
21
+ The hand-written scanner is gone with it — about 1,240 lines, including the fixture table that had grown to justify it. Leaving both in place would have meant a justified exception passing one gate and failing the other, since the old one honoured only its own marker.
22
+
23
+ The rule reports pattern literals. `new RegExp(...)` is not yet included: three call sites use it and they are not one thing — two compile a pattern in order to validate it, which is the opposite of matching against input, while `b.guardSql` builds its detectors that way and does match input. Reporting all three today would mean either an allowlist entry for the one that should be caught, or a rushed conversion of a set of injection detectors. It is tracked instead, and the rule stays exactly as strict as the scanner it replaces. **Fixed:** *A YAML tag beginning with a digit slipped past the tag ban* — `b.parsers.yaml` refuses anchors, aliases, tags and directives. The tag check accepted `!` followed by a letter or `<`, while the comment beside it described the rule as "alphanumeric or `<`" — and the code was the weaker of the two. `a: !123` is a local tag, and it parsed as the plain string `"!123"` rather than being refused.
24
+
25
+ It is now refused, along with `!!123`. A quoted `"!123"` is still an ordinary string, because a quoted scalar is not a tag. **Security:** *Every pattern literal is gone from the five parsers that consume adversarial bytes* — Fifty-five of them: `safe-yaml` 24, `safe-toml` 16, `safe-env` 8, `safe-ini` 7, `safe-xml` 1.
26
+
27
+ The replacements walk characters: YAML's core-schema scalar resolution is now exact-set membership and a left-to-right digit walk; TOML's date, time and offset fields are index comparisons against fixed widths; INI's integer, float and hex classification and its `[name "subsection"]` header are single passes; `.env`'s inline-comment split, `export ` prefix and `$VAR` refusal are scans.
28
+
29
+ Every replacement was compared against the pattern it replaced over exhaustive short strings and adversarial cases before the call site changed — between roughly 60,000 and 1,400,000 inputs each, all with zero divergence. Where a reported line number derives from a match position, the position was compared too, not just the verdict.
30
+
31
+ One honest note on scale: these patterns were not all hanging. `.env`'s inline-comment matcher looked like catastrophic backtracking and is not — its separator and token classes are disjoint, so the partition of a line is unique, and it measures flat to 40,000 characters. The rule removes it anyway, and that is the point: the cost of a screen in this family holds by construction, rather than by someone re-deriving the ambiguity of each pattern correctly every time one is edited. **References:** [ESTree — RegExpLiteral carries the pattern and flags as parsed data](https://github.com/estree/estree/blob/master/es5.md#regexpliteral) · [OWASP — Regular expression Denial of Service (ReDoS)](https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS)
32
+
11
33
  - v0.18.36 (2026-08-18) — **Six constant-time comparisons told you which candidate matched, by how long they took.** A comparison that runs in constant time answers one question without leaking anything. A loop of them that stops at the first match answers a second question nobody asked: how far down the list the match was. Six places in the framework compared a presented value against a list of candidates and returned as soon as one matched, so the response time reported the match's position — which deny-list entry a certificate hit, which verifier's nonce a token carried, where in a key rotation the sender currently sits.
12
34
 
13
35
  Every one of them returned the correct answer. The defect is only in how long it took, which is why no test caught it and why the guard that replaces them is structural.
@@ -64,6 +64,7 @@ var lazyRequire = require("../lazy-require");
64
64
  var numericBounds = require("../numeric-bounds");
65
65
  var safeBuffer = require("../safe-buffer");
66
66
  var safeJson = require("../safe-json");
67
+ var codepointClass = require("../codepoint-class");
67
68
  var { FrameworkError } = require("../framework-error");
68
69
  var { boot } = require("../log");
69
70
 
@@ -90,10 +91,117 @@ class SafeEnvError extends FrameworkError {
90
91
  }
91
92
  }
92
93
 
94
+ // Split an unquoted value at an inline `# comment`, returning the text BEFORE
95
+ // the comment, or null when there is no comment to strip. Replaces
96
+ // `/^([^\s#]*(?:[ \t]+[^#\s]+)*)\s+#.*$/`.
97
+ //
98
+ // That pattern was NOT catastrophic, and it is worth saying so rather than
99
+ // implying the family rule only ever removes live bugs. `[ \t]+` and `[^#\s]+`
100
+ // are disjoint, so the partition of a line into separators and tokens is unique
101
+ // and there is nothing for the engine to backtrack through — measured flat to
102
+ // 40k characters across every shape that defeats the match. It goes because the
103
+ // rule is uniform: a screen in this family costs the length of its input, and
104
+ // that holds by construction instead of by someone re-deriving the ambiguity of
105
+ // each pattern correctly, every time one is edited. The neighbouring
106
+ // trailing-whitespace strip is already a walk, for a reason that WAS measured —
107
+ // `.replace(/[ \t]+$/)` is quadratic in V8 and the parser caps total bytes, not
108
+ // bytes per line.
109
+ //
110
+ // Reading the pattern back gives a rule with no search in it. Group 1 admits no
111
+ // `#` at all, so the `#` the pattern finds is always the FIRST one; it must be
112
+ // preceded by whitespace; and everything before that whitespace run may only be
113
+ // separated by spaces and tabs (a `\v`, `\f` or U+00A0 is whitespace to `\s` but
114
+ // not to `[ \t]`, so the pattern would not match and neither does this).
115
+ function _splitInlineComment(rest) {
116
+ var hash = rest.indexOf("#");
117
+ if (hash <= 0) return null; // absent, or nothing before it
118
+ if (!codepointClass.inRanges(rest.charCodeAt(hash - 1), codepointClass.WHITESPACE_RANGES)) {
119
+ return null; // `color#red` keeps its `#`
120
+ }
121
+ // Walk back over the whitespace run that `\s+` consumed.
122
+ var end = hash - 1;
123
+ while (end >= 0 && codepointClass.inRanges(rest.charCodeAt(end), codepointClass.WHITESPACE_RANGES)) {
124
+ end -= 1;
125
+ }
126
+ var head = rest.slice(0, end + 1);
127
+ // The separators inside group 1 are `[ \t]` only.
128
+ for (var i = 0; i < head.length; i += 1) {
129
+ var cc = head.charCodeAt(i);
130
+ if (!codepointClass.inRanges(cc, codepointClass.WHITESPACE_RANGES)) continue;
131
+ if (cc !== 0x20 && cc !== 0x09) return null;
132
+ }
133
+ // `#.*$` cannot cross a line terminator, and `$` without `m` is end-of-string.
134
+ // Lines arrive split on LF and CR already, but U+2028 and U+2029 survive that
135
+ // and are terminators to the grammar, so a comment containing one meant the
136
+ // pattern did not match and the whole value was kept.
137
+ for (var t = hash; t < rest.length; t += 1) {
138
+ var tc = rest.charCodeAt(t);
139
+ if (tc === 0x0A || tc === 0x0D || tc === 0x2028 || tc === 0x2029) return null;
140
+ }
141
+ return head;
142
+ }
143
+
144
+ // `export FOO=bar` — the POSIX shell convention. Replaces a `/^export\s+/` test
145
+ // paired with an identical `.replace`, which walked the prefix twice.
146
+ // The default key shape, `^[A-Z_][A-Z0-9_]*$`, as a walk. The label is what the
147
+ // refusal message quotes, so an operator still sees the shape they violated
148
+ // rather than the word "function".
149
+ var _ASCII_UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
150
+ var _KEY_HEAD_CHARS = _ASCII_UPPER + "_";
151
+ var _KEY_TAIL_CHARS = _ASCII_UPPER + codepointClass.ASCII_DIGITS + "_";
152
+ var DEFAULT_KEY_SHAPE = "^[A-Z_][A-Z0-9_]*$";
153
+
154
+ function _matchesDefaultKeyShape(key) {
155
+ if (typeof key !== "string" || key.length === 0) return false;
156
+ if (_KEY_HEAD_CHARS.indexOf(key.charAt(0)) === -1) return false;
157
+ if (key.length === 1) return true; // isRunOf("") is false
158
+ return codepointClass.isRunOf(key.slice(1), _KEY_TAIL_CHARS);
159
+ }
160
+
161
+ var _EXPORT = "export";
162
+
163
+ function _stripExportPrefix(line) {
164
+ if (line.slice(0, _EXPORT.length) !== _EXPORT) return line;
165
+ var i = _EXPORT.length;
166
+ var afterWord = i;
167
+ while (i < line.length &&
168
+ codepointClass.inRanges(line.charCodeAt(i), codepointClass.WHITESPACE_RANGES)) i += 1;
169
+ if (i === afterWord) return line; // `exported=1` is a key, not a prefix
170
+ return line.slice(i);
171
+ }
172
+
173
+ // `$VAR` and `${VAR}` references, which this parser refuses rather than expands.
174
+ // Replaces `/\$(\{[A-Za-z_]|[A-Za-z_])/` — a `$` followed either by a brace and
175
+ // an identifier head, or by an identifier head directly. A trailing `$`, `$1` or
176
+ // `$ ` is a literal dollar and stays one.
177
+ function _isIdentifierHead(ch) {
178
+ // The length guard is load-bearing: `charAt` past the end returns "", and
179
+ // `indexOf("")` is 0 on every string, so without it a trailing `$` reads as a
180
+ // reference and a literal dollar at end-of-value is refused.
181
+ if (ch.length !== 1) return false;
182
+ return ch === "_" || codepointClass.ASCII_ALPHA.indexOf(ch) !== -1;
183
+ }
184
+
185
+ function _hasVariableReference(text) {
186
+ for (var i = 0; i < text.length; i += 1) {
187
+ if (text.charAt(i) !== "$") continue;
188
+ var next = text.charAt(i + 1);
189
+ if (next === "{") {
190
+ if (_isIdentifierHead(text.charAt(i + 2))) return true;
191
+ continue;
192
+ }
193
+ if (_isIdentifierHead(next)) return true;
194
+ }
195
+ return false;
196
+ }
197
+
93
198
  var DEFAULTS = {
94
199
  maxBytes: C.BYTES.kib(64),
95
200
  maxKeys: 1_000,
96
- keyShape: /^[A-Z_][A-Z0-9_]*$/,
201
+ // The DEFAULT shape is a walk, not a pattern. An operator may still pass a
202
+ // RegExp — there the pattern is the INPUT, which is the one case this family
203
+ // allows — and that override is honoured unchanged below.
204
+ keyShape: null,
97
205
  applyToProcess: false,
98
206
  allowOverwrite: false,
99
207
  rejectUnknown: false,
@@ -120,7 +228,11 @@ function parse(input, opts) {
120
228
  ? Math.min(opts.maxBytes, C.BYTES.mib(1)) : DEFAULTS.maxBytes;
121
229
  var maxKeys = opts.maxKeys !== undefined
122
230
  ? Math.min(opts.maxKeys, 100_000) : DEFAULTS.maxKeys;
123
- var keyShape = opts.keyShape instanceof RegExp ? opts.keyShape : DEFAULTS.keyShape;
231
+ var operatorKeyShape = opts.keyShape instanceof RegExp ? opts.keyShape : null;
232
+ var keyShapeLabel = operatorKeyShape ? String(operatorKeyShape) : DEFAULT_KEY_SHAPE;
233
+ function keyShapeAccepts(key) {
234
+ return operatorKeyShape ? operatorKeyShape.test(key) : _matchesDefaultKeyShape(key);
235
+ }
124
236
 
125
237
  input = safeBuffer.normalizeText(input, {
126
238
  maxBytes: maxBytes,
@@ -129,7 +241,9 @@ function parse(input, opts) {
129
241
  sizeCode: "env/too-large",
130
242
  });
131
243
 
132
- var rawLines = input.split(/\r\n|\r|\n/);
244
+ // splitLinesAny breaks on LF, CR and CRLF-as-one — the same three the
245
+ // `/\r\n|\r|\n/` alternation covered, in the same order of preference.
246
+ var rawLines = codepointClass.splitLinesAny(input);
133
247
  var values = Object.create(null);
134
248
  var seen = new Set();
135
249
 
@@ -137,14 +251,12 @@ function parse(input, opts) {
137
251
  var line = rawLines[i];
138
252
  var lineNumber = i + 1;
139
253
  // Trim leading whitespace (operators sometimes indent for readability)
140
- var trimmed = line.replace(/^[ \t]+/, "");
254
+ var trimmed = codepointClass.trimChars(line, " \t", { trailing: false });
141
255
  if (trimmed.length === 0) continue;
142
256
  if (trimmed.charAt(0) === "#") continue;
143
257
 
144
258
  // Optional `export ` prefix (POSIX shell convention)
145
- if (/^export\s+/.test(trimmed)) {
146
- trimmed = trimmed.replace(/^export\s+/, "");
147
- }
259
+ trimmed = _stripExportPrefix(trimmed);
148
260
 
149
261
  var eqIdx = trimmed.indexOf("=");
150
262
  if (eqIdx < 0) {
@@ -159,9 +271,9 @@ function parse(input, opts) {
159
271
  if (pick.isPoisonedKey(key)) {
160
272
  throw new SafeEnvError("forbidden key '" + key + "'", "env/poisoned-key", lineNumber);
161
273
  }
162
- if (!keyShape.test(key)) {
274
+ if (!keyShapeAccepts(key)) {
163
275
  throw new SafeEnvError(
164
- "key '" + key + "' does not match keyShape " + keyShape,
276
+ "key '" + key + "' does not match keyShape " + keyShapeLabel,
165
277
  "env/bad-key-shape", lineNumber
166
278
  );
167
279
  }
@@ -188,18 +300,14 @@ function parse(input, opts) {
188
300
  // Unquoted value: strip trailing whitespace + inline `# comment`.
189
301
  // The comment marker MUST be preceded by whitespace to count
190
302
  // (so a value like `KEY=color#red` keeps the literal `#`).
191
- var commentMatch = rest.match(/^([^\s#]*(?:[ \t]+[^#\s]+)*)\s+#.*$/);
303
+ var beforeComment = _splitInlineComment(rest);
192
304
  // stripTrailingHspace is a linear char-scan; .replace(/[ \t]+$/) is O(n^2)
193
305
  // in V8 and the env parser only caps TOTAL bytes, not per-line, so a
194
306
  // single huge-whitespace value line would otherwise hang the parser.
195
- if (commentMatch) {
196
- value = safeBuffer.stripTrailingHspace(commentMatch[1]);
197
- } else {
198
- value = safeBuffer.stripTrailingHspace(rest);
199
- }
307
+ value = safeBuffer.stripTrailingHspace(beforeComment === null ? rest : beforeComment);
200
308
  // Reject `$VAR` style references — explicit error so operators
201
309
  // see the policy rather than silently getting unexpanded text.
202
- if (/\$(\{[A-Za-z_]|[A-Za-z_])/.test(value)) {
310
+ if (_hasVariableReference(value)) {
203
311
  throw new SafeEnvError(
204
312
  "$VAR / ${VAR} expansion not supported (escape with \\$ if literal, or quote and expand yourself)",
205
313
  "env/expansion-banned", lineNumber
@@ -254,7 +362,8 @@ function _decodeDoubleQuoted(rest, lineNumber) {
254
362
  // Optional inline comment after closing quote — ignore.
255
363
  return out;
256
364
  }
257
- if (ch === "$" && /^[{A-Za-z_]/.test(rest.charAt(i + 1) || "")) {
365
+ var afterDollar = rest.charAt(i + 1);
366
+ if (ch === "$" && (afterDollar === "{" || _isIdentifierHead(afterDollar))) {
258
367
  throw new SafeEnvError(
259
368
  "$VAR / ${VAR} expansion not supported in double-quoted value (use \\$ for literal $)",
260
369
  "env/expansion-banned", lineNumber
@@ -41,6 +41,7 @@ var C = require("../constants");
41
41
  var pick = require("../pick");
42
42
  var numericBounds = require("../numeric-bounds");
43
43
  var safeBuffer = require("../safe-buffer");
44
+ var codepointClass = require("../codepoint-class");
44
45
  var { defineClass } = require("../framework-error");
45
46
 
46
47
  var IniSafeError = defineClass("IniSafeError", { alwaysPermanent: true });
@@ -69,7 +70,7 @@ function _stripComment(line) {
69
70
  if (c === "\"" && !inSingle) { inDouble = !inDouble; continue; }
70
71
  if (c === "'" && !inDouble) { inSingle = !inSingle; continue; }
71
72
  if (!inSingle && !inDouble && (c === ";" || c === "#")) {
72
- if (i === 0 || /\s/.test(line.charAt(i - 1))) {
73
+ if (i === 0 || codepointClass.inRanges(line.charCodeAt(i - 1), codepointClass.WHITESPACE_RANGES)) {
73
74
  return line.slice(0, i);
74
75
  }
75
76
  }
@@ -110,6 +111,113 @@ function _unquote(raw) {
110
111
  return s;
111
112
  }
112
113
 
114
+ // Lexical shape tests for the value forms INI coerces. Each replaces an
115
+ // anchored pattern and walks the string once, so the cost is its length —
116
+ // values arrive from a file an operator may not control, and a screen whose
117
+ // cost depends on the arrangement of the characters is what this family avoids.
118
+ //
119
+ // `\d` in the patterns these replace is ASCII-only, so isAsciiDigit matches it
120
+ // exactly; a walk using a Unicode digit test would accept more than the pattern
121
+ // did and silently widen what parses as a number.
122
+
123
+ // `/^0x[0-9a-f]+$/i` — the prefix, then at least one hex digit, nothing else.
124
+ function _isHexInteger(s) {
125
+ if (s.length < 3) return false;
126
+ if (s.charAt(0) !== "0") return false;
127
+ var x = s.charAt(1);
128
+ if (x !== "x" && x !== "X") return false;
129
+ return codepointClass.isRunOf(s.slice(2), codepointClass.ASCII_HEX);
130
+ }
131
+
132
+ // Digits from `from` to the end, at least one. The shared tail of every shape
133
+ // below, and the reason none of them needs a quantifier.
134
+ function _digitsToEnd(s, from) {
135
+ if (from >= s.length) return false;
136
+ return codepointClass.isRunOf(s.slice(from), codepointClass.ASCII_DIGITS);
137
+ }
138
+
139
+ function _digitRunEnd(s, from) {
140
+ var i = from;
141
+ while (i < s.length && codepointClass.isAsciiDigit(s.charCodeAt(i))) i += 1;
142
+ return i; // === from when no digits
143
+ }
144
+
145
+ // `/^-?\d+$/`
146
+ function _isDecimalInteger(s) {
147
+ return _digitsToEnd(s, s.charAt(0) === "-" ? 1 : 0);
148
+ }
149
+
150
+ // `/^-?\d+\.\d+([eE][+-]?\d+)?$/` or `/^-?\d+[eE][+-]?\d+$/` — a float needs
151
+ // either a fractional part, an exponent, or both, which is what keeps a bare
152
+ // integer out of this branch and in the one above it.
153
+ function _isDecimalFloat(s) {
154
+ var i = s.charAt(0) === "-" ? 1 : 0;
155
+ var afterInt = _digitRunEnd(s, i);
156
+ if (afterInt === i) return false; // no integer part
157
+ i = afterInt;
158
+ var sawFraction = false;
159
+ if (s.charAt(i) === ".") {
160
+ var afterFrac = _digitRunEnd(s, i + 1);
161
+ if (afterFrac === i + 1) return false; // `1.` is not a float here
162
+ i = afterFrac;
163
+ sawFraction = true;
164
+ }
165
+ var e = s.charAt(i);
166
+ if (e === "e" || e === "E") {
167
+ var j = i + 1;
168
+ var sign = s.charAt(j);
169
+ if (sign === "+" || sign === "-") j += 1;
170
+ if (!_digitsToEnd(s, j)) return false;
171
+ return true; // exponent consumed the rest
172
+ }
173
+ return sawFraction && i === s.length;
174
+ }
175
+
176
+ // `[name "subsection"]` — the git-config form. Replaces
177
+ // `/^([A-Za-z0-9._-]+)\s+"([^"\\]*(?:\\.[^"\\]*)*)"$/`, whose inner group is the
178
+ // unrolled-loop idiom for a quoted string; walking it is both clearer and free
179
+ // of the backtracking that idiom exists to avoid.
180
+ //
181
+ // Two details of the pattern are load-bearing and preserved here. The value is
182
+ // captured VERBATIM, escapes included — `_unquote` does the unescaping later, so
183
+ // consuming the backslash here would double-unescape. And `\\.` cannot match a
184
+ // line terminator, so a backslash before one is not an escape and the whole
185
+ // header fails to match rather than swallowing the break.
186
+ var _SECTION_NAME_CHARS = codepointClass.ASCII_ALNUM + "._-";
187
+
188
+ function _isDotAtom(ch) {
189
+ // The characters `.` does NOT match without the `s` flag.
190
+ return ch !== "\n" && ch !== "\r" && ch !== "\u2028" && ch !== "\u2029";
191
+ }
192
+
193
+ function _parseQuotedSectionHeader(inner) {
194
+ var i = 0;
195
+ while (i < inner.length && _SECTION_NAME_CHARS.indexOf(inner.charAt(i)) !== -1) i += 1;
196
+ if (i === 0) return null; // name needs one char
197
+ var name = inner.slice(0, i);
198
+ var afterName = i;
199
+ while (i < inner.length &&
200
+ codepointClass.inRanges(inner.charCodeAt(i), codepointClass.WHITESPACE_RANGES)) i += 1;
201
+ if (i === afterName) return null; // `\s+` needs one
202
+ if (inner.charAt(i) !== "\"") return null;
203
+ i += 1;
204
+ var value = "";
205
+ while (i < inner.length) {
206
+ var ch = inner.charAt(i);
207
+ if (ch === "\\") {
208
+ var next = inner.charAt(i + 1);
209
+ if (i + 1 >= inner.length || !_isDotAtom(next)) return null;
210
+ value += ch + next;
211
+ i += 2;
212
+ continue;
213
+ }
214
+ if (ch === "\"") return i === inner.length - 1 ? [name, value] : null;
215
+ value += ch;
216
+ i += 1;
217
+ }
218
+ return null; // unterminated quote
219
+ }
220
+
113
221
  function _coerceValue(raw) {
114
222
  if (raw.length === 0) return raw;
115
223
  var first = raw.charAt(0);
@@ -117,21 +225,21 @@ function _coerceValue(raw) {
117
225
  var lower = raw.toLowerCase();
118
226
  if (TRUE_VALUES.has(lower)) return true;
119
227
  if (FALSE_VALUES.has(lower)) return false;
120
- if (/^0x[0-9a-f]+$/i.test(raw)) {
228
+ if (_isHexInteger(raw)) {
121
229
  var hex = parseInt(raw, RADIX_HEX);
122
230
  if (!Number.isSafeInteger(hex)) {
123
231
  throw _err("ini/value-out-of-range", "hex integer exceeds safe-integer range: " + raw);
124
232
  }
125
233
  return hex;
126
234
  }
127
- if (/^-?\d+$/.test(raw)) {
235
+ if (_isDecimalInteger(raw)) {
128
236
  var n = Number(raw);
129
237
  if (!Number.isSafeInteger(n)) {
130
238
  throw _err("ini/value-out-of-range", "integer exceeds safe-integer range: " + raw);
131
239
  }
132
240
  return n;
133
241
  }
134
- if (/^-?\d+\.\d+([eE][+-]?\d+)?$/.test(raw) || /^-?\d+[eE][+-]?\d+$/.test(raw)) {
242
+ if (_isDecimalFloat(raw)) {
135
243
  var f = Number(raw);
136
244
  // Float overflow (e.g. 1e999) coerces to ±Infinity — the same
137
245
  // never-silently-coerce refusal the integer/hex branches enforce with
@@ -178,10 +286,8 @@ function _parseSectionHeader(line) {
178
286
  if (inner.length === 0) {
179
287
  throw _err("ini/empty-section", "section header [] has no name");
180
288
  }
181
- var quotedMatch = /^([A-Za-z0-9._-]+)\s+"([^"\\]*(?:\\.[^"\\]*)*)"$/.exec(inner);
182
- if (quotedMatch) {
183
- return [quotedMatch[1], quotedMatch[2]];
184
- }
289
+ var quoted = _parseQuotedSectionHeader(inner);
290
+ if (quoted) return quoted;
185
291
  var parts = inner.split(".");
186
292
  for (var i = 0; i < parts.length; i++) {
187
293
  if (parts[i].length === 0) {
@@ -240,7 +346,7 @@ function parse(input, opts) {
240
346
  var sectionCount = 0;
241
347
  var keysInCurrentSection = 0;
242
348
 
243
- var lines = input.split(/\r?\n/);
349
+ var lines = codepointClass.splitLines(input);
244
350
  for (var li = 0; li < lines.length; li++) {
245
351
  var raw = lines[li];
246
352
  var stripped = _stripComment(raw).trim();
@@ -71,6 +71,61 @@ var RADIX_BIN = 0x2;
71
71
  var RADIX_OCTAL = 0x8;
72
72
  var RADIX_HEX = 0x10;
73
73
 
74
+ // Character sets and fixed-width shapes, as walks. TOML's grammar is all
75
+ // fixed-width date and time fields and single-character classes, so every one of
76
+ // these replaces a pattern with an index comparison — nothing here needs a
77
+ // search, and the parser's cost stays the length of the document.
78
+ var _OCTAL_DIGITS = "01234567";
79
+ var _BINARY_DIGITS = "01";
80
+ var _BARE_KEY_TAIL = codepointClass.ASCII_ALNUM + "_";
81
+ var _NUMBER_LEADS = codepointClass.ASCII_DIGITS + "+-i n"; // `inf`, `nan`, sign, space
82
+
83
+ function _isDigit(ch) { return ch.length === 1 && codepointClass.ASCII_DIGITS.indexOf(ch) !== -1; }
84
+ function _isHexDigit(ch) { return ch.length === 1 && codepointClass.ASCII_HEX.indexOf(ch) !== -1; }
85
+ function _isOctalDigit(ch) { return ch.length === 1 && _OCTAL_DIGITS.indexOf(ch) !== -1; }
86
+ function _isBinaryDigit(ch){ return ch.length === 1 && _BINARY_DIGITS.indexOf(ch) !== -1; }
87
+
88
+ // `charAt` past the end returns "", and `"".indexOf` is 0 on every string, so
89
+ // each of these guards its length before asking. Without it an out-of-range read
90
+ // answers "yes" and the shape checks accept a truncated document.
91
+ function _isBareKeyChar(ch) { return ch.length === 1 && _BARE_KEY_TAIL.indexOf(ch) !== -1; }
92
+ function _isNumberLead(ch) { return ch.length === 1 && _NUMBER_LEADS.indexOf(ch) !== -1; }
93
+
94
+ function _digitsAt(text, from, count) {
95
+ if (from + count > text.length) return false;
96
+ for (var i = 0; i < count; i += 1) {
97
+ if (!_isDigit(text.charAt(from + i))) return false;
98
+ }
99
+ return true;
100
+ }
101
+
102
+ // `\d{4}-\d{2}-\d{2}` anchored at the start of `text`.
103
+ function _looksLikeDate(text) {
104
+ return _digitsAt(text, 0, 4) && text.charAt(4) === "-" &&
105
+ _digitsAt(text, 5, 2) && text.charAt(7) === "-" &&
106
+ _digitsAt(text, 8, 2);
107
+ }
108
+
109
+ // `\d{2}:\d{2}:\d{2}` anchored at the start of `text`.
110
+ function _looksLikeTime(text) {
111
+ return _digitsAt(text, 0, 2) && text.charAt(2) === ":" &&
112
+ _digitsAt(text, 3, 2) && text.charAt(5) === ":" &&
113
+ _digitsAt(text, 6, 2);
114
+ }
115
+
116
+ // `^[+-]\d{2}:\d{2}$` — a whole numeric UTC offset, nothing after it.
117
+ function _isNumericOffset(text) {
118
+ if (text.length !== 6) return false;
119
+ var sign = text.charAt(0);
120
+ if (sign !== "+" && sign !== "-") return false;
121
+ return _digitsAt(text, 1, 2) && text.charAt(3) === ":" && _digitsAt(text, 4, 2);
122
+ }
123
+
124
+ // Strip the `_` digit separators TOML allows inside numbers.
125
+ function _stripUnderscores(text) {
126
+ return text.indexOf("_") === -1 ? text : codepointClass.stripRanges(text, [0x5F]);
127
+ }
128
+
74
129
  // Date-time literal character widths (per TOML / RFC 3339).
75
130
  var TIME_CHARS = 0x8; // "HH:MM:SS"
76
131
  var OFFSET_CHARS = 0x6; // "+HH:MM"
@@ -285,7 +340,7 @@ function parse(input, opts) {
285
340
  case "U": {
286
341
  var hexLen = 0x8;
287
342
  var hex8 = input.substring(pos, pos + hexLen);
288
- if (hex8.length < hexLen || !/^[0-9a-fA-F]{8}$/.test(hex8)) {
343
+ if (hex8.length < hexLen || hex8.length !== 8 || !codepointClass.isRunOf(hex8, codepointClass.ASCII_HEX)) {
289
344
  throw _err("bad \\U escape", "toml/bad-escape");
290
345
  }
291
346
  _advance(hexLen);
@@ -404,19 +459,19 @@ function parse(input, opts) {
404
459
  // current position doesn't start a date-time literal.
405
460
  function _tryParseDateTime() {
406
461
  // Date-time form: YYYY-MM-DD followed by 'T'/' ' followed by time
407
- if (pos + 10 <= len && /^\d{4}-\d{2}-\d{2}/.test(input.substr(pos, 10))) {
462
+ if (pos + 10 <= len && _looksLikeDate(input.substr(pos, 10))) {
408
463
  var sep = _peek(10);
409
464
  if (sep === "T" || sep === "t" || sep === " ") {
410
465
  // Look ahead for time portion (HH:MM:SS = 8 chars)
411
466
  var timeStart = pos + 11;
412
467
  var timeChars = TIME_CHARS;
413
- if (/^\d{2}:\d{2}:\d{2}/.test(input.substr(timeStart, timeChars))) {
468
+ if (_looksLikeTime(input.substr(timeStart, timeChars))) {
414
469
  var datePart = input.substr(pos, 10);
415
470
  var timeEnd = timeStart + timeChars;
416
471
  // Optional fractional
417
472
  if (_peek(timeEnd - pos) === ".") {
418
473
  timeEnd += 1;
419
- while (timeEnd < len && /\d/.test(input.charAt(timeEnd))) timeEnd += 1;
474
+ while (timeEnd < len && _isDigit(input.charAt(timeEnd))) timeEnd += 1;
420
475
  }
421
476
  var timePart = input.substring(timeStart, timeEnd);
422
477
  // Optional offset
@@ -427,7 +482,7 @@ function parse(input, opts) {
427
482
  // Expect ±HH:MM (6 chars)
428
483
  var offsetChars = OFFSET_CHARS;
429
484
  var off = input.substr(timeEnd, offsetChars);
430
- if (/^[+-]\d{2}:\d{2}$/.test(off)) {
485
+ if (_isNumericOffset(off)) {
431
486
  offsetStr = off;
432
487
  timeEnd += offsetChars;
433
488
  }
@@ -445,7 +500,7 @@ function parse(input, opts) {
445
500
  }
446
501
  // Date-only — but only if followed by something OTHER than digit/colon
447
502
  var after = _peek(10);
448
- if (!after || !/[0-9:.]/.test(after)) {
503
+ if (!after || !(_isDigit(after) || after === ":" || after === ".")) {
449
504
  var ds = input.substr(pos, 10);
450
505
  _advance(10);
451
506
  return { kind: "local-date", value: ds };
@@ -453,11 +508,11 @@ function parse(input, opts) {
453
508
  }
454
509
  // Time-only: HH:MM:SS (8 chars)
455
510
  var timeOnlyChars = TIME_CHARS;
456
- if (pos + timeOnlyChars <= len && /^\d{2}:\d{2}:\d{2}/.test(input.substr(pos, timeOnlyChars))) {
511
+ if (pos + timeOnlyChars <= len && _looksLikeTime(input.substr(pos, timeOnlyChars))) {
457
512
  var teEnd = pos + timeOnlyChars;
458
513
  if (input.charAt(teEnd) === ".") {
459
514
  teEnd += 1;
460
- while (teEnd < len && /\d/.test(input.charAt(teEnd))) teEnd += 1;
515
+ while (teEnd < len && _isDigit(input.charAt(teEnd))) teEnd += 1;
461
516
  }
462
517
  var ts = input.substring(pos, teEnd);
463
518
  _advance(teEnd - pos);
@@ -496,12 +551,12 @@ function parse(input, opts) {
496
551
  while (!_eof()) {
497
552
  var ch = _peek();
498
553
  if (ch === "_") { _advance(); continue; }
499
- if (radix === RADIX_HEX && /[0-9a-fA-F]/.test(ch)) { _advance(); continue; }
500
- if (radix === RADIX_OCTAL && /[0-7]/.test(ch)) { _advance(); continue; }
501
- if (radix === RADIX_BIN && /[01]/.test(ch)) { _advance(); continue; }
554
+ if (radix === RADIX_HEX && _isHexDigit(ch)) { _advance(); continue; }
555
+ if (radix === RADIX_OCTAL && _isOctalDigit(ch)) { _advance(); continue; }
556
+ if (radix === RADIX_BIN && _isBinaryDigit(ch)) { _advance(); continue; }
502
557
  break;
503
558
  }
504
- var digits = input.substring(digitsStart, pos).replace(/_/g, "");
559
+ var digits = _stripUnderscores(input.substring(digitsStart, pos));
505
560
  if (digits.length === 0) throw _err("expected digits after radix prefix", "toml/bad-number");
506
561
  var n = parseInt(digits, radix);
507
562
  if (!Number.isSafeInteger(n)) {
@@ -527,7 +582,7 @@ function parse(input, opts) {
527
582
  }
528
583
  break;
529
584
  }
530
- var raw = input.substring(startPos, pos).replace(/_/g, "");
585
+ var raw = _stripUnderscores(input.substring(startPos, pos));
531
586
  if (raw === "" || raw === "-" || raw === "+") {
532
587
  throw new SafeTomlError("invalid number", "toml/bad-number", startLine, startCol);
533
588
  }
@@ -560,17 +615,17 @@ function parse(input, opts) {
560
615
  if (c === "[") return _parseArray(depth + 1);
561
616
  if (c === "{") return _parseInlineTable(depth + 1);
562
617
 
563
- if (input.substr(pos, 4) === "true" && !/[A-Za-z0-9_]/.test(input.charAt(pos + 4) || "")) {
618
+ if (input.substr(pos, 4) === "true" && !_isBareKeyChar(input.charAt(pos + 4))) {
564
619
  _advance(4); return true;
565
620
  }
566
- if (input.substr(pos, 5) === "false" && !/[A-Za-z0-9_]/.test(input.charAt(pos + 5) || "")) {
621
+ if (input.substr(pos, 5) === "false" && !_isBareKeyChar(input.charAt(pos + 5))) {
567
622
  _advance(5); return false;
568
623
  }
569
624
 
570
625
  var dt = _tryParseDateTime();
571
626
  if (dt !== null) return dt.value;
572
627
 
573
- if (/[0-9+\-i n]/.test(c)) return _parseNumber(c);
628
+ if (_isNumberLead(c)) return _parseNumber(c);
574
629
 
575
630
  throw _err("unexpected character '" + c + "'", "toml/expected-value");
576
631
  }
@@ -53,6 +53,7 @@ var C = require("../constants");
53
53
  var pick = require("../pick");
54
54
  var numericBounds = require("../numeric-bounds");
55
55
  var safeBuffer = require("../safe-buffer");
56
+ var codepointClass = require("../codepoint-class");
56
57
  var { FrameworkError } = require("../framework-error");
57
58
 
58
59
  class SafeXmlError extends FrameworkError {
@@ -405,7 +406,12 @@ function parse(input, opts) {
405
406
  }
406
407
  }
407
408
  Object.assign(obj, grouped);
408
- var combinedText = textParts.join("").replace(/\s+/g, " ").trim();
409
+ // Collapse whitespace runs to a single space and trim, by walking rather
410
+ // than matching: the text came off the wire and a screen whose cost depends
411
+ // on the shape of the input is the thing this family does not do.
412
+ // splitOnWhitespace drops empty segments, so joining restores exactly the
413
+ // collapsed-and-trimmed form.
414
+ var combinedText = codepointClass.splitOnWhitespace(textParts.join("")).join(" ");
409
415
  if (combinedText.length > 0) obj["#text"] = combinedText;
410
416
  return _make(name, obj);
411
417
  }
@@ -59,6 +59,7 @@ var pick = require("../pick");
59
59
  var boundedMap = require("../bounded-map");
60
60
  var numericBounds = require("../numeric-bounds");
61
61
  var safeBuffer = require("../safe-buffer");
62
+ var codepointClass = require("../codepoint-class");
62
63
  var { FrameworkError } = require("../framework-error");
63
64
 
64
65
  class SafeYamlError extends FrameworkError {
@@ -93,14 +94,187 @@ var DEFAULTS = {
93
94
  // YAML 1.2 core-schema scalar resolution. Order matters: null first
94
95
  // (covers ~ and empty), then bool, then int (with base prefixes), then
95
96
  // float, then string fallback.
96
- var NULL_RE = /^(null|Null|NULL|~|)$/;
97
- var BOOL_RE = /^(true|True|TRUE|false|False|FALSE)$/;
98
- var INT_RE = /^[-+]?(0|[1-9][0-9]*)$/;
99
- var INT_OCT = /^0o[0-7]+$/;
100
- var INT_HEX = /^0x[0-9a-fA-F]+$/;
101
- var FLOAT_RE = /^[-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?$/;
102
- var FLOAT_INF = /^[-+]?\.(inf|Inf|INF)$/;
103
- var FLOAT_NAN = /^\.(nan|NaN|NAN)$/;
97
+ // The resolvers are exact-set membership or a left-to-right walk. YAML's core
98
+ // schema spells each type out, so none of these needs to search: the anchored
99
+ // alternations were only ever a compact way of writing a fixed vocabulary and a
100
+ // digit shape.
101
+ var NULL_TOKENS = { "": 1, "null": 1, "Null": 1, "NULL": 1, "~": 1 };
102
+ var BOOL_TRUE = { "true": 1, "True": 1, "TRUE": 1 };
103
+ var BOOL_FALSE = { "false": 1, "False": 1, "FALSE": 1 };
104
+ var INF_TOKENS = { ".inf": 1, ".Inf": 1, ".INF": 1 };
105
+ var NAN_TOKENS = { ".nan": 1, ".NaN": 1, ".NAN": 1 };
106
+ var OCTAL_DIGITS = "01234567";
107
+
108
+ function _isNull(s) { return Object.prototype.hasOwnProperty.call(NULL_TOKENS, s); }
109
+ function _isBool(s) {
110
+ return Object.prototype.hasOwnProperty.call(BOOL_TRUE, s) ||
111
+ Object.prototype.hasOwnProperty.call(BOOL_FALSE, s);
112
+ }
113
+
114
+ function _signOffset(s) { var c = s.charAt(0); return (c === "-" || c === "+") ? 1 : 0; }
115
+
116
+ // `^[-+]?(0|[1-9][0-9]*)$` — no leading zeros, because `010` is a string in the
117
+ // core schema rather than an octal.
118
+ function _isDecimalInt(s) {
119
+ var i = _signOffset(s);
120
+ var rest = s.slice(i);
121
+ if (rest.length === 0) return false;
122
+ if (rest === "0") return true;
123
+ if (rest.charAt(0) === "0") return false;
124
+ return codepointClass.isRunOf(rest, codepointClass.ASCII_DIGITS);
125
+ }
126
+
127
+ function _isPrefixedInt(s, marker, alphabet) {
128
+ if (s.length < 3) return false;
129
+ if (s.charAt(0) !== "0" || s.charAt(1) !== marker) return false;
130
+ return codepointClass.isRunOf(s.slice(2), alphabet);
131
+ }
132
+ function _isOctalInt(s) { return _isPrefixedInt(s, "o", OCTAL_DIGITS); }
133
+ function _isHexInt(s) { return _isPrefixedInt(s, "x", codepointClass.ASCII_HEX); }
134
+
135
+ // `^[-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?$` — either a leading dot
136
+ // with digits after it, or digits with an optional dot and optional digits, then
137
+ // an optional exponent that must carry at least one digit.
138
+ function _isFloat(s) {
139
+ var i = _signOffset(s);
140
+ var n = s.length;
141
+ // Both branches below return early when they find no digit, so reaching the
142
+ // exponent means at least one was consumed — no separate flag needed.
143
+ if (s.charAt(i) === ".") {
144
+ i += 1;
145
+ var fracStart = i;
146
+ while (i < n && codepointClass.ASCII_DIGITS.indexOf(s.charAt(i)) !== -1) i += 1;
147
+ if (i === fracStart) return false; // `.` alone is not a float
148
+ } else {
149
+ var intStart = i;
150
+ while (i < n && codepointClass.ASCII_DIGITS.indexOf(s.charAt(i)) !== -1) i += 1;
151
+ if (i === intStart) return false;
152
+ if (s.charAt(i) === ".") {
153
+ i += 1;
154
+ while (i < n && codepointClass.ASCII_DIGITS.indexOf(s.charAt(i)) !== -1) i += 1;
155
+ }
156
+ }
157
+ if (i === n) return true;
158
+ var e = s.charAt(i);
159
+ if (e !== "e" && e !== "E") return false;
160
+ i += 1;
161
+ var expSign = s.charAt(i);
162
+ if (expSign === "+" || expSign === "-") i += 1;
163
+ if (i >= n) return false;
164
+ return codepointClass.isRunOf(s.slice(i), codepointClass.ASCII_DIGITS);
165
+ }
166
+
167
+ function _isInfinity(s) {
168
+ return Object.prototype.hasOwnProperty.call(INF_TOKENS, s.slice(_signOffset(s)));
169
+ }
170
+ function _isNotANumber(s) {
171
+ return Object.prototype.hasOwnProperty.call(NAN_TOKENS, s);
172
+ }
173
+
174
+ function _isWhitespaceChar(ch) {
175
+ return ch.length === 1 && codepointClass.inRanges(ch.charCodeAt(0), codepointClass.WHITESPACE_RANGES);
176
+ }
177
+
178
+ // The four characters `.` does not match without the `s` flag.
179
+ function _isLineTerminator(ch) {
180
+ return ch === "\n" || ch === "\r" || ch === "\u2028" || ch === "\u2029";
181
+ }
182
+
183
+ // `^---(\s|$)` / `^\.\.\.(\s|$)` — a document marker is the three characters and
184
+ // then either the end of the line or a space, so `---foo` is not one.
185
+ function _isDocumentMarker(line, marker) {
186
+ if (line.slice(0, marker.length) !== marker) return false;
187
+ if (line.length === marker.length) return true;
188
+ return _isWhitespaceChar(line.charAt(marker.length));
189
+ }
190
+
191
+ // `^[|>][-+]?[0-9]?\s*$` and its variant allowing a trailing `# comment`.
192
+ // A block-scalar header: the indicator, an optional chomping sign, an optional
193
+ // single explicit-indent digit, then nothing that matters.
194
+ function _isBlockScalarHeader(content, allowComment) {
195
+ var i = 0;
196
+ var lead = content.charAt(0);
197
+ if (lead !== "|" && lead !== ">") return false;
198
+ i += 1;
199
+ var sign = content.charAt(i);
200
+ if (sign === "-" || sign === "+") i += 1;
201
+ var digit = content.charAt(i);
202
+ if (digit.length === 1 && codepointClass.ASCII_DIGITS.indexOf(digit) !== -1) i += 1;
203
+ while (i < content.length && _isWhitespaceChar(content.charAt(i))) i += 1;
204
+ if (i === content.length) return true;
205
+ if (!allowComment) return false;
206
+ if (content.charAt(i) !== "#") return false;
207
+ // `.*` stops at a line terminator, and `$` without `m` is end-of-string, so a
208
+ // comment may not contain one. ECMAScript counts four, not just LF: U+2028 and
209
+ // U+2029 are line terminators to the grammar even though nothing else here
210
+ // treats them as breaks, and accepting them would admit a header the pattern
211
+ // refused.
212
+ var tail = content.slice(i);
213
+ for (var t = 0; t < tail.length; t += 1) {
214
+ if (_isLineTerminator(tail.charAt(t))) return false;
215
+ }
216
+ return true;
217
+ }
218
+
219
+ // `(^|\s)([&*])([A-Za-z0-9_][A-Za-z0-9_-]*)` — an anchor or alias sigil that
220
+ // opens a token. Returns the index of the match INCLUDING the leading separator,
221
+ // matching what the pattern captured, so the reported line is unchanged.
222
+ var _NAME_HEAD = codepointClass.ASCII_ALNUM + "_";
223
+ var _NAME_TAIL = codepointClass.ASCII_ALNUM + "_-";
224
+
225
+ function _findAnchorOrAlias(text) {
226
+ for (var i = 0; i < text.length; i += 1) {
227
+ var ch = text.charAt(i);
228
+ if (ch !== "&" && ch !== "*") continue;
229
+ var atStart = i === 0;
230
+ if (!atStart && !_isWhitespaceChar(text.charAt(i - 1))) continue;
231
+ if (_NAME_HEAD.indexOf(text.charAt(i + 1)) === -1 || text.charAt(i + 1) === "") continue;
232
+ return { index: atStart ? i : i - 1, sigil: ch };
233
+ }
234
+ return null;
235
+ }
236
+
237
+ // `(^|[\s-])(!{1,2}[A-Za-z<])` — one or two `!` opening a tag.
238
+ function _findTag(text) {
239
+ for (var i = 0; i < text.length; i += 1) {
240
+ if (text.charAt(i) !== "!") continue;
241
+ var atStart = i === 0;
242
+ var before = text.charAt(i - 1);
243
+ if (!atStart && !(before === "-" || _isWhitespaceChar(before))) continue;
244
+ // The pattern is greedy, so it takes two `!` when both are present.
245
+ var after = text.charAt(i + 1) === "!" ? text.charAt(i + 2) : text.charAt(i + 1);
246
+ if (after.length !== 1) continue;
247
+ // Digits count. The pattern this replaces used `[A-Za-z<]` while the comment
248
+ // above it said "alphanumeric or `<`", and the code was the weaker of the
249
+ // two: `a: !123` is a local tag and parsed as the plain string "!123"
250
+ // instead of being refused, so the documented ban had a hole in it. This is
251
+ // not a regression from the rewrite — `main` behaves the same way — but the
252
+ // promise is that tags are refused, so the stricter reading wins.
253
+ if (after !== "<" && codepointClass.ASCII_ALNUM.indexOf(after) === -1) continue;
254
+ return { index: atStart ? i : i - 1 };
255
+ }
256
+ return null;
257
+ }
258
+
259
+ // `(^|\n)%(YAML|TAG)\b` — a directive at column zero.
260
+ var _DIRECTIVE_NAMES = ["YAML", "TAG"];
261
+
262
+ function _findDirective(text) {
263
+ for (var i = 0; i < text.length; i += 1) {
264
+ if (text.charAt(i) !== "%") continue;
265
+ var atStart = i === 0;
266
+ if (!atStart && text.charAt(i - 1) !== "\n") continue;
267
+ for (var d = 0; d < _DIRECTIVE_NAMES.length; d += 1) {
268
+ var name = _DIRECTIVE_NAMES[d];
269
+ if (text.slice(i + 1, i + 1 + name.length) !== name) continue;
270
+ // `\b` — the character after the name must not continue a word.
271
+ var next = text.charAt(i + 1 + name.length);
272
+ if (next.length === 1 && (_NAME_HEAD.indexOf(next) !== -1)) continue;
273
+ return { index: atStart ? i : i - 1, precededByNewline: !atStart };
274
+ }
275
+ }
276
+ return null;
277
+ }
104
278
 
105
279
  function _resolveScalar(s) {
106
280
  // Fall back to string for any token whose length exceeds the scalar cap
@@ -109,31 +283,31 @@ function _resolveScalar(s) {
109
283
  // type-inference regexes never see a pathologically long string.
110
284
  if (typeof s !== "string" || s.length > MAX_SCALAR_BYTES) return s;
111
285
  // Below: every regex test sees an `s` whose s.length <= MAX_SCALAR_BYTES.
112
- if (NULL_RE.test(s)) return null;
113
- if (BOOL_RE.test(s)) return s.toLowerCase() === "true";
286
+ if (_isNull(s)) return null;
287
+ if (_isBool(s)) return s.toLowerCase() === "true";
114
288
  // s.length <= MAX_SCALAR_BYTES asserted at function entry above.
115
- if (INT_RE.test(s)) {
289
+ if (_isDecimalInt(s)) {
116
290
  var n = parseInt(s, 10);
117
291
  if (Number.isSafeInteger(n)) return n;
118
292
  return s; // fallback to string for huge ints (don't lose precision silently)
119
293
  }
120
294
  // s.length <= MAX_SCALAR_BYTES asserted at function entry above.
121
- if (INT_OCT.test(s)) {
295
+ if (_isOctalInt(s)) {
122
296
  var oct = parseInt(s.substring(2), RADIX_OCTAL);
123
297
  if (Number.isSafeInteger(oct)) return oct;
124
298
  return s;
125
299
  }
126
300
  // s.length <= MAX_SCALAR_BYTES asserted at function entry above.
127
- if (INT_HEX.test(s)) {
301
+ if (_isHexInt(s)) {
128
302
  var hex = parseInt(s.substring(2), RADIX_HEX);
129
303
  if (Number.isSafeInteger(hex)) return hex;
130
304
  return s;
131
305
  }
132
306
  // s.length <= MAX_SCALAR_BYTES asserted at function entry above.
133
- if (FLOAT_INF.test(s)) return s.charAt(0) === "-" ? -Infinity : Infinity;
134
- if (FLOAT_NAN.test(s)) return NaN;
307
+ if (_isInfinity(s)) return s.charAt(0) === "-" ? -Infinity : Infinity;
308
+ if (_isNotANumber(s)) return NaN;
135
309
  // s.length <= MAX_SCALAR_BYTES asserted at function entry above.
136
- if (FLOAT_RE.test(s)) {
310
+ if (_isFloat(s)) {
137
311
  var f = parseFloat(s);
138
312
  if (!isNaN(f)) return f;
139
313
  }
@@ -179,7 +353,9 @@ function parse(input, opts) {
179
353
  _preValidate(input);
180
354
 
181
355
  // Normalize line endings: CRLF / CR → LF for consistent line-based work.
182
- input = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
356
+ // Normalise CRLF and lone CR to LF. splitLinesAny treats all three as one
357
+ // break, so rejoining with "\n" is the same substitution in one pass.
358
+ input = codepointClass.splitLinesAny(input).join("\n");
183
359
 
184
360
  // Split into raw lines preserving line numbers.
185
361
  var rawLines = input.split("\n");
@@ -212,11 +388,11 @@ function parse(input, opts) {
212
388
  // Reject any later `---` or `...` (multi-document streams not supported).
213
389
  var idx = 0;
214
390
  while (idx < lines.length && (lines[idx].isBlank || lines[idx].isComment)) idx += 1;
215
- if (idx < lines.length && /^---(\s|$)/.test(lines[idx].content)) idx += 1;
391
+ if (idx < lines.length && _isDocumentMarker(lines[idx].content, "---")) idx += 1;
216
392
  // Subsequent doc markers anywhere = reject.
217
393
  for (var j = idx; j < lines.length; j++) {
218
394
  var c = lines[j].content;
219
- if (/^---(\s|$)/.test(c) || /^\.\.\.(\s|$)/.test(c)) {
395
+ if (_isDocumentMarker(c, "---") || _isDocumentMarker(c, "...")) {
220
396
  throw new SafeYamlError(
221
397
  "multi-document YAML streams are not supported",
222
398
  "yaml/multi-document", lines[j].lineNumber, 1
@@ -277,7 +453,7 @@ function parse(input, opts) {
277
453
  }
278
454
 
279
455
  // Block scalar header: `|` or `>` (with optional chomp/indent indicator)
280
- if (/^[|>][-+]?[0-9]?\s*(#.*)?$/.test(content)) {
456
+ if (_isBlockScalarHeader(content, true)) {
281
457
  return _parseBlockScalar(k, indent, content);
282
458
  }
283
459
 
@@ -398,7 +574,8 @@ function parse(input, opts) {
398
574
  // 1. if there's content after the colon, that's a flow value or
399
575
  // a plain scalar continuation on the same line.
400
576
  // 2. otherwise the value lives on subsequent more-indented lines.
401
- var afterColon = ln.content.substring(keyRange.valueStart).replace(/^[ \t]+/, "");
577
+ var afterColon = codepointClass.trimChars(
578
+ ln.content.substring(keyRange.valueStart), " \t", { trailing: false });
402
579
  // Strip end-of-line comment
403
580
  afterColon = _stripEolComment(afterColon);
404
581
  var value;
@@ -406,7 +583,7 @@ function parse(input, opts) {
406
583
  // Inline value on this line.
407
584
  if (afterColon.charAt(0) === "|" || afterColon.charAt(0) === ">") {
408
585
  // Block scalar header inline with key
409
- if (!/^[|>][-+]?[0-9]?\s*$/.test(afterColon)) {
586
+ if (!_isBlockScalarHeader(afterColon, false)) {
410
587
  throw new SafeYamlError("malformed block scalar header",
411
588
  "yaml/bad-block-scalar", ln.lineNumber, ln.indent + 1);
412
589
  }
@@ -506,7 +683,8 @@ function parse(input, opts) {
506
683
  // branch below) using _parseFlowValue's end position, then return the value
507
684
  // via the direct parser so the shape is identical to the pre-existing path.
508
685
  var fend = _parseFlowValue(t, 0, lineNumber, col, 0).nextPos;
509
- var afterFlow = t.slice(fend).replace(/^\s+/, "");
686
+ var afterFlow = codepointClass.trimRanges(
687
+ t.slice(fend), codepointClass.WHITESPACE_RANGES, { trailing: false });
510
688
  if (afterFlow.length > 0 && afterFlow.charAt(0) !== "#") {
511
689
  throw new SafeYamlError("unexpected content after flow collection",
512
690
  "yaml/trailing-content", lineNumber, col);
@@ -517,7 +695,8 @@ function parse(input, opts) {
517
695
  if (t.charAt(0) === '"') {
518
696
  var dq = _decodeDoubleQuoted(t, lineNumber, col);
519
697
  var afterDq = _trailingAfterQuoted(t, '"');
520
- if (afterDq.length > 0 && afterDq.replace(/^\s+/, "") !== "") {
698
+ if (afterDq.length > 0 &&
699
+ codepointClass.trimRanges(afterDq, codepointClass.WHITESPACE_RANGES, { trailing: false }) !== "") {
521
700
  throw new SafeYamlError("unexpected content after quoted string",
522
701
  "yaml/trailing-content", lineNumber, col);
523
702
  }
@@ -759,7 +938,7 @@ function parse(input, opts) {
759
938
  }
760
939
  case "U": {
761
940
  var hex8 = raw.substring(i + 2, i + 10);
762
- if (!/^[0-9a-fA-F]{8}$/.test(hex8)) {
941
+ if (hex8.length !== 8 || !codepointClass.isRunOf(hex8, codepointClass.ASCII_HEX)) {
763
942
  throw new SafeYamlError("bad \\U escape", "yaml/bad-escape", lineNumber, col + i);
764
943
  }
765
944
  var code = parseInt(hex8, RADIX_HEX);
@@ -904,7 +1083,7 @@ function parse(input, opts) {
904
1083
 
905
1084
  if (chomp === "-") {
906
1085
  // strip — remove trailing newline(s)
907
- body = body.replace(/\n+$/, "");
1086
+ body = codepointClass.trimRanges(body, [0x0A], { leading: false });
908
1087
  } else if (chomp === "+") {
909
1088
  // keep — restore trailing blanks we popped
910
1089
  body += "\n".repeat(trailingBlanks);
@@ -1026,14 +1205,12 @@ function _preValidate(input) {
1026
1205
  // (or start of value position). A simple heuristic: any unescaped `&`
1027
1206
  // that's followed by an identifier char and is preceded by space or
1028
1207
  // line start is an anchor. Same for `*`.
1029
- var anchorOrAliasRe = /(^|\s)([&*])([A-Za-z0-9_][A-Za-z0-9_-]*)/;
1030
- var m = safe.match(anchorOrAliasRe);
1208
+ var m = _findAnchorOrAlias(safe);
1031
1209
  if (m) {
1032
- var posIdx = safe.indexOf(m[0]);
1033
- var lineCount = safe.substring(0, posIdx).split("\n").length;
1210
+ var lineCount = safe.substring(0, m.index).split("\n").length;
1034
1211
  throw new SafeYamlError(
1035
- m[2] === "&" ? "anchors are not supported" : "aliases are not supported",
1036
- m[2] === "&" ? "yaml/anchors-banned" : "yaml/aliases-banned",
1212
+ m.sigil === "&" ? "anchors are not supported" : "aliases are not supported",
1213
+ m.sigil === "&" ? "yaml/anchors-banned" : "yaml/aliases-banned",
1037
1214
  lineCount, 1
1038
1215
  );
1039
1216
  }
@@ -1041,21 +1218,17 @@ function _preValidate(input) {
1041
1218
  // Tags: `!` at start of value or after `: ` / `- `. False-positive risk:
1042
1219
  // "key: !something" vs "key: ![bracket". We match `!` followed by
1043
1220
  // alphanumeric or `<`.
1044
- var tagRe = /(^|[\s-])(!{1,2}[A-Za-z<])/;
1045
- var mt = safe.match(tagRe);
1221
+ var mt = _findTag(safe);
1046
1222
  if (mt) {
1047
- var tagIdx = safe.indexOf(mt[0]);
1048
- var tagLine = safe.substring(0, tagIdx).split("\n").length;
1223
+ var tagLine = safe.substring(0, mt.index).split("\n").length;
1049
1224
  throw new SafeYamlError("tags are not supported",
1050
1225
  "yaml/tags-banned", tagLine, 1);
1051
1226
  }
1052
1227
 
1053
1228
  // Directives: `%YAML` or `%TAG` at column 0
1054
- var dirRe = /(^|\n)%(YAML|TAG)\b/;
1055
- var md = safe.match(dirRe);
1229
+ var md = _findDirective(safe);
1056
1230
  if (md) {
1057
- var dirIdx = safe.indexOf(md[0]);
1058
- var dirLine = safe.substring(0, dirIdx).split("\n").length + (md[1] === "\n" ? 1 : 0);
1231
+ var dirLine = safe.substring(0, md.index).split("\n").length + (md.precededByNewline ? 1 : 0);
1059
1232
  throw new SafeYamlError("directives are not supported",
1060
1233
  "yaml/directives-banned", dirLine, 1);
1061
1234
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.18.36",
3
+ "version": "0.18.37",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:f384e0aa-ad27-4c4f-a0b0-eceb0cac068e",
5
+ "serialNumber": "urn:uuid:446f4ad2-1001-404f-a62c-c116788ed0b8",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-08-18T23:20:05.652Z",
8
+ "timestamp": "2026-08-19T02:55:12.713Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.18.36",
22
+ "bom-ref": "@blamejs/core@0.18.37",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.18.36",
25
+ "version": "0.18.37",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.18.36",
29
+ "purl": "pkg:npm/%40blamejs/core@0.18.37",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.18.36",
57
+ "ref": "@blamejs/core@0.18.37",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]