@blamejs/core 0.18.36 → 0.18.38
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 +56 -0
- package/lib/guard-regex.js +6 -0
- package/lib/guard-sql.js +49 -4
- package/lib/parsers/safe-env.js +126 -17
- package/lib/parsers/safe-ini.js +115 -9
- package/lib/parsers/safe-toml.js +71 -16
- package/lib/parsers/safe-xml.js +7 -1
- package/lib/parsers/safe-yaml.js +213 -40
- package/lib/safe-json.js +6 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,62 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.18.x
|
|
10
10
|
|
|
11
|
+
- v0.18.38 (2026-08-19) — **`COPY ... TO STDIN` was reported as a server-side file access whenever it carried more than one space.** `b.guardSql`'s `copy-file` detector finds a `COPY` that reads or writes a file on the database server, and excludes the client-streaming `STDIN` and `STDOUT` forms because those touch no file. The exclusion was a negative lookahead, and it only worked when the whitespace before the keyword was exactly one character: `COPY t TO STDIN` was quiet, `COPY t TO STDIN` was reported critical.
|
|
12
|
+
|
|
13
|
+
The exclusion is now decided by reading the following word rather than by a lookahead, so the spacing no longer changes the verdict.
|
|
14
|
+
|
|
15
|
+
No API changes. Upgrade if you pass SQL fragments through `b.guardSql`. **Changed:** *The content-safety gate now refuses a pattern built at runtime* — `blamejs/no-regex-in-content-safety` reported pattern literals only, so `new RegExp(source)` passed it unnoticed anywhere under `lib/**/safe-*.js` or `lib/**/guard-*.js`. It now reports `new RegExp(...)` and `RegExp(...)`, in both the bare and member spellings — `globalThis.RegExp(src)` puts a MemberExpression in the callee and an identifier-only check permits it silently.
|
|
16
|
+
|
|
17
|
+
Three call sites carry a suppression with its reason recorded beside it. Two of them never match anything against input: `b.guardRegex` asks the parser whether an operator-supplied pattern compiles at all and discards the result, and `b.safeJson` compiles a schema `pattern` precisely so `assertSafe` can screen the compiled form. The third is `b.guardSql`'s detector table, described below. · *The SQL detectors stay on the platform engine, and the reason is now recorded* — These 32 detectors are built with `new RegExp` and run against a caller's SQL, which reads like something the content-safety rule should forbid. Moving them to `b.regexLinear` — whose cost is the length of the subject whatever the pattern says — was tried and measured, and it is worse:
|
|
18
|
+
|
|
19
|
+
| subject | linear | platform |
|
|
20
|
+
| --- | --- | --- |
|
|
21
|
+
| 158-byte benign SELECT | 1.4 ms | 0.001 ms |
|
|
22
|
+
| 4 KiB benign SELECT | 15.4 ms | 0.012 ms |
|
|
23
|
+
|
|
24
|
+
The engine expands each `{0,4000}` span into thousands of states, so every ordinary fragment pays. The platform engine's worst case over these same patterns, on adversarial input built to defeat every lazy span, is 5 ms at 32 KiB — less than the linear engine charges for a benign 4 KiB one. The swap would have made every request cost more than the attack it was meant to prevent.
|
|
25
|
+
|
|
26
|
+
What bounds these patterns is the patterns: every span is explicitly capped and none pairs two quantifiers over an overlapping alphabet. That is a property of this table rather than a general licence, and it is written where the table is defined so a new detector gets the same check.
|
|
27
|
+
|
|
28
|
+
SECURITY.md is corrected to match. It named a build gate retired in 0.18.37, said no primitive in the family "contains a regular expression" where the enforced claim is that none SCREENS with one, and now states plainly that a runtime-built pattern is not yet reported by the gate. **Fixed:** *The COPY file-access detector no longer depends on how much whitespace precedes STDIN* — The pattern excluded the safe forms with `\s+(?!STDIN\b|STDOUT\b)`. `\s+` is greedy but backtracks: given two spaces it could give one back, leaving ` STDIN` in front of the lookahead, which then read as "not STDIN" and reported a statement that opens no file.
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
COPY t TO STDIN quiet
|
|
32
|
+
COPY t TO STDIN reported as "reads or writes a server-side file"
|
|
33
|
+
COPY t TO STDIN reported
|
|
34
|
+
COPY t TO \n STDIN reported
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The exclusion is now tested BEFORE the whitespace is consumed — `\b(?:TO|FROM)\b(?!\s+(?:STDIN|STDOUT)\b)\s+` — so it is evaluated once, at a fixed position, and cannot interact with that quantifier at all.
|
|
38
|
+
|
|
39
|
+
The obvious repair is worse. Teaching the lookahead to skip whitespace where it stood, `\s+(?!\s*(?:STDIN|STDOUT)\b)`, gives the two quantifiers the same alphabet: every backtrack re-scans the remainder of the run, which measures 45 ms on a 16,000-space run and grows quadratically — a denial of service introduced into the primitive whose job is to prevent one. Moving the test in front of `\s+` measures flat, and a cost regression check now covers it.
|
|
40
|
+
|
|
41
|
+
What the detector catches is unchanged, and the lookahead is kept rather than replaced with a scan for a specific reason: its backtracking is what finds the file in `COPY (SELECT x FROM STDOUT) TO '/tmp/x'`, where the first candidate is excluded and the one that matters appears later in the same statement. A scan that resumed past the excluded match would miss it, because every match has to begin at `COPY`. `COPY t TO '/etc/passwd'` fires, `COPY t TO STDINX` fires because the word does not end at `STDIN`, and `COPY t TO STDOUT; COPY u TO '/tmp/x'` fires on the second statement.
|
|
42
|
+
|
|
43
|
+
Twenty regression checks cover the whitespace forms, the nested case and the boundary. **References:** [PostgreSQL COPY — TO/FROM STDIN and STDOUT stream to the client](https://www.postgresql.org/docs/current/sql-copy.html)
|
|
44
|
+
|
|
45
|
+
- 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.
|
|
46
|
+
|
|
47
|
+
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.
|
|
48
|
+
|
|
49
|
+
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.
|
|
50
|
+
|
|
51
|
+
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.
|
|
52
|
+
|
|
53
|
+
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.
|
|
54
|
+
|
|
55
|
+
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.
|
|
56
|
+
|
|
57
|
+
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.
|
|
58
|
+
|
|
59
|
+
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.
|
|
60
|
+
|
|
61
|
+
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.
|
|
62
|
+
|
|
63
|
+
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.
|
|
64
|
+
|
|
65
|
+
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)
|
|
66
|
+
|
|
11
67
|
- 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
68
|
|
|
13
69
|
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.
|
package/lib/guard-regex.js
CHANGED
|
@@ -2321,6 +2321,12 @@ function _ambiguityFindings(src, flags) {
|
|
|
2321
2321
|
// at all" and returned every finding false, so a quadratic pattern using
|
|
2322
2322
|
// any of that syntax walked straight past this gate.
|
|
2323
2323
|
var compiles = true;
|
|
2324
|
+
// The result is discarded and the pattern is never run against a subject —
|
|
2325
|
+
// this asks the parser whether `text` is a regular expression at all, which
|
|
2326
|
+
// is the opposite direction from screening input with one. Nothing a caller
|
|
2327
|
+
// supplies is matched here; a source that does not compile simply reports as
|
|
2328
|
+
// not-a-regex.
|
|
2329
|
+
// eslint-disable-next-line blamejs/no-regex-in-content-safety
|
|
2324
2330
|
try { RegExp(text, typeof flags === "string" ? flags : ""); }
|
|
2325
2331
|
catch (_e) { compiles = false; }
|
|
2326
2332
|
if (compiles) {
|
package/lib/guard-sql.js
CHANGED
|
@@ -248,11 +248,32 @@ var MASK_SPACE = " ";
|
|
|
248
248
|
|
|
249
249
|
function _re(source) {
|
|
250
250
|
// Construct each detector regex from an ASCII source string so the
|
|
251
|
-
// source file embeds no attack-character literals. Case-insensitive
|
|
252
|
-
//
|
|
253
|
-
|
|
251
|
+
// source file embeds no attack-character literals. Case-insensitive, and
|
|
252
|
+
// deliberately NOT global: `.test()` on a `g` regex advances `lastIndex` and
|
|
253
|
+
// answers differently on the next call with the same input.
|
|
254
|
+
//
|
|
255
|
+
// These stay on the platform engine, and that was measured rather than
|
|
256
|
+
// assumed. Compiling all 32 on b.regexLinear — whose cost is the length of the
|
|
257
|
+
// subject whatever the pattern says — makes a BENIGN 4 KiB fragment cost 15 ms
|
|
258
|
+
// against 0.012 ms here, because the NFA expands each `{0,4000}` span into
|
|
259
|
+
// thousands of states. The platform engine's own worst case over these
|
|
260
|
+
// patterns is 5 ms at 32 KiB of adversarial SQL, so the swap would have made
|
|
261
|
+
// every request pay more than the attack it was meant to prevent.
|
|
262
|
+
//
|
|
263
|
+
// What bounds these is the patterns themselves: every span is explicitly
|
|
264
|
+
// capped, and none pairs two quantifiers over an overlapping alphabet. That is
|
|
265
|
+
// a property of THIS table, not a general licence — a new detector needs the
|
|
266
|
+
// same check before it is added.
|
|
267
|
+
//
|
|
268
|
+
// The suppression below is the ONE audited exception in the family, and it is
|
|
269
|
+
// recorded rather than assumed: the alternative was measured and is worse, the
|
|
270
|
+
// sources are a fixed compile-time table rather than anything a caller
|
|
271
|
+
// supplies, and the cost is covered by a regression check in guard-sql.test.js.
|
|
272
|
+
// eslint-disable-next-line blamejs/no-regex-in-content-safety
|
|
273
|
+
return new RegExp(source, "i"); // allow:dynamic-regex — compile-time ASCII literal table, every span bounded
|
|
254
274
|
}
|
|
255
275
|
|
|
276
|
+
|
|
256
277
|
// \b word-boundary + optional whitespace/paren tolerance baked into
|
|
257
278
|
// each source string. `[\s]` spans the comment-collapsed single spaces.
|
|
258
279
|
var DETECTORS = [
|
|
@@ -263,7 +284,31 @@ var DETECTORS = [
|
|
|
263
284
|
reason: "COPY ... PROGRAM executes a shell command (Postgres RCE)" },
|
|
264
285
|
{ code: "sql.file-access", severity: "critical", kind: "copy-file",
|
|
265
286
|
family: "floor", dialect: "postgres",
|
|
266
|
-
|
|
287
|
+
// STDIN / STDOUT stream to the client and touch no server-side file, so
|
|
288
|
+
// they are excluded — and the exclusion is tested BEFORE the whitespace is
|
|
289
|
+
// consumed, which is the whole trick here.
|
|
290
|
+
//
|
|
291
|
+
// Written the other way round, after `\s+`, it is wrong: `\s+` is greedy but
|
|
292
|
+
// backtracks, so given two spaces it gives one back, leaves " STDIN" in
|
|
293
|
+
// front of a lookahead that only knows how to reject "STDIN", and reports a
|
|
294
|
+
// client-side `COPY t TO STDIN` as a server-side file access. One space was
|
|
295
|
+
// quiet and two were critical.
|
|
296
|
+
//
|
|
297
|
+
// Teaching that lookahead to skip whitespace (`(?!\s*(?:STDIN|STDOUT)\b)`)
|
|
298
|
+
// fixes the verdict and introduces a worse problem: `\s+` and the `\s*`
|
|
299
|
+
// range over the same characters, so every backtrack re-scans the rest of
|
|
300
|
+
// the run — 45 ms on a 16k-space run, quadratic, in the one primitive whose
|
|
301
|
+
// job is to not be a denial of service.
|
|
302
|
+
//
|
|
303
|
+
// In front of `\s+` the lookahead is evaluated once, at a fixed position,
|
|
304
|
+
// and cannot interact with that quantifier at all. Measured flat.
|
|
305
|
+
//
|
|
306
|
+
// A lookahead rather than a scan-and-compare because its backtracking is
|
|
307
|
+
// what finds the file in `COPY (SELECT x FROM STDOUT) TO '/tmp/x'`: the
|
|
308
|
+
// first candidate is excluded and the one that matters comes later in the
|
|
309
|
+
// SAME statement, which a scan resuming past the excluded match would miss,
|
|
310
|
+
// since every match has to begin at COPY.
|
|
311
|
+
re: _re("\\bCOPY\\b[\\s\\S]{0,4000}?\\b(?:TO|FROM)\\b(?!\\s+(?:STDIN|STDOUT)\\b)\\s+"),
|
|
267
312
|
reason: "COPY TO/FROM <file> reads or writes a server-side file" },
|
|
268
313
|
{ code: "sql.file-access", severity: "critical", kind: "large-object",
|
|
269
314
|
family: "floor", dialect: "postgres",
|
package/lib/parsers/safe-env.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 (!
|
|
274
|
+
if (!keyShapeAccepts(key)) {
|
|
163
275
|
throw new SafeEnvError(
|
|
164
|
-
"key '" + key + "' does not match 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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
package/lib/parsers/safe-ini.js
CHANGED
|
@@ -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 ||
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
|
182
|
-
if (
|
|
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 =
|
|
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();
|
package/lib/parsers/safe-toml.js
CHANGED
|
@@ -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 ||
|
|
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 &&
|
|
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 (
|
|
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 &&
|
|
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 (
|
|
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 ||
|
|
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 &&
|
|
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 &&
|
|
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 &&
|
|
500
|
-
if (radix === RADIX_OCTAL &&
|
|
501
|
-
if (radix === RADIX_BIN &&
|
|
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)
|
|
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)
|
|
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" &&
|
|
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" &&
|
|
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 (
|
|
628
|
+
if (_isNumberLead(c)) return _parseNumber(c);
|
|
574
629
|
|
|
575
630
|
throw _err("unexpected character '" + c + "'", "toml/expected-value");
|
|
576
631
|
}
|
package/lib/parsers/safe-xml.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/lib/parsers/safe-yaml.js
CHANGED
|
@@ -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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
var
|
|
101
|
-
var
|
|
102
|
-
var
|
|
103
|
-
var
|
|
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 (
|
|
113
|
-
if (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
134
|
-
if (
|
|
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 (
|
|
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
|
-
|
|
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 &&
|
|
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 (
|
|
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 (
|
|
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 =
|
|
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 (
|
|
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 =
|
|
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 &&
|
|
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 (
|
|
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 =
|
|
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
|
|
1030
|
-
var m = safe.match(anchorOrAliasRe);
|
|
1208
|
+
var m = _findAnchorOrAlias(safe);
|
|
1031
1209
|
if (m) {
|
|
1032
|
-
var
|
|
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
|
|
1036
|
-
m
|
|
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
|
|
1045
|
-
var mt = safe.match(tagRe);
|
|
1221
|
+
var mt = _findTag(safe);
|
|
1046
1222
|
if (mt) {
|
|
1047
|
-
var
|
|
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
|
|
1055
|
-
var md = safe.match(dirRe);
|
|
1229
|
+
var md = _findDirective(safe);
|
|
1056
1230
|
if (md) {
|
|
1057
|
-
var
|
|
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/lib/safe-json.js
CHANGED
|
@@ -837,6 +837,12 @@ function _patternMatcher(pattern) {
|
|
|
837
837
|
// branch twice. That is the overlap the alternation rule exists to
|
|
838
838
|
// catch, so `/^(?=(a|A)+$)a+$/i` would pass a source-only screen and
|
|
839
839
|
// then run, under those flags, against a value from the wire.
|
|
840
|
+
// Compiled precisely so assertSafe can screen the COMPILED form, per the
|
|
841
|
+
// comment above: a source-only screen misreads `(a|A)+` under `i`. The
|
|
842
|
+
// pattern here is the operator's schema `pattern` keyword — the input
|
|
843
|
+
// being validated rather than the implementation of a screen — and it is
|
|
844
|
+
// refused before it ever runs against a value.
|
|
845
|
+
// eslint-disable-next-line blamejs/no-regex-in-content-safety
|
|
840
846
|
var native = new RegExp(source, flags);
|
|
841
847
|
// assertSafe builds `new ErrorClass(code, message)`; SafeJsonError takes
|
|
842
848
|
// them the other way round, so the screen reports through its own error.
|
package/package.json
CHANGED
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:
|
|
5
|
+
"serialNumber": "urn:uuid:3e525c66-4a72-4004-90a2-8985bbb5daa4",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-08-
|
|
8
|
+
"timestamp": "2026-08-19T06:46:25.351Z",
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.18.38",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.18.
|
|
25
|
+
"version": "0.18.38",
|
|
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.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.18.38",
|
|
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.
|
|
57
|
+
"ref": "@blamejs/core@0.18.38",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|