@object-ui/core 3.1.5 → 3.3.0
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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +12 -0
- package/dist/adapters/ValueDataSource.d.ts +5 -1
- package/dist/adapters/ValueDataSource.js +27 -0
- package/dist/errors/index.js +2 -3
- package/dist/evaluator/ExpressionCache.d.ts +9 -10
- package/dist/evaluator/ExpressionCache.js +29 -8
- package/dist/evaluator/SafeExpressionParser.d.ts +131 -0
- package/dist/evaluator/SafeExpressionParser.js +851 -0
- package/dist/evaluator/index.d.ts +1 -0
- package/dist/evaluator/index.js +1 -0
- package/dist/protocols/DndProtocol.js +2 -14
- package/dist/protocols/KeyboardProtocol.js +1 -4
- package/dist/protocols/NotificationProtocol.js +3 -13
- package/dist/utils/debug.js +2 -1
- package/package.json +6 -6
- package/src/__tests__/protocols/DndProtocol.test.ts +4 -4
- package/src/__tests__/protocols/KeyboardProtocol.test.ts +2 -2
- package/src/__tests__/protocols/NotificationProtocol.test.ts +3 -3
- package/src/adapters/ValueDataSource.ts +21 -0
- package/src/adapters/__tests__/ValueDataSource.test.ts +99 -0
- package/src/errors/index.ts +4 -5
- package/src/evaluator/ExpressionCache.ts +24 -10
- package/src/evaluator/SafeExpressionParser.ts +893 -0
- package/src/evaluator/__tests__/ExpressionEvaluator.test.ts +427 -0
- package/src/evaluator/index.ts +1 -0
- package/src/protocols/DndProtocol.ts +2 -18
- package/src/protocols/KeyboardProtocol.ts +1 -5
- package/src/protocols/NotificationProtocol.ts +3 -12
- package/src/utils/debug.ts +2 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,851 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* @object-ui/core - Safe Expression Parser
|
|
10
|
+
*
|
|
11
|
+
* CSP-safe recursive-descent expression parser for the ObjectUI expression engine.
|
|
12
|
+
* Replaces the `new Function()` / `eval()`-based expression compilation with a
|
|
13
|
+
* sandboxed interpreter that works under strict Content Security Policy headers.
|
|
14
|
+
*
|
|
15
|
+
* Operator precedence (lowest → highest):
|
|
16
|
+
* 1. Ternary a ? b : c
|
|
17
|
+
* 2. Nullish a ?? b
|
|
18
|
+
* 3. Logical OR a || b
|
|
19
|
+
* 4. Logical AND a && b
|
|
20
|
+
* 5. Equality === !== == != > < >= <=
|
|
21
|
+
* 6. Addition a + b a - b
|
|
22
|
+
* 7. Multiplication a * b a / b a % b
|
|
23
|
+
* 8. Unary !a -a +a typeof a
|
|
24
|
+
* 9. Member a.b a[b] a?.b a?.[b] a(…) a.b(…)
|
|
25
|
+
* 10. Primary literals · identifiers · ( expr ) · [ … ]
|
|
26
|
+
*
|
|
27
|
+
* @module evaluator
|
|
28
|
+
* @packageDocumentation
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* A safe subset of global JavaScript objects that are always available in
|
|
32
|
+
* expressions regardless of the provided context.
|
|
33
|
+
*
|
|
34
|
+
* SECURITY: Only read-only, non-executable primitive utilities are exposed.
|
|
35
|
+
* Constructors (`String`, `Number`, `Boolean`, `Array`) are intentionally
|
|
36
|
+
* omitted: they expose a `.constructor` property that resolves to `Function`,
|
|
37
|
+
* creating a sandbox-escape path even when `Function` itself is not listed.
|
|
38
|
+
* `eval`, `Function`, `window`, `document`, `process`, etc. are NOT included.
|
|
39
|
+
*/
|
|
40
|
+
const SAFE_GLOBALS = {
|
|
41
|
+
Math,
|
|
42
|
+
JSON,
|
|
43
|
+
parseInt,
|
|
44
|
+
parseFloat,
|
|
45
|
+
isNaN,
|
|
46
|
+
isFinite,
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Property keys that must never be accessed on any object in an expression.
|
|
50
|
+
*
|
|
51
|
+
* SECURITY: `constructor` reaches `Function`; `__proto__`/`prototype` allow
|
|
52
|
+
* prototype-chain manipulation. Access is blocked on both dot and bracket
|
|
53
|
+
* notation to prevent sandbox escapes like `obj['constructor']('...')`.
|
|
54
|
+
*/
|
|
55
|
+
const BLOCKED_PROPS = new Set([
|
|
56
|
+
'constructor',
|
|
57
|
+
'__proto__',
|
|
58
|
+
'prototype',
|
|
59
|
+
'__defineGetter__',
|
|
60
|
+
'__defineSetter__',
|
|
61
|
+
'__lookupGetter__',
|
|
62
|
+
'__lookupSetter__',
|
|
63
|
+
]);
|
|
64
|
+
/**
|
|
65
|
+
* CSP-safe recursive-descent expression parser.
|
|
66
|
+
*
|
|
67
|
+
* Call `evaluate(expression, context)` to parse and execute an expression
|
|
68
|
+
* string against a data context object without any use of `eval()` or
|
|
69
|
+
* `new Function()`.
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* ```ts
|
|
73
|
+
* const parser = new SafeExpressionParser();
|
|
74
|
+
* parser.evaluate('data.amount > 1000', { data: { amount: 1500 } }); // true
|
|
75
|
+
* parser.evaluate('stage !== "closed_won" && stage !== "closed_lost"', { stage: 'open' }); // true
|
|
76
|
+
* parser.evaluate('items.filter(i => i.active).length', { items: [{active:true},{active:false}] }); // 1
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export class SafeExpressionParser {
|
|
80
|
+
constructor() {
|
|
81
|
+
Object.defineProperty(this, "source", {
|
|
82
|
+
enumerable: true,
|
|
83
|
+
configurable: true,
|
|
84
|
+
writable: true,
|
|
85
|
+
value: ''
|
|
86
|
+
});
|
|
87
|
+
Object.defineProperty(this, "pos", {
|
|
88
|
+
enumerable: true,
|
|
89
|
+
configurable: true,
|
|
90
|
+
writable: true,
|
|
91
|
+
value: 0
|
|
92
|
+
});
|
|
93
|
+
Object.defineProperty(this, "context", {
|
|
94
|
+
enumerable: true,
|
|
95
|
+
configurable: true,
|
|
96
|
+
writable: true,
|
|
97
|
+
value: {}
|
|
98
|
+
});
|
|
99
|
+
/**
|
|
100
|
+
* Evaluation guard.
|
|
101
|
+
*
|
|
102
|
+
* When `false` the parser still advances `this.pos` through the source
|
|
103
|
+
* (maintaining correct position for the caller) but suppresses:
|
|
104
|
+
* - ReferenceErrors from undefined identifiers
|
|
105
|
+
* - actual function / method invocations
|
|
106
|
+
* - constructor calls
|
|
107
|
+
*
|
|
108
|
+
* This implements proper short-circuit semantics for `||`, `&&`, `??`, and
|
|
109
|
+
* the ternary operator without needing a separate AST pass.
|
|
110
|
+
*/
|
|
111
|
+
Object.defineProperty(this, "_evaluating", {
|
|
112
|
+
enumerable: true,
|
|
113
|
+
configurable: true,
|
|
114
|
+
writable: true,
|
|
115
|
+
value: true
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
// ─── Public API ────────────────────────────────────────────────────────────
|
|
119
|
+
/**
|
|
120
|
+
* Evaluate an expression string against a data context.
|
|
121
|
+
*
|
|
122
|
+
* Safe for use under strict CSP — never uses `eval()` or `new Function()`.
|
|
123
|
+
*
|
|
124
|
+
* @param expression - The expression to evaluate (without `${}` wrapper)
|
|
125
|
+
* @param context - Variables available to the expression
|
|
126
|
+
* @returns The evaluated result
|
|
127
|
+
* @throws {ReferenceError} When an identifier is not found in the context
|
|
128
|
+
* @throws {TypeError} On type mismatches (e.g., calling a non-function)
|
|
129
|
+
* @throws {SyntaxError} On malformed expression syntax
|
|
130
|
+
*/
|
|
131
|
+
evaluate(expression, context) {
|
|
132
|
+
this.source = expression.trim();
|
|
133
|
+
this.pos = 0;
|
|
134
|
+
// Safe globals are available but user-provided context takes priority.
|
|
135
|
+
this.context = { ...SAFE_GLOBALS, ...context };
|
|
136
|
+
const result = this.parseTernary();
|
|
137
|
+
this.skipWhitespace();
|
|
138
|
+
if (this.pos < this.source.length) {
|
|
139
|
+
throw new SyntaxError(`Unexpected token "${this.source[this.pos]}" at position ${this.pos} in expression "${expression}"`);
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
// ─── Character helpers ────────────────────────────────────────────────────
|
|
144
|
+
skipWhitespace() {
|
|
145
|
+
while (this.pos < this.source.length && /\s/.test(this.source[this.pos])) {
|
|
146
|
+
this.pos++;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
peek(offset = 0) {
|
|
150
|
+
return this.source[this.pos + offset] ?? '';
|
|
151
|
+
}
|
|
152
|
+
consume() {
|
|
153
|
+
return this.source[this.pos++] ?? '';
|
|
154
|
+
}
|
|
155
|
+
// ─── Evaluation control helpers ───────────────────────────────────────────
|
|
156
|
+
/**
|
|
157
|
+
* Execute `fn` with `_evaluating` temporarily set to `enabled`.
|
|
158
|
+
* Restores the previous value even if `fn` throws.
|
|
159
|
+
*
|
|
160
|
+
* Used to implement short-circuit evaluation: when a branch should not be
|
|
161
|
+
* executed we call `withEvaluation(false, parseX)` which advances the source
|
|
162
|
+
* position without performing any side-effectful evaluations.
|
|
163
|
+
*/
|
|
164
|
+
withEvaluation(enabled, fn) {
|
|
165
|
+
const prev = this._evaluating;
|
|
166
|
+
this._evaluating = enabled;
|
|
167
|
+
try {
|
|
168
|
+
return fn();
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
this._evaluating = prev;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// ─── Security helpers ─────────────────────────────────────────────────────
|
|
175
|
+
/**
|
|
176
|
+
* Guard property accesses against sandbox-escape keys.
|
|
177
|
+
* Throws `TypeError` when the key is in `BLOCKED_PROPS`.
|
|
178
|
+
*
|
|
179
|
+
* Only string keys need checking: all blocked property names are strings,
|
|
180
|
+
* and `BLOCKED_PROPS.has()` with a number or symbol can never match them.
|
|
181
|
+
* Numeric indices (e.g. `arr[0]`) and symbol-keyed properties are therefore
|
|
182
|
+
* safe to access and are intentionally left unchecked.
|
|
183
|
+
*/
|
|
184
|
+
assertSafeProp(key) {
|
|
185
|
+
if (typeof key === 'string' && BLOCKED_PROPS.has(key)) {
|
|
186
|
+
throw new TypeError(`Access to property "${key}" is not permitted in expressions`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// ─── Parsing levels ───────────────────────────────────────────────────────
|
|
190
|
+
/** Level 1 — Ternary: `cond ? trueVal : falseVal` (right-associative) */
|
|
191
|
+
parseTernary() {
|
|
192
|
+
const cond = this.parseNullish();
|
|
193
|
+
this.skipWhitespace();
|
|
194
|
+
if (this.peek() === '?' && this.peek(1) !== '?') {
|
|
195
|
+
this.pos++; // consume '?'
|
|
196
|
+
this.skipWhitespace();
|
|
197
|
+
if (!this._evaluating) {
|
|
198
|
+
// Dry-run mode: parse both branches for position tracking only.
|
|
199
|
+
this.parseTernary(); // true branch (positional advance)
|
|
200
|
+
this.skipWhitespace();
|
|
201
|
+
if (this.peek() !== ':') {
|
|
202
|
+
throw new SyntaxError('Expected ":" in ternary expression');
|
|
203
|
+
}
|
|
204
|
+
this.pos++; // consume ':'
|
|
205
|
+
this.skipWhitespace();
|
|
206
|
+
this.parseTernary(); // false branch (positional advance)
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
if (cond) {
|
|
210
|
+
// Evaluate true branch; skip false branch without side effects.
|
|
211
|
+
const trueVal = this.parseTernary();
|
|
212
|
+
this.skipWhitespace();
|
|
213
|
+
if (this.peek() !== ':') {
|
|
214
|
+
throw new SyntaxError('Expected ":" in ternary expression');
|
|
215
|
+
}
|
|
216
|
+
this.pos++; // consume ':'
|
|
217
|
+
this.skipWhitespace();
|
|
218
|
+
this.withEvaluation(false, () => this.parseTernary()); // skip false
|
|
219
|
+
return trueVal;
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
// Skip true branch without side effects; evaluate false branch.
|
|
223
|
+
this.withEvaluation(false, () => this.parseTernary()); // skip true
|
|
224
|
+
this.skipWhitespace();
|
|
225
|
+
if (this.peek() !== ':') {
|
|
226
|
+
throw new SyntaxError('Expected ":" in ternary expression');
|
|
227
|
+
}
|
|
228
|
+
this.pos++; // consume ':'
|
|
229
|
+
this.skipWhitespace();
|
|
230
|
+
return this.parseTernary(); // evaluate false
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return cond;
|
|
234
|
+
}
|
|
235
|
+
/** Level 2 — Nullish coalescing: `a ?? b` */
|
|
236
|
+
parseNullish() {
|
|
237
|
+
let left = this.parseOr();
|
|
238
|
+
this.skipWhitespace();
|
|
239
|
+
while (this.peek() === '?' && this.peek(1) === '?') {
|
|
240
|
+
this.pos += 2;
|
|
241
|
+
this.skipWhitespace();
|
|
242
|
+
// Short-circuit: left is non-nullish — skip RHS without evaluating it.
|
|
243
|
+
if (this._evaluating && left != null) {
|
|
244
|
+
this.withEvaluation(false, () => this.parseOr());
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
const right = this.parseOr();
|
|
248
|
+
if (this._evaluating)
|
|
249
|
+
left = left ?? right;
|
|
250
|
+
}
|
|
251
|
+
this.skipWhitespace();
|
|
252
|
+
}
|
|
253
|
+
return left;
|
|
254
|
+
}
|
|
255
|
+
/** Level 3 — Logical OR: `a || b` */
|
|
256
|
+
parseOr() {
|
|
257
|
+
let left = this.parseAnd();
|
|
258
|
+
this.skipWhitespace();
|
|
259
|
+
while (this.peek() === '|' && this.peek(1) === '|') {
|
|
260
|
+
this.pos += 2;
|
|
261
|
+
this.skipWhitespace();
|
|
262
|
+
// Short-circuit: left is truthy — skip RHS without evaluating it.
|
|
263
|
+
if (this._evaluating && left) {
|
|
264
|
+
this.withEvaluation(false, () => this.parseAnd());
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
const right = this.parseAnd();
|
|
268
|
+
if (this._evaluating)
|
|
269
|
+
left = left || right;
|
|
270
|
+
}
|
|
271
|
+
this.skipWhitespace();
|
|
272
|
+
}
|
|
273
|
+
return left;
|
|
274
|
+
}
|
|
275
|
+
/** Level 4 — Logical AND: `a && b` */
|
|
276
|
+
parseAnd() {
|
|
277
|
+
let left = this.parseEquality();
|
|
278
|
+
this.skipWhitespace();
|
|
279
|
+
while (this.peek() === '&' && this.peek(1) === '&') {
|
|
280
|
+
this.pos += 2;
|
|
281
|
+
this.skipWhitespace();
|
|
282
|
+
// Short-circuit: left is falsy — skip RHS without evaluating it.
|
|
283
|
+
if (this._evaluating && !left) {
|
|
284
|
+
this.withEvaluation(false, () => this.parseEquality());
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
const right = this.parseEquality();
|
|
288
|
+
if (this._evaluating)
|
|
289
|
+
left = left && right;
|
|
290
|
+
}
|
|
291
|
+
this.skipWhitespace();
|
|
292
|
+
}
|
|
293
|
+
return left;
|
|
294
|
+
}
|
|
295
|
+
/** Level 5 — Equality and relational comparisons */
|
|
296
|
+
parseEquality() {
|
|
297
|
+
let left = this.parseAddition();
|
|
298
|
+
this.skipWhitespace();
|
|
299
|
+
// eslint-disable-next-line no-constant-condition
|
|
300
|
+
while (true) {
|
|
301
|
+
let op = null;
|
|
302
|
+
if (this.peek() === '=' && this.peek(1) === '=' && this.peek(2) === '=') {
|
|
303
|
+
op = '===';
|
|
304
|
+
this.pos += 3;
|
|
305
|
+
}
|
|
306
|
+
else if (this.peek() === '!' && this.peek(1) === '=' && this.peek(2) === '=') {
|
|
307
|
+
op = '!==';
|
|
308
|
+
this.pos += 3;
|
|
309
|
+
}
|
|
310
|
+
else if (this.peek() === '=' && this.peek(1) === '=') {
|
|
311
|
+
op = '==';
|
|
312
|
+
this.pos += 2;
|
|
313
|
+
}
|
|
314
|
+
else if (this.peek() === '!' && this.peek(1) === '=') {
|
|
315
|
+
op = '!=';
|
|
316
|
+
this.pos += 2;
|
|
317
|
+
}
|
|
318
|
+
else if (this.peek() === '>' && this.peek(1) === '=') {
|
|
319
|
+
op = '>=';
|
|
320
|
+
this.pos += 2;
|
|
321
|
+
}
|
|
322
|
+
else if (this.peek() === '<' && this.peek(1) === '=') {
|
|
323
|
+
op = '<=';
|
|
324
|
+
this.pos += 2;
|
|
325
|
+
}
|
|
326
|
+
else if (this.peek() === '>' && this.peek(1) !== '>') {
|
|
327
|
+
op = '>';
|
|
328
|
+
this.pos++;
|
|
329
|
+
}
|
|
330
|
+
else if (this.peek() === '<' && this.peek(1) !== '<') {
|
|
331
|
+
op = '<';
|
|
332
|
+
this.pos++;
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
this.skipWhitespace();
|
|
338
|
+
const right = this.parseAddition();
|
|
339
|
+
switch (op) {
|
|
340
|
+
case '===':
|
|
341
|
+
left = left === right;
|
|
342
|
+
break;
|
|
343
|
+
case '!==':
|
|
344
|
+
left = left !== right;
|
|
345
|
+
break;
|
|
346
|
+
// eslint-disable-next-line eqeqeq
|
|
347
|
+
case '==':
|
|
348
|
+
left = left == right;
|
|
349
|
+
break;
|
|
350
|
+
// eslint-disable-next-line eqeqeq
|
|
351
|
+
case '!=':
|
|
352
|
+
left = left != right;
|
|
353
|
+
break;
|
|
354
|
+
case '>':
|
|
355
|
+
left = left > right;
|
|
356
|
+
break;
|
|
357
|
+
case '<':
|
|
358
|
+
left = left < right;
|
|
359
|
+
break;
|
|
360
|
+
case '>=':
|
|
361
|
+
left = left >= right;
|
|
362
|
+
break;
|
|
363
|
+
case '<=':
|
|
364
|
+
left = left <= right;
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
this.skipWhitespace();
|
|
368
|
+
}
|
|
369
|
+
return left;
|
|
370
|
+
}
|
|
371
|
+
/** Level 6 — Addition / Subtraction */
|
|
372
|
+
parseAddition() {
|
|
373
|
+
let left = this.parseMultiplication();
|
|
374
|
+
this.skipWhitespace();
|
|
375
|
+
while ((this.peek() === '+' || this.peek() === '-') &&
|
|
376
|
+
this.peek(1) !== '=' // avoid consuming += / -=
|
|
377
|
+
) {
|
|
378
|
+
const op = this.consume();
|
|
379
|
+
this.skipWhitespace();
|
|
380
|
+
const right = this.parseMultiplication();
|
|
381
|
+
left = op === '+' ? left + right : left - right;
|
|
382
|
+
this.skipWhitespace();
|
|
383
|
+
}
|
|
384
|
+
return left;
|
|
385
|
+
}
|
|
386
|
+
/** Level 7 — Multiplication / Division / Modulo */
|
|
387
|
+
parseMultiplication() {
|
|
388
|
+
let left = this.parseUnary();
|
|
389
|
+
this.skipWhitespace();
|
|
390
|
+
while ((this.peek() === '*' || this.peek() === '/' || this.peek() === '%') &&
|
|
391
|
+
this.peek(1) !== '=') {
|
|
392
|
+
const op = this.consume();
|
|
393
|
+
this.skipWhitespace();
|
|
394
|
+
const right = this.parseUnary();
|
|
395
|
+
if (op === '*')
|
|
396
|
+
left = left * right;
|
|
397
|
+
else if (op === '/')
|
|
398
|
+
left = left / right;
|
|
399
|
+
else
|
|
400
|
+
left = left % right;
|
|
401
|
+
this.skipWhitespace();
|
|
402
|
+
}
|
|
403
|
+
return left;
|
|
404
|
+
}
|
|
405
|
+
/** Level 8 — Unary operators: `!`, `-`, `+`, `typeof` */
|
|
406
|
+
parseUnary() {
|
|
407
|
+
this.skipWhitespace();
|
|
408
|
+
// typeof (must be checked before identifier parsing to avoid consuming it)
|
|
409
|
+
if (this.source.startsWith('typeof', this.pos) &&
|
|
410
|
+
!/[\w$]/.test(this.source[this.pos + 6] ?? '')) {
|
|
411
|
+
this.pos += 6;
|
|
412
|
+
this.skipWhitespace();
|
|
413
|
+
try {
|
|
414
|
+
return typeof this.parseUnary();
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
// typeof undeclaredVar === 'undefined' in real JS
|
|
418
|
+
return 'undefined';
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (this.peek() === '!') {
|
|
422
|
+
this.pos++;
|
|
423
|
+
return !this.parseUnary();
|
|
424
|
+
}
|
|
425
|
+
if (this.peek() === '-') {
|
|
426
|
+
this.pos++;
|
|
427
|
+
return -this.parseUnary();
|
|
428
|
+
}
|
|
429
|
+
if (this.peek() === '+') {
|
|
430
|
+
this.pos++;
|
|
431
|
+
return +this.parseUnary();
|
|
432
|
+
}
|
|
433
|
+
return this.parseMember();
|
|
434
|
+
}
|
|
435
|
+
/** Level 9 — Member access, method calls, function calls */
|
|
436
|
+
parseMember() {
|
|
437
|
+
let obj = this.parsePrimary();
|
|
438
|
+
// eslint-disable-next-line no-constant-condition
|
|
439
|
+
while (true) {
|
|
440
|
+
this.skipWhitespace();
|
|
441
|
+
const isOptionalDot = this.peek() === '?' && this.peek(1) === '.';
|
|
442
|
+
const isDot = this.peek() === '.' && this.peek(1) !== '.';
|
|
443
|
+
if (isDot || isOptionalDot) {
|
|
444
|
+
// Property / method access: obj.prop or obj?.prop
|
|
445
|
+
if (isOptionalDot)
|
|
446
|
+
this.pos++; // consume '?' for ?.
|
|
447
|
+
this.pos++; // consume '.'
|
|
448
|
+
this.skipWhitespace();
|
|
449
|
+
const prop = this.parseIdentifierName();
|
|
450
|
+
if (!prop)
|
|
451
|
+
break;
|
|
452
|
+
// Block sandbox-escape properties regardless of evaluation mode.
|
|
453
|
+
this.assertSafeProp(prop);
|
|
454
|
+
this.skipWhitespace();
|
|
455
|
+
if (this.peek() === '(') {
|
|
456
|
+
// Method call: obj.method(args)
|
|
457
|
+
this.pos++; // consume '('
|
|
458
|
+
const args = this.parseArgList();
|
|
459
|
+
if (this.peek() !== ')') {
|
|
460
|
+
throw new SyntaxError(`Expected ")" after argument list at position ${this.pos}`);
|
|
461
|
+
}
|
|
462
|
+
this.pos++; // consume ')'
|
|
463
|
+
if (!this._evaluating) {
|
|
464
|
+
obj = undefined;
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
if (obj != null && typeof obj[prop] === 'function') {
|
|
468
|
+
obj = obj[prop](...args);
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
obj = undefined;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
else {
|
|
475
|
+
// Property access
|
|
476
|
+
if (!this._evaluating) {
|
|
477
|
+
obj = undefined;
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
obj = obj != null ? obj[prop] : undefined;
|
|
481
|
+
}
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
const isOptionalBracket = this.peek() === '?' && this.peek(1) === '[';
|
|
485
|
+
const isBracket = this.peek() === '[';
|
|
486
|
+
if (isBracket || isOptionalBracket) {
|
|
487
|
+
// Bracket access: obj[key] or obj?.[key]
|
|
488
|
+
if (isOptionalBracket)
|
|
489
|
+
this.pos++; // consume '?' for ?.[
|
|
490
|
+
this.pos++; // consume '['
|
|
491
|
+
this.skipWhitespace();
|
|
492
|
+
const key = this.parseTernary();
|
|
493
|
+
this.skipWhitespace();
|
|
494
|
+
if (this.peek() !== ']') {
|
|
495
|
+
throw new SyntaxError(`Expected "]" after bracket expression at position ${this.pos}`);
|
|
496
|
+
}
|
|
497
|
+
this.pos++; // consume ']'
|
|
498
|
+
// Block sandbox-escape properties regardless of evaluation mode.
|
|
499
|
+
this.assertSafeProp(key);
|
|
500
|
+
if (!this._evaluating) {
|
|
501
|
+
obj = undefined;
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
obj = obj != null ? obj[key] : undefined;
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
if (this.peek() === '(') {
|
|
508
|
+
// Direct function call on a returned value, e.g. (getFunc())(args)
|
|
509
|
+
this.pos++; // consume '('
|
|
510
|
+
const args = this.parseArgList();
|
|
511
|
+
if (this.peek() !== ')') {
|
|
512
|
+
throw new SyntaxError(`Expected ")" after argument list at position ${this.pos}`);
|
|
513
|
+
}
|
|
514
|
+
this.pos++; // consume ')'
|
|
515
|
+
if (!this._evaluating) {
|
|
516
|
+
obj = undefined;
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
if (typeof obj === 'function') {
|
|
520
|
+
obj = obj(...args);
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
throw new TypeError(`${String(obj)} is not a function`);
|
|
524
|
+
}
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
return obj;
|
|
530
|
+
}
|
|
531
|
+
/** Level 10 — Primary expressions: literals, identifiers, `(expr)`, `[…]` */
|
|
532
|
+
parsePrimary() {
|
|
533
|
+
this.skipWhitespace();
|
|
534
|
+
const ch = this.peek();
|
|
535
|
+
// Parenthesized expression
|
|
536
|
+
if (ch === '(') {
|
|
537
|
+
this.pos++;
|
|
538
|
+
const val = this.parseTernary();
|
|
539
|
+
this.skipWhitespace();
|
|
540
|
+
if (this.peek() !== ')') {
|
|
541
|
+
throw new SyntaxError(`Expected ")" to close "(" expression at position ${this.pos}`);
|
|
542
|
+
}
|
|
543
|
+
this.pos++;
|
|
544
|
+
return val;
|
|
545
|
+
}
|
|
546
|
+
// Array literal
|
|
547
|
+
if (ch === '[') {
|
|
548
|
+
return this.parseArrayLiteral();
|
|
549
|
+
}
|
|
550
|
+
// String literals
|
|
551
|
+
if (ch === '"' || ch === "'") {
|
|
552
|
+
return this.parseString(ch);
|
|
553
|
+
}
|
|
554
|
+
// Number literals (unary `-` is handled in parseUnary, not here)
|
|
555
|
+
if (/\d/.test(ch) || (ch === '.' && /\d/.test(this.peek(1)))) {
|
|
556
|
+
return this.parseNumber();
|
|
557
|
+
}
|
|
558
|
+
// Identifiers and keywords
|
|
559
|
+
if (/[a-zA-Z_$]/.test(ch)) {
|
|
560
|
+
return this.parseIdentifierOrKeyword();
|
|
561
|
+
}
|
|
562
|
+
throw new SyntaxError(`Unexpected character "${ch}" at position ${this.pos}`);
|
|
563
|
+
}
|
|
564
|
+
// ─── Literal parsers ──────────────────────────────────────────────────────
|
|
565
|
+
parseArrayLiteral() {
|
|
566
|
+
this.pos++; // consume '['
|
|
567
|
+
const items = [];
|
|
568
|
+
this.skipWhitespace();
|
|
569
|
+
while (this.peek() !== ']' && this.pos < this.source.length) {
|
|
570
|
+
items.push(this.parseTernary());
|
|
571
|
+
this.skipWhitespace();
|
|
572
|
+
if (this.peek() === ',') {
|
|
573
|
+
this.pos++;
|
|
574
|
+
this.skipWhitespace();
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
if (this.peek() !== ']') {
|
|
578
|
+
throw new SyntaxError(`Expected "]" to close array literal at position ${this.pos}`);
|
|
579
|
+
}
|
|
580
|
+
this.pos++;
|
|
581
|
+
return items;
|
|
582
|
+
}
|
|
583
|
+
parseString(quote) {
|
|
584
|
+
this.pos++; // consume opening quote
|
|
585
|
+
let str = '';
|
|
586
|
+
while (this.pos < this.source.length) {
|
|
587
|
+
const ch = this.source[this.pos];
|
|
588
|
+
if (ch === '\\') {
|
|
589
|
+
this.pos++;
|
|
590
|
+
const esc = this.source[this.pos++] ?? '';
|
|
591
|
+
switch (esc) {
|
|
592
|
+
case 'n':
|
|
593
|
+
str += '\n';
|
|
594
|
+
break;
|
|
595
|
+
case 't':
|
|
596
|
+
str += '\t';
|
|
597
|
+
break;
|
|
598
|
+
case 'r':
|
|
599
|
+
str += '\r';
|
|
600
|
+
break;
|
|
601
|
+
case '\\':
|
|
602
|
+
str += '\\';
|
|
603
|
+
break;
|
|
604
|
+
case '"':
|
|
605
|
+
str += '"';
|
|
606
|
+
break;
|
|
607
|
+
case "'":
|
|
608
|
+
str += "'";
|
|
609
|
+
break;
|
|
610
|
+
case '`':
|
|
611
|
+
str += '`';
|
|
612
|
+
break;
|
|
613
|
+
default: str += esc;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
else if (ch === quote) {
|
|
617
|
+
this.pos++; // consume closing quote
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
str += ch;
|
|
622
|
+
this.pos++;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return str;
|
|
626
|
+
}
|
|
627
|
+
parseNumber() {
|
|
628
|
+
const start = this.pos;
|
|
629
|
+
let hasDigits = false;
|
|
630
|
+
// Note: `parsePrimary` only calls this method when the first character is
|
|
631
|
+
// a digit OR when it is '.' followed immediately by a digit, so the case
|
|
632
|
+
// of a bare '.' (e.g. `.toString()`) can never reach here.
|
|
633
|
+
// Integer part
|
|
634
|
+
while (this.pos < this.source.length && /\d/.test(this.source[this.pos])) {
|
|
635
|
+
hasDigits = true;
|
|
636
|
+
this.pos++;
|
|
637
|
+
}
|
|
638
|
+
// Optional fractional part (only one decimal point is consumed; a second
|
|
639
|
+
// '.' is left in the stream and will cause an "unexpected token" error at
|
|
640
|
+
// the `evaluate()` level, correctly rejecting inputs like `1.2.3`).
|
|
641
|
+
if (this.source[this.pos] === '.') {
|
|
642
|
+
this.pos++; // consume '.'
|
|
643
|
+
while (this.pos < this.source.length && /\d/.test(this.source[this.pos])) {
|
|
644
|
+
hasDigits = true;
|
|
645
|
+
this.pos++;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
// Optional exponent: e+5, E-3
|
|
649
|
+
if (/[eE]/.test(this.source[this.pos] ?? '')) {
|
|
650
|
+
this.pos++; // consume 'e' or 'E'
|
|
651
|
+
if (/[+\-]/.test(this.source[this.pos] ?? ''))
|
|
652
|
+
this.pos++; // optional sign
|
|
653
|
+
let expDigits = 0;
|
|
654
|
+
while (this.pos < this.source.length && /\d/.test(this.source[this.pos])) {
|
|
655
|
+
expDigits++;
|
|
656
|
+
this.pos++;
|
|
657
|
+
}
|
|
658
|
+
if (expDigits === 0) {
|
|
659
|
+
throw new SyntaxError(`Invalid numeric literal exponent at position ${start}`);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
if (!hasDigits) {
|
|
663
|
+
throw new SyntaxError(`Invalid numeric literal at position ${start}`);
|
|
664
|
+
}
|
|
665
|
+
const raw = this.source.slice(start, this.pos);
|
|
666
|
+
const value = Number(raw);
|
|
667
|
+
// Defensive final check: the strict loop above should never produce a
|
|
668
|
+
// non-finite result, but we guard here so any latent bug surfaces as a
|
|
669
|
+
// clear SyntaxError rather than silently propagating NaN.
|
|
670
|
+
if (!Number.isFinite(value)) {
|
|
671
|
+
throw new SyntaxError(`Invalid numeric literal "${raw}" at position ${start}`);
|
|
672
|
+
}
|
|
673
|
+
return value;
|
|
674
|
+
}
|
|
675
|
+
// ─── Identifier / keyword parsing ────────────────────────────────────────
|
|
676
|
+
/**
|
|
677
|
+
* Parse an identifier name (stops at non-word characters).
|
|
678
|
+
* Does NOT consume any trailing whitespace or operators.
|
|
679
|
+
*/
|
|
680
|
+
parseIdentifierName() {
|
|
681
|
+
const start = this.pos;
|
|
682
|
+
while (this.pos < this.source.length && /[\w$]/.test(this.source[this.pos])) {
|
|
683
|
+
this.pos++;
|
|
684
|
+
}
|
|
685
|
+
return this.source.slice(start, this.pos);
|
|
686
|
+
}
|
|
687
|
+
/** Parse an identifier and resolve keywords, `new`, arrows, calls, lookups. */
|
|
688
|
+
parseIdentifierOrKeyword() {
|
|
689
|
+
const id = this.parseIdentifierName();
|
|
690
|
+
// Literal keywords
|
|
691
|
+
switch (id) {
|
|
692
|
+
case 'true': return true;
|
|
693
|
+
case 'false': return false;
|
|
694
|
+
case 'null': return null;
|
|
695
|
+
case 'undefined': return undefined;
|
|
696
|
+
case 'NaN': return NaN;
|
|
697
|
+
case 'Infinity': return Infinity;
|
|
698
|
+
}
|
|
699
|
+
// `new` keyword: new Date(), new RegExp(...)
|
|
700
|
+
if (id === 'new') {
|
|
701
|
+
return this.parseNewExpression();
|
|
702
|
+
}
|
|
703
|
+
this.skipWhitespace();
|
|
704
|
+
// Single-param arrow function without parentheses: `param => body`
|
|
705
|
+
if (this.peek() === '=' && this.peek(1) === '>') {
|
|
706
|
+
return this.parseArrowFunction(id);
|
|
707
|
+
}
|
|
708
|
+
// Function call: FN(args)
|
|
709
|
+
if (this.peek() === '(') {
|
|
710
|
+
this.pos++; // consume '('
|
|
711
|
+
const args = this.parseArgList();
|
|
712
|
+
if (this.peek() !== ')') {
|
|
713
|
+
throw new SyntaxError(`Expected ")" after argument list at position ${this.pos}`);
|
|
714
|
+
}
|
|
715
|
+
this.pos++; // consume ')'
|
|
716
|
+
if (!this._evaluating)
|
|
717
|
+
return undefined;
|
|
718
|
+
const fn = this.context[id];
|
|
719
|
+
if (typeof fn === 'function') {
|
|
720
|
+
return fn(...args);
|
|
721
|
+
}
|
|
722
|
+
throw new TypeError(`"${id}" is not a function`);
|
|
723
|
+
}
|
|
724
|
+
// Variable lookup.
|
|
725
|
+
// In not-evaluating mode return undefined instead of throwing ReferenceError,
|
|
726
|
+
// so that short-circuited branches do not cause spurious errors.
|
|
727
|
+
if (!this._evaluating)
|
|
728
|
+
return undefined;
|
|
729
|
+
if (!(id in this.context)) {
|
|
730
|
+
throw new ReferenceError(`${id} is not defined`);
|
|
731
|
+
}
|
|
732
|
+
return this.context[id];
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Handle `new ConstructorName(args)` expressions.
|
|
736
|
+
* Only safe constructors (Date, RegExp) are permitted.
|
|
737
|
+
*/
|
|
738
|
+
parseNewExpression() {
|
|
739
|
+
this.skipWhitespace();
|
|
740
|
+
const constructorName = this.parseIdentifierName();
|
|
741
|
+
this.skipWhitespace();
|
|
742
|
+
let args = [];
|
|
743
|
+
if (this.peek() === '(') {
|
|
744
|
+
this.pos++; // consume '('
|
|
745
|
+
args = this.parseArgList();
|
|
746
|
+
if (this.peek() !== ')') {
|
|
747
|
+
throw new SyntaxError(`Expected ")" after new ${constructorName}() at position ${this.pos}`);
|
|
748
|
+
}
|
|
749
|
+
this.pos++; // consume ')'
|
|
750
|
+
}
|
|
751
|
+
if (!this._evaluating)
|
|
752
|
+
return undefined;
|
|
753
|
+
switch (constructorName) {
|
|
754
|
+
case 'Date':
|
|
755
|
+
return new Date(...args);
|
|
756
|
+
case 'RegExp':
|
|
757
|
+
return new RegExp(args[0], args[1]);
|
|
758
|
+
default:
|
|
759
|
+
throw new TypeError(`new ${constructorName}() is not supported in expressions`);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Parse a single-param arrow function: `param => bodyExpression`
|
|
764
|
+
*
|
|
765
|
+
* The body is captured as a source substring (without evaluating it at
|
|
766
|
+
* parse time), so that the parameter is properly bound when the returned
|
|
767
|
+
* function is later invoked (e.g., inside `.filter()`, `.map()`, etc.).
|
|
768
|
+
*/
|
|
769
|
+
parseArrowFunction(param) {
|
|
770
|
+
this.pos += 2; // consume '=>'
|
|
771
|
+
this.skipWhitespace();
|
|
772
|
+
// Scan the source to find where the body expression ends (depth-0 comma
|
|
773
|
+
// or closing bracket/paren), without evaluating it.
|
|
774
|
+
const bodyStart = this.pos;
|
|
775
|
+
const bodyEnd = this.scanExpressionEnd();
|
|
776
|
+
const bodyStr = this.source.slice(bodyStart, bodyEnd).trim();
|
|
777
|
+
this.pos = bodyEnd;
|
|
778
|
+
// Capture the outer context by value so the returned function can use it.
|
|
779
|
+
const capturedContext = this.context;
|
|
780
|
+
return (arg) => {
|
|
781
|
+
const parser = new SafeExpressionParser();
|
|
782
|
+
return parser.evaluate(bodyStr, {
|
|
783
|
+
...capturedContext,
|
|
784
|
+
[param]: arg,
|
|
785
|
+
});
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Scan forward from the current position to find the end of a sub-expression
|
|
790
|
+
* without evaluating it. Stops when a depth-0 `,`, `)`, or `]` is found.
|
|
791
|
+
* Correctly skips over string literals and nested brackets.
|
|
792
|
+
*
|
|
793
|
+
* @returns The index just past the last character of the sub-expression.
|
|
794
|
+
*/
|
|
795
|
+
scanExpressionEnd() {
|
|
796
|
+
let i = this.pos;
|
|
797
|
+
let depth = 0;
|
|
798
|
+
let inString = false;
|
|
799
|
+
let stringChar = '';
|
|
800
|
+
while (i < this.source.length) {
|
|
801
|
+
const ch = this.source[i];
|
|
802
|
+
if (inString) {
|
|
803
|
+
if (ch === '\\') {
|
|
804
|
+
i += 2; // skip escaped character
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
if (ch === stringChar) {
|
|
808
|
+
inString = false;
|
|
809
|
+
}
|
|
810
|
+
i++;
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
if (ch === '"' || ch === "'") {
|
|
814
|
+
inString = true;
|
|
815
|
+
stringChar = ch;
|
|
816
|
+
i++;
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
if (ch === '(' || ch === '[') {
|
|
820
|
+
depth++;
|
|
821
|
+
}
|
|
822
|
+
else if (ch === ')' || ch === ']') {
|
|
823
|
+
if (depth === 0)
|
|
824
|
+
break; // end of this sub-expression
|
|
825
|
+
depth--;
|
|
826
|
+
}
|
|
827
|
+
else if (ch === ',' && depth === 0) {
|
|
828
|
+
break; // argument separator
|
|
829
|
+
}
|
|
830
|
+
i++;
|
|
831
|
+
}
|
|
832
|
+
return i;
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Parse a comma-separated argument list up to (but not including) the
|
|
836
|
+
* closing `)`.
|
|
837
|
+
*/
|
|
838
|
+
parseArgList() {
|
|
839
|
+
const args = [];
|
|
840
|
+
this.skipWhitespace();
|
|
841
|
+
while (this.peek() !== ')' && this.pos < this.source.length) {
|
|
842
|
+
args.push(this.parseTernary());
|
|
843
|
+
this.skipWhitespace();
|
|
844
|
+
if (this.peek() === ',') {
|
|
845
|
+
this.pos++;
|
|
846
|
+
this.skipWhitespace();
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
return args;
|
|
850
|
+
}
|
|
851
|
+
}
|