@openmrs/esm-expression-evaluator 10.0.1-pre.5420 → 10.0.1-pre.5434

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.
@@ -1,3 +1,3 @@
1
- [0] Successfully compiled: 5 files with swc (286.5ms)
1
+ [0] Successfully compiled: 5 files with swc (242.27ms)
2
2
  [0] swc --strip-leading-paths src -d dist exited with code 0
3
3
  [1] tsc --project tsconfig.build.json exited with code 0
package/README.md CHANGED
@@ -1,3 +1,27 @@
1
1
  # openmrs-esm-expression-evaluator
2
2
 
3
3
  openmrs-esm-expression-evaluator provides functions to allow evaluating user-defined expressions in a way safer than eval.
4
+
5
+ ## Writing null-safe expressions
6
+
7
+ Member access on `null` or `undefined` is an error, as it is in JavaScript, so an expression that runs
8
+ against data which may not have loaded yet needs a guard. `&&`, `||` and `??` short-circuit, and optional
9
+ chaining is supported, so any of these work:
10
+
11
+ ```js
12
+ session?.user ? session.user.privileges.some((p) => p.display === 'Some Privilege') : false;
13
+ session && session.user && session.user.privileges.length > 0;
14
+ ```
15
+
16
+ A short circuit abandons the rest of the chain rather than a single access, so `a?.b.c.d` is `undefined`
17
+ when `a` is nullish. A property that is genuinely missing is still an error: `a?.b.c` throws when `a` is
18
+ `{}`, because `a.b` is undefined for reasons optional chaining says nothing about.
19
+
20
+ ## What the sandbox guarantees
21
+
22
+ Expressions are interpreted from a jsep AST; they are never compiled or run by the JS engine. The
23
+ interpreter supports only the expression language, so there is no assignment, no statements, no object
24
+ literals, and no `this`. Inline arrow functions *are* supported, since callbacks like
25
+ `arr.find((v) => v === needle)` are a large part of what expressions are for; what is prohibited is
26
+ building a function from a string, which is what an escape to the `Function` constructor would give you.
27
+ An expression sees nothing but the `variables` it is handed and the small set of globals in `globals.ts`.
package/dist/evaluator.js CHANGED
@@ -305,11 +305,11 @@ function visitExpression(expression, context) {
305
305
  case 'ConditionalExpression':
306
306
  return visitConditionalExpression(expression, context);
307
307
  case 'CallExpression':
308
- return visitCallExpression(expression, context);
308
+ return unwrapShortCircuit(visitCallExpression(expression, context));
309
309
  case 'ArrowFunctionExpression':
310
310
  return visitArrowFunctionExpression(expression, context);
311
311
  case 'MemberExpression':
312
- return visitMemberExpression(expression, context);
312
+ return unwrapShortCircuit(visitMemberExpression(expression, context));
313
313
  case 'ArrayExpression':
314
314
  return visitArrayExpression(expression, context);
315
315
  case 'SequenceExpression':
@@ -356,6 +356,15 @@ function visitUnaryExpression(expression, context) {
356
356
  }
357
357
  }
358
358
  function visitBinaryExpression(expression, context) {
359
+ // these binary operators are meant to short-circuit
360
+ switch(expression.operator){
361
+ case '&&':
362
+ return visitExpression(expression.left, context) && visitExpression(expression.right, context);
363
+ case '||':
364
+ return visitExpression(expression.left, context) || visitExpression(expression.right, context);
365
+ case '??':
366
+ return visitExpression(expression.left, context) ?? visitExpression(expression.right, context);
367
+ }
359
368
  let left = visitExpression(expression.left, context);
360
369
  let right = visitExpression(expression.right, context);
361
370
  switch(expression.operator){
@@ -389,12 +398,6 @@ function visitBinaryExpression(expression, context) {
389
398
  return left <= right;
390
399
  case 'in':
391
400
  return left in right;
392
- case '&&':
393
- return left && right;
394
- case '||':
395
- return left || right;
396
- case '??':
397
- return left ?? right;
398
401
  default:
399
402
  throw `Expression evaluator does not support operator '${expression.operator}' operator`;
400
403
  }
@@ -404,8 +407,16 @@ function visitConditionalExpression(expression, context) {
404
407
  return test ? visitExpression(expression.consequent, context) : visitExpression(expression.alternate, context);
405
408
  }
406
409
  function visitCallExpression(expression, context) {
410
+ let callee = visitChainOperand(expression.callee, context);
411
+ if (callee === shortCircuit) {
412
+ return shortCircuit;
413
+ }
414
+ // `cb?.()` abandons the chain before its arguments are evaluated, so this has to come first; otherwise
415
+ // an argument's side effects happen for a call that never takes place
416
+ if (expression.optional && (callee === null || callee === undefined)) {
417
+ return shortCircuit;
418
+ }
407
419
  let args = expression.arguments?.map(handleNullableExpression(context));
408
- let callee = visitExpression(expression.callee, context);
409
420
  if (!callee) {
410
421
  throw `No function named ${getCallTargetName(expression.callee)} is defined in this context`;
411
422
  } else if (!(typeof callee === 'function')) {
@@ -437,10 +448,19 @@ function visitArrowFunctionExpression(expression, context) {
437
448
  return acc;
438
449
  }, []));
439
450
  return visitExpression(expression.body, context.addVariables(vars));
440
- }).bind(context.thisObj ?? null);
451
+ }).bind(null);
441
452
  }
442
453
  function visitMemberExpression(expression, context) {
443
- let obj = visitExpression(expression.object, context);
454
+ const obj = visitChainOperand(expression.object, context);
455
+ if (obj === shortCircuit) {
456
+ return shortCircuit;
457
+ }
458
+ if (expression.optional && (obj === null || obj === undefined)) {
459
+ return shortCircuit;
460
+ }
461
+ if (obj === null) {
462
+ throw TypeError(`TypeError: cannot read properties of null (reading '${describePropertyName(expression.property, context)}')`);
463
+ }
444
464
  if (obj === undefined) {
445
465
  switch(expression.object.type){
446
466
  case 'Identifier':
@@ -450,43 +470,21 @@ function visitMemberExpression(expression, context) {
450
470
  }
451
471
  case 'MemberExpression':
452
472
  {
453
- let propertyName = visitExpressionName(expression.property, context);
454
- throw TypeError(`TypeError: cannot read properties of undefined (reading '${propertyName}')`);
473
+ throw TypeError(`TypeError: cannot read properties of undefined (reading '${describePropertyName(expression.property, context)}')`);
455
474
  }
456
475
  default:
457
476
  throw `VisitMemberExpression does not support operator '${expression.object.type}' type`;
458
477
  }
459
478
  }
460
- let newObj = obj;
461
- if (typeof obj === 'string') {
462
- newObj = String.prototype;
463
- } else if (typeof obj === 'number') {
464
- newObj = Number.prototype;
465
- } else if (typeof obj === 'function') {
466
- // no-op
467
- } else if (typeof obj !== 'object') {
479
+ if (typeof obj !== 'object' && typeof obj !== 'function' && typeof obj !== 'string' && typeof obj !== 'number') {
468
480
  throw `VisitMemberExpression does not support member access on type ${typeof obj}`;
469
481
  }
470
- context.thisObj = newObj;
471
- let result;
472
- switch(expression.property.type){
473
- case 'Identifier':
474
- case 'MemberExpression':
475
- result = visitExpression(expression.property, context);
476
- break;
477
- default:
478
- {
479
- const property = visitExpression(expression.property, context);
480
- if (typeof property === 'undefined') {
481
- throw {
482
- type: 'Illegal property access',
483
- message: 'No property was supplied to the property access'
484
- };
485
- }
486
- validatePropertyName(property);
487
- result = obj[property];
488
- }
489
- }
482
+ // `a.b` names its property statically; `a[b]` evaluates it, so the result has to be turned into the
483
+ // exact key the indexer will use before it can be validated
484
+ const key = expression.computed ? toPropertyKey(visitExpression(expression.property, context)) : staticPropertyName(expression.property);
485
+ validatePropertyName(key);
486
+ validatePropertyAccess(obj, key);
487
+ const result = obj[key];
490
488
  if (typeof result === 'function') {
491
489
  return result.bind(obj);
492
490
  }
@@ -533,26 +531,15 @@ function visitTemplateElement(expression, context) {
533
531
  }
534
532
  function visitIdentifier(expression, context) {
535
533
  validatePropertyName(expression.name);
536
- // we support both `object` and `function` in the same way as technically property access on functions
537
- // is possible; the use-case here is to support JS's "static" functions like `Number.isInteger()`, which
538
- // is technically reading a property on a function
539
- const thisObj = context.thisObj;
540
- if (thisObj && (typeof thisObj === 'object' || typeof thisObj === 'function') && expression.name in thisObj) {
541
- const result = thisObj[expression.name];
542
- validatePropertyName(result);
543
- return result;
544
- } else if (context.variables && expression.name in context.variables) {
545
- const result = context.variables[expression.name];
546
- validatePropertyName(result);
547
- return result;
548
- } else if (expression.name in context.globals) {
534
+ if (context.variables && Object.hasOwn(context.variables, expression.name)) {
535
+ return context.variables[expression.name];
536
+ } else if (Object.hasOwn(context.globals, expression.name)) {
549
537
  return context.globals[expression.name];
550
538
  } else {
551
539
  return undefined;
552
540
  }
553
541
  }
554
542
  function visitLiteral(expression, context) {
555
- validatePropertyName(expression.value);
556
543
  return expression.value;
557
544
  }
558
545
  function createSynchronousContext(variables) {
@@ -563,7 +550,6 @@ function createAsynchronousContext(variables) {
563
550
  }
564
551
  function createContextInternal(variables, globals_) {
565
552
  const context = {
566
- thisObj: undefined,
567
553
  variables: {
568
554
  ...variables
569
555
  },
@@ -581,6 +567,30 @@ function createContextInternal(variables, globals_) {
581
567
  context.addVariables.bind(context);
582
568
  return context;
583
569
  }
570
+ /**
571
+ * Marks that an optional link (`a?.b`) found a nullish value. Optional chaining abandons the rest of the
572
+ * chain rather than just the one access, so this is returned in place of a value and passed along by each
573
+ * member access and call until it reaches the end of the chain, where {@link unwrapShortCircuit} turns it
574
+ * into `undefined`. It has to be distinguishable from a real `undefined`, since `a.b.c` where `a.b` is
575
+ * genuinely undefined is still an error.
576
+ */ const shortCircuit = Symbol('optional chain short-circuited');
577
+ /**
578
+ * Visits the object of a member access or the callee of a call, i.e. a link in a chain rather than the end
579
+ * of one. Dispatching directly keeps a short circuit intact, where {@link visitExpression} would have
580
+ * already flattened it to `undefined`.
581
+ */ function visitChainOperand(expression, context) {
582
+ switch(expression.type){
583
+ case 'MemberExpression':
584
+ return visitMemberExpression(expression, context);
585
+ case 'CallExpression':
586
+ return visitCallExpression(expression, context);
587
+ default:
588
+ return visitExpression(expression, context);
589
+ }
590
+ }
591
+ function unwrapShortCircuit(result) {
592
+ return result === shortCircuit ? undefined : result;
593
+ }
584
594
  // helper useful for handling arrays of expressions, since `null` expressions should not be
585
595
  // dispatched to `visitExpression()`
586
596
  function handleNullableExpression(context) {
@@ -599,6 +609,67 @@ function validatePropertyName(name) {
599
609
  };
600
610
  }
601
611
  }
612
+ /**
613
+ * Returns the name of a statically-accessed property, i.e. the `b` of `a.b`. jsep only ever parses an
614
+ * identifier here, so anything else means we were handed an AST we did not build.
615
+ */ function staticPropertyName(expression) {
616
+ if (expression.type !== 'Identifier') {
617
+ throw `VisitMemberExpression does not support a property of type '${expression.type}'`;
618
+ }
619
+ return expression.name;
620
+ }
621
+ /**
622
+ * Narrows the result of a computed property expression to the key that `obj[key]` would actually read.
623
+ * Anything else is refused rather than converted: JS applies ToPropertyKey to whatever it is given, so a
624
+ * value like `['const' + 'ructor']` reads `constructor` even though it compares equal to nothing.
625
+ */ function toPropertyKey(value) {
626
+ if (typeof value === 'undefined') {
627
+ throw {
628
+ type: 'Illegal property access',
629
+ message: 'No property was supplied to the property access'
630
+ };
631
+ }
632
+ if (typeof value !== 'string' && typeof value !== 'number') {
633
+ throw {
634
+ type: 'Illegal property access',
635
+ message: `Cannot use a value of type ${value === null ? 'null' : typeof value} as a property name`
636
+ };
637
+ }
638
+ return value;
639
+ }
640
+ /** Properties of `Object.prototype` that are harmless enough to leave reachable */ const safeBasePropertyNames = new Set([
641
+ 'toString',
642
+ 'valueOf',
643
+ 'hasOwnProperty'
644
+ ]);
645
+ /**
646
+ * Refuses any property that resolves on `Object.prototype` or `Function.prototype`.
647
+ */ function validatePropertyAccess(obj, key) {
648
+ if (safeBasePropertyNames.has(String(key))) {
649
+ return;
650
+ }
651
+ // primitives are boxed so that `hasOwn()` sees a string's `length` and indices
652
+ let holder = Object(obj);
653
+ while(holder !== null){
654
+ if (Object.hasOwn(holder, key)) {
655
+ if (holder === Object.prototype || holder === Function.prototype) {
656
+ throw {
657
+ type: 'Illegal property access',
658
+ message: `Cannot access the ${key} property of objects`
659
+ };
660
+ }
661
+ return;
662
+ }
663
+ holder = Object.getPrototypeOf(holder);
664
+ }
665
+ }
666
+ /** Best-effort name for a property, used only to build error messages */ function describePropertyName(expression, context) {
667
+ try {
668
+ return visitExpressionName(expression, context);
669
+ } catch {
670
+ return '<computed>';
671
+ }
672
+ }
602
673
  function isValidVariableType(val) {
603
674
  if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean' || typeof val === 'function' || val === null || val instanceof RegExp) {
604
675
  return true;
package/dist/extractor.js CHANGED
@@ -123,6 +123,11 @@ function visitArrowFunctionExpression(expression, context) {
123
123
  }
124
124
  function visitMemberExpression(expression, context) {
125
125
  visitExpression(expression.object, context);
126
+ // the `b` of `a[b]` is a variable the caller has to supply, whereas the `b` of `a.b` is a property name
127
+ if (expression.computed) {
128
+ visitExpression(expression.property, context);
129
+ return;
130
+ }
126
131
  const newContext = {
127
132
  ...context
128
133
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmrs/esm-expression-evaluator",
3
- "version": "10.0.1-pre.5420",
3
+ "version": "10.0.1-pre.5434",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Utilities for evaluating user-defined expressions",
6
6
  "type": "module",
@@ -88,6 +88,150 @@ describe('OpenMRS Expression Evaluator', () => {
88
88
  expect(() => evaluate('a[b]', { a: {}, b: 'prototype' })).toThrow(/Cannot access the prototype property .*/i);
89
89
  });
90
90
 
91
+ it('should not support constructor references', () => {
92
+ expect(() => evaluate('a.constructor', { a: {} })).toThrow(/Cannot access the constructor property .*/i);
93
+ expect(() => evaluate('a["constructor"]', { a: {} })).toThrow(/Cannot access the constructor property .*/i);
94
+ expect(() => evaluate("a['const' + 'ructor']", { a: {} })).toThrow(/Cannot access the constructor property .*/i);
95
+ expect(() => evaluate('a[b]', { a: {}, b: 'constructor' })).toThrow(/Cannot access the constructor property .*/i);
96
+ expect(() => evaluate('String.constructor')).toThrow(/Cannot access the constructor property .*/i);
97
+ });
98
+
99
+ // A computed property is coerced by ToPropertyKey, so a non-string key whose string conversion is
100
+ // `constructor` used to read `constructor` while comparing equal to nothing the guard knew about.
101
+ it('should not allow a non-string computed property to name a property', () => {
102
+ expect(() => evaluate("String[['const' + 'ructor']]('return 1')()")).toThrow(
103
+ /Cannot use a value of type object as a property name/i,
104
+ );
105
+ expect(() => evaluate("(() => 1)[['const' + 'ructor']]('return 1')()")).toThrow(
106
+ /Cannot use a value of type object as a property name/i,
107
+ );
108
+ expect(() => evaluate("String['con'.concat('structor').split('|')]")).toThrow(
109
+ /Cannot use a value of type object as a property name/i,
110
+ );
111
+ expect(() => evaluate("String[Array.of('const' + 'ructor')]")).toThrow(
112
+ /Cannot use a value of type object as a property name/i,
113
+ );
114
+ expect(() => evaluate("a[[['__pro' + 'to__']]]", { a: {} })).toThrow(
115
+ /Cannot use a value of type object as a property name/i,
116
+ );
117
+ expect(() => evaluate('a[b]', { a: {}, b: true })).toThrow(
118
+ /Cannot use a value of type boolean as a property name/i,
119
+ );
120
+ expect(() => evaluate('a[b]', { a: {}, b: null })).toThrow(/Cannot use a value of type null as a property name/i);
121
+ });
122
+
123
+ // `Object.prototype` and `Function.prototype` hold every generic route back to the host realm, so no
124
+ // property that resolves on either of them should be readable, however the key is spelled.
125
+ it('should not expose Object.prototype or Function.prototype members', () => {
126
+ const fn = () => 1;
127
+ expect(() => evaluate("f['ca' + 'll']", { f: fn })).toThrow(/Cannot access the call property .*/i);
128
+ expect(() => evaluate('f.apply', { f: fn })).toThrow(/Cannot access the apply property .*/i);
129
+ expect(() => evaluate('f.bind', { f: fn })).toThrow(/Cannot access the bind property .*/i);
130
+ expect(() => evaluate("a['__define' + 'Getter__']", { a: {} })).toThrow(
131
+ /Cannot access the __defineGetter__ property .*/i,
132
+ );
133
+ expect(() => evaluate("a['__lookup' + 'Getter__']", { a: {} })).toThrow(
134
+ /Cannot access the __lookupGetter__ property .*/i,
135
+ );
136
+ expect(() => evaluate('a.isPrototypeOf', { a: {} })).toThrow(/Cannot access the isPrototypeOf property .*/i);
137
+ // identifiers must not reach them through the variables or globals maps either
138
+ expect(evaluate('toString')).toBeUndefined();
139
+ expect(evaluate('hasOwnProperty')).toBeUndefined();
140
+ });
141
+
142
+ it('should still allow harmless base properties and builtins on their own prototypes', () => {
143
+ expect(evaluate('a.toString()', { a: {} })).toBe('[object Object]');
144
+ expect(evaluate('a.valueOf()', { a: 1 })).toBe(1);
145
+ expect(evaluate('"abc".toUpperCase()')).toBe('ABC');
146
+ expect(evaluate('a.length', { a: [1, 2, 3] })).toBe(3);
147
+ expect(evaluate('f.name', { f: function named() {} })).toBe('named');
148
+ });
149
+
150
+ // `evaluateAsync()` shares the visitor, so every guard has to hold there too
151
+ it('should apply the same guards asynchronously', async () => {
152
+ await expect(evaluateAsync("String[['const' + 'ructor']]('return 1')()")).rejects.toThrow(
153
+ /Cannot use a value of type object as a property name/i,
154
+ );
155
+ await expect(evaluateAsync('a.constructor', { a: {} })).rejects.toThrow(
156
+ /Cannot access the constructor property .*/i,
157
+ );
158
+ await expect(evaluateAsync('a.__proto__', { a: {} })).rejects.toThrow(/Cannot access the __proto__ property .*/i);
159
+ await expect(evaluateAsync("f['ca' + 'll']", { f: () => 1 })).rejects.toThrow(
160
+ /Cannot access the call property .*/i,
161
+ );
162
+ });
163
+
164
+ it('should support computed property access', () => {
165
+ expect(evaluate('arr[i]', { arr: [10, 20, 30], i: 1 })).toBe(20);
166
+ expect(evaluate('o[k]', { o: { x: 5 }, k: 'x' })).toBe(5);
167
+ expect(evaluate('o[k.n]', { o: { x: 5 }, k: { n: 'x' } })).toBe(5);
168
+ expect(evaluate('o[k()]', { o: { x: 5 }, k: () => 'x' })).toBe(5);
169
+ });
170
+
171
+ it('should not resolve a missing property to a same-named variable', () => {
172
+ expect(evaluate('a.b', { a: {}, b: 5 })).toBeUndefined();
173
+ });
174
+
175
+ it('should reject member access on null', () => {
176
+ expect(() => evaluate('a.b', { a: null })).toThrow("TypeError: cannot read properties of null (reading 'b')");
177
+ expect(() => evaluate('a.b.c', { a: { b: null } })).toThrow(
178
+ "TypeError: cannot read properties of null (reading 'c')",
179
+ );
180
+ });
181
+
182
+ it('should short-circuit logical operators', () => {
183
+ const right = vi.fn(() => true);
184
+
185
+ expect(evaluate('a && right()', { a: false, right })).toBe(false);
186
+ expect(evaluate('a || right()', { a: true, right })).toBe(true);
187
+ expect(evaluate('a ?? right()', { a: 'set', right })).toBe('set');
188
+ expect(right).not.toHaveBeenCalled();
189
+
190
+ expect(evaluate('a && right()', { a: true, right })).toBe(true);
191
+ expect(right).toHaveBeenCalledTimes(1);
192
+
193
+ // the reason it matters: guarding a member access against a nullish value
194
+ expect(evaluate('a && a.b', { a: null })).toBeNull();
195
+ expect(evaluate('a ? a.b : false', { a: null })).toBe(false);
196
+ });
197
+
198
+ it('should support optional chaining', () => {
199
+ expect(evaluate('a?.b', { a: null })).toBeUndefined();
200
+ expect(evaluate('a?.b', { a: undefined })).toBeUndefined();
201
+ expect(evaluate('a?.b', { a: { b: 'value' } })).toBe('value');
202
+ expect(evaluate('a?.[k]', { a: null, k: 'b' })).toBeUndefined();
203
+
204
+ // a short circuit abandons the rest of the chain, not just the one access
205
+ expect(evaluate('a?.b.c.d', { a: null })).toBeUndefined();
206
+ expect(evaluate('a?.b()', { a: null })).toBeUndefined();
207
+ expect(evaluate('a?.b.c()', { a: null })).toBeUndefined();
208
+
209
+ // but a genuinely missing property is still an error
210
+ expect(() => evaluate('a?.b.c', { a: {} })).toThrow("TypeError: cannot read properties of undefined (reading 'c')");
211
+ });
212
+
213
+ it('should support optional calls', () => {
214
+ const arg = vi.fn(() => 1);
215
+
216
+ expect(evaluate('cb?.(arg())', { cb: null, arg })).toBeUndefined();
217
+ expect(evaluate('o.cb?.(arg())', { o: {}, arg })).toBeUndefined();
218
+ expect(evaluate('cb?.(arg()).b.c', { cb: undefined, arg })).toBeUndefined();
219
+
220
+ // a call that does not happen must not evaluate its arguments
221
+ expect(arg).not.toHaveBeenCalled();
222
+
223
+ expect(evaluate('cb?.(arg())', { cb: (n: number) => n + 1, arg })).toBe(2);
224
+ expect(arg).toHaveBeenCalledTimes(1);
225
+
226
+ // `?.()` only guards against a nullish callee; a non-callable value is still an error
227
+ expect(() => evaluate('cb?.()', { cb: { notCallable: true } })).toThrow('cb is not a function');
228
+ });
229
+
230
+ it('should allow values that share a name with a forbidden property', () => {
231
+ expect(evaluate('a', { a: 'constructor' })).toBe('constructor');
232
+ expect(evaluate("a === 'const' + 'ructor'", { a: 'constructor' })).toBe(true);
233
+ });
234
+
91
235
  it('should support ternaries', () => {
92
236
  expect(evaluate('a ? 1 : 2', { a: true })).toBe(1);
93
237
  expect(evaluate('a ? 1 : 2', { a: false })).toBe(2);
package/src/evaluator.ts CHANGED
@@ -371,11 +371,11 @@ function visitExpression(expression: jsep.Expression, context: EvaluationContext
371
371
  case 'ConditionalExpression':
372
372
  return visitConditionalExpression(expression as jsep.ConditionalExpression, context);
373
373
  case 'CallExpression':
374
- return visitCallExpression(expression as jsep.CallExpression, context);
374
+ return unwrapShortCircuit(visitCallExpression(expression as jsep.CallExpression, context));
375
375
  case 'ArrowFunctionExpression':
376
376
  return visitArrowFunctionExpression(expression as ArrowExpression, context);
377
377
  case 'MemberExpression':
378
- return visitMemberExpression(expression as jsep.MemberExpression, context);
378
+ return unwrapShortCircuit(visitMemberExpression(expression as jsep.MemberExpression, context));
379
379
  case 'ArrayExpression':
380
380
  return visitArrayExpression(expression as jsep.ArrayExpression, context);
381
381
  case 'SequenceExpression':
@@ -426,6 +426,16 @@ function visitUnaryExpression(expression: jsep.UnaryExpression, context: Evaluat
426
426
  }
427
427
 
428
428
  function visitBinaryExpression(expression: jsep.BinaryExpression, context: EvaluationContext) {
429
+ // these binary operators are meant to short-circuit
430
+ switch (expression.operator) {
431
+ case '&&':
432
+ return visitExpression(expression.left, context) && visitExpression(expression.right, context);
433
+ case '||':
434
+ return visitExpression(expression.left, context) || visitExpression(expression.right, context);
435
+ case '??':
436
+ return visitExpression(expression.left, context) ?? visitExpression(expression.right, context);
437
+ }
438
+
429
439
  let left = visitExpression(expression.left, context);
430
440
  let right = visitExpression(expression.right, context);
431
441
 
@@ -460,12 +470,6 @@ function visitBinaryExpression(expression: jsep.BinaryExpression, context: Evalu
460
470
  return left <= right;
461
471
  case 'in':
462
472
  return left in right;
463
- case '&&':
464
- return left && right;
465
- case '||':
466
- return left || right;
467
- case '??':
468
- return left ?? right;
469
473
  default:
470
474
  throw `Expression evaluator does not support operator '${expression.operator}' operator`;
471
475
  }
@@ -477,8 +481,19 @@ function visitConditionalExpression(expression: jsep.ConditionalExpression, cont
477
481
  }
478
482
 
479
483
  function visitCallExpression(expression: jsep.CallExpression, context: EvaluationContext) {
484
+ let callee = visitChainOperand(expression.callee, context);
485
+
486
+ if (callee === shortCircuit) {
487
+ return shortCircuit;
488
+ }
489
+
490
+ // `cb?.()` abandons the chain before its arguments are evaluated, so this has to come first; otherwise
491
+ // an argument's side effects happen for a call that never takes place
492
+ if (expression.optional && (callee === null || callee === undefined)) {
493
+ return shortCircuit;
494
+ }
495
+
480
496
  let args = expression.arguments?.map(handleNullableExpression(context));
481
- let callee = visitExpression(expression.callee, context);
482
497
 
483
498
  if (!callee) {
484
499
  throw `No function named ${getCallTargetName(expression.callee)} is defined in this context`;
@@ -516,11 +531,25 @@ function visitArrowFunctionExpression(expression: ArrowExpression, context: Eval
516
531
  );
517
532
 
518
533
  return visitExpression(expression.body, context.addVariables(vars));
519
- }.bind(context.thisObj ?? null);
534
+ }.bind(null);
520
535
  }
521
536
 
522
537
  function visitMemberExpression(expression: jsep.MemberExpression, context: EvaluationContext) {
523
- let obj = visitExpression(expression.object, context);
538
+ const obj = visitChainOperand(expression.object, context);
539
+
540
+ if (obj === shortCircuit) {
541
+ return shortCircuit;
542
+ }
543
+
544
+ if (expression.optional && (obj === null || obj === undefined)) {
545
+ return shortCircuit;
546
+ }
547
+
548
+ if (obj === null) {
549
+ throw TypeError(
550
+ `TypeError: cannot read properties of null (reading '${describePropertyName(expression.property, context)}')`,
551
+ );
552
+ }
524
553
 
525
554
  if (obj === undefined) {
526
555
  switch (expression.object.type) {
@@ -529,42 +558,32 @@ function visitMemberExpression(expression: jsep.MemberExpression, context: Evalu
529
558
  throw ReferenceError(`ReferenceError: ${objectName} is not defined`);
530
559
  }
531
560
  case 'MemberExpression': {
532
- let propertyName = visitExpressionName(expression.property, context);
533
- throw TypeError(`TypeError: cannot read properties of undefined (reading '${propertyName}')`);
561
+ throw TypeError(
562
+ `TypeError: cannot read properties of undefined (reading '${describePropertyName(
563
+ expression.property,
564
+ context,
565
+ )}')`,
566
+ );
534
567
  }
535
568
  default:
536
569
  throw `VisitMemberExpression does not support operator '${expression.object.type}' type`;
537
570
  }
538
571
  }
539
572
 
540
- let newObj = obj;
541
- if (typeof obj === 'string') {
542
- newObj = String.prototype;
543
- } else if (typeof obj === 'number') {
544
- newObj = Number.prototype;
545
- } else if (typeof obj === 'function') {
546
- // no-op
547
- } else if (typeof obj !== 'object') {
573
+ if (typeof obj !== 'object' && typeof obj !== 'function' && typeof obj !== 'string' && typeof obj !== 'number') {
548
574
  throw `VisitMemberExpression does not support member access on type ${typeof obj}`;
549
575
  }
550
576
 
551
- context.thisObj = newObj;
577
+ // `a.b` names its property statically; `a[b]` evaluates it, so the result has to be turned into the
578
+ // exact key the indexer will use before it can be validated
579
+ const key = expression.computed
580
+ ? toPropertyKey(visitExpression(expression.property, context))
581
+ : staticPropertyName(expression.property);
552
582
 
553
- let result: unknown;
554
- switch (expression.property.type) {
555
- case 'Identifier':
556
- case 'MemberExpression':
557
- result = visitExpression(expression.property, context);
558
- break;
559
- default: {
560
- const property = visitExpression(expression.property, context);
561
- if (typeof property === 'undefined') {
562
- throw { type: 'Illegal property access', message: 'No property was supplied to the property access' };
563
- }
564
- validatePropertyName(property);
565
- result = obj[property];
566
- }
567
- }
583
+ validatePropertyName(key);
584
+ validatePropertyAccess(obj, key);
585
+
586
+ const result = obj[key];
568
587
 
569
588
  if (typeof result === 'function') {
570
589
  return result.bind(obj);
@@ -629,19 +648,9 @@ function visitTemplateElement(expression: TemplateElement, context: EvaluationCo
629
648
  function visitIdentifier(expression: jsep.Identifier, context: EvaluationContext) {
630
649
  validatePropertyName(expression.name);
631
650
 
632
- // we support both `object` and `function` in the same way as technically property access on functions
633
- // is possible; the use-case here is to support JS's "static" functions like `Number.isInteger()`, which
634
- // is technically reading a property on a function
635
- const thisObj = context.thisObj;
636
- if (thisObj && (typeof thisObj === 'object' || typeof thisObj === 'function') && expression.name in thisObj) {
637
- const result = thisObj[expression.name];
638
- validatePropertyName(result);
639
- return result;
640
- } else if (context.variables && expression.name in context.variables) {
641
- const result = context.variables[expression.name];
642
- validatePropertyName(result);
643
- return result;
644
- } else if (expression.name in context.globals) {
651
+ if (context.variables && Object.hasOwn(context.variables, expression.name)) {
652
+ return context.variables[expression.name];
653
+ } else if (Object.hasOwn(context.globals, expression.name)) {
645
654
  return context.globals[expression.name];
646
655
  } else {
647
656
  return undefined;
@@ -649,14 +658,12 @@ function visitIdentifier(expression: jsep.Identifier, context: EvaluationContext
649
658
  }
650
659
 
651
660
  function visitLiteral(expression: jsep.Literal, context: EvaluationContext) {
652
- validatePropertyName(expression.value);
653
661
  return expression.value;
654
662
  }
655
663
 
656
664
  // Internal helpers and utilities
657
665
 
658
666
  interface EvaluationContext {
659
- thisObj: object | undefined;
660
667
  variables: VariablesMap;
661
668
  globals: typeof globals | typeof globalsAsync;
662
669
  addVariables(vars: VariablesMap): EvaluationContext;
@@ -672,7 +679,6 @@ function createAsynchronousContext(variables: VariablesMap): EvaluationContext {
672
679
 
673
680
  function createContextInternal(variables: VariablesMap, globals_: typeof globals | typeof globalsAsync) {
674
681
  const context = {
675
- thisObj: undefined,
676
682
  variables: { ...variables },
677
683
  globals: { ...globals_ },
678
684
  addVariables(vars: VariablesMap) {
@@ -686,6 +692,35 @@ function createContextInternal(variables: VariablesMap, globals_: typeof globals
686
692
  return context;
687
693
  }
688
694
 
695
+ /**
696
+ * Marks that an optional link (`a?.b`) found a nullish value. Optional chaining abandons the rest of the
697
+ * chain rather than just the one access, so this is returned in place of a value and passed along by each
698
+ * member access and call until it reaches the end of the chain, where {@link unwrapShortCircuit} turns it
699
+ * into `undefined`. It has to be distinguishable from a real `undefined`, since `a.b.c` where `a.b` is
700
+ * genuinely undefined is still an error.
701
+ */
702
+ const shortCircuit = Symbol('optional chain short-circuited');
703
+
704
+ /**
705
+ * Visits the object of a member access or the callee of a call, i.e. a link in a chain rather than the end
706
+ * of one. Dispatching directly keeps a short circuit intact, where {@link visitExpression} would have
707
+ * already flattened it to `undefined`.
708
+ */
709
+ function visitChainOperand(expression: jsep.Expression, context: EvaluationContext) {
710
+ switch (expression.type) {
711
+ case 'MemberExpression':
712
+ return visitMemberExpression(expression as jsep.MemberExpression, context);
713
+ case 'CallExpression':
714
+ return visitCallExpression(expression as jsep.CallExpression, context);
715
+ default:
716
+ return visitExpression(expression, context);
717
+ }
718
+ }
719
+
720
+ function unwrapShortCircuit(result: unknown) {
721
+ return result === shortCircuit ? undefined : result;
722
+ }
723
+
689
724
  // helper useful for handling arrays of expressions, since `null` expressions should not be
690
725
  // dispatched to `visitExpression()`
691
726
  function handleNullableExpression(context: EvaluationContext) {
@@ -704,6 +739,73 @@ function validatePropertyName(name: unknown) {
704
739
  }
705
740
  }
706
741
 
742
+ /**
743
+ * Returns the name of a statically-accessed property, i.e. the `b` of `a.b`. jsep only ever parses an
744
+ * identifier here, so anything else means we were handed an AST we did not build.
745
+ */
746
+ function staticPropertyName(expression: jsep.Expression) {
747
+ if (expression.type !== 'Identifier') {
748
+ throw `VisitMemberExpression does not support a property of type '${expression.type}'`;
749
+ }
750
+
751
+ return (expression as jsep.Identifier).name;
752
+ }
753
+
754
+ /**
755
+ * Narrows the result of a computed property expression to the key that `obj[key]` would actually read.
756
+ * Anything else is refused rather than converted: JS applies ToPropertyKey to whatever it is given, so a
757
+ * value like `['const' + 'ructor']` reads `constructor` even though it compares equal to nothing.
758
+ */
759
+ function toPropertyKey(value: unknown): string | number {
760
+ if (typeof value === 'undefined') {
761
+ throw { type: 'Illegal property access', message: 'No property was supplied to the property access' };
762
+ }
763
+
764
+ if (typeof value !== 'string' && typeof value !== 'number') {
765
+ throw {
766
+ type: 'Illegal property access',
767
+ message: `Cannot use a value of type ${value === null ? 'null' : typeof value} as a property name`,
768
+ };
769
+ }
770
+
771
+ return value;
772
+ }
773
+
774
+ /** Properties of `Object.prototype` that are harmless enough to leave reachable */
775
+ const safeBasePropertyNames = new Set(['toString', 'valueOf', 'hasOwnProperty']);
776
+
777
+ /**
778
+ * Refuses any property that resolves on `Object.prototype` or `Function.prototype`.
779
+ */
780
+ function validatePropertyAccess(obj: object | string | number, key: string | number) {
781
+ if (safeBasePropertyNames.has(String(key))) {
782
+ return;
783
+ }
784
+
785
+ // primitives are boxed so that `hasOwn()` sees a string's `length` and indices
786
+ let holder: object | null = Object(obj);
787
+ while (holder !== null) {
788
+ if (Object.hasOwn(holder, key)) {
789
+ if (holder === Object.prototype || holder === Function.prototype) {
790
+ throw { type: 'Illegal property access', message: `Cannot access the ${key} property of objects` };
791
+ }
792
+
793
+ return;
794
+ }
795
+
796
+ holder = Object.getPrototypeOf(holder);
797
+ }
798
+ }
799
+
800
+ /** Best-effort name for a property, used only to build error messages */
801
+ function describePropertyName(expression: jsep.Expression, context: EvaluationContext) {
802
+ try {
803
+ return visitExpressionName(expression, context);
804
+ } catch {
805
+ return '<computed>';
806
+ }
807
+ }
808
+
707
809
  function isValidVariableType(val: unknown): val is VariablesMap['a'] {
708
810
  if (
709
811
  typeof val === 'string' ||
@@ -28,6 +28,12 @@ describe('OpenMRS Expression Extractor', () => {
28
28
  expect(extractVariableNames('`${a.b}`')).toEqual(['a']);
29
29
  });
30
30
 
31
+ it('reports computed property names as variables', () => {
32
+ expect(extractVariableNames('o[k]')).toEqual(['o', 'k']);
33
+ expect(extractVariableNames('o["x"]')).toEqual(['o']);
34
+ expect(extractVariableNames('o.x')).toEqual(['o']);
35
+ });
36
+
31
37
  it('supports RegExp', () => {
32
38
  expect(extractVariableNames('/.*/.test(a)')).toEqual(['a']);
33
39
  });
package/src/extractor.ts CHANGED
@@ -129,6 +129,13 @@ function visitArrowFunctionExpression(expression: ArrowExpression, context: Eval
129
129
 
130
130
  function visitMemberExpression(expression: jsep.MemberExpression, context: EvaluationContext) {
131
131
  visitExpression(expression.object, context);
132
+
133
+ // the `b` of `a[b]` is a variable the caller has to supply, whereas the `b` of `a.b` is a property name
134
+ if (expression.computed) {
135
+ visitExpression(expression.property, context);
136
+ return;
137
+ }
138
+
132
139
  const newContext = { ...context };
133
140
  newContext.isLocalExpression = true;
134
141
  visitExpression(expression.property, newContext);