micra.js 2.3.1 → 2.4.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/src/utils/expr.ts CHANGED
@@ -1,130 +1,213 @@
1
1
  /**
2
- * src/utils/expr.ts — JS expression evaluator.
2
+ * src/utils/expr.ts — CSP-safe JS-expression evaluator.
3
3
  *
4
- * Responsibilities:
5
- * - Compile expression strings into cached functions
6
- * - Evaluate them against a state object
7
- * - Fast-path for simple property lookups
8
- * - Shadow non-state identifiers so directive expressions cannot reach
9
- * globals like `window`, `fetch`, `constructor`, etc. A small whitelist
10
- * of utility globals (Math, JSON, Date, ...) remains accessible.
4
+ * Directive expressions (`data-text="count > 0"`, `data-class="x:a === b"`, …)
5
+ * are parsed into a small AST and walked by an interpreter. There is NO
6
+ * `new Function` / `eval` anywhere — so Micra runs under a strict
7
+ * Content-Security-Policy (`default-src 'self'`, no `unsafe-eval`).
11
8
  *
12
9
  * LLM NOTE: This module is PURE. It does not touch the DOM or mutate state.
13
10
  *
14
11
  * Security model:
15
- * Directive expressions are JavaScript they are compiled via `new Function`
16
- * and run with full JS capability except that bare identifiers must resolve
17
- * to either a state key, a component instance method, or one of
18
- * ALLOWED_GLOBALS. This blocks the `constructor.constructor("...")()` chain
19
- * and accidental access to `window` / `document` / `fetch`. It does NOT
20
- * sandbox method calls if a component method itself touches `window`,
21
- * that still works. Treat directive templates as trusted code regardless.
12
+ * The interpreter can only reach: top-level state keys, component methods,
13
+ * and a whitelist of utility globals (Math, JSON, Date, …). A bare
14
+ * identifier that is none of those resolves to `undefined` `window`,
15
+ * `document`, `fetch`, `eval`, `constructor` are unreachable *by
16
+ * construction* (there is no scope that contains them), not by shadowing.
17
+ * Member access additionally refuses the prototype-escape property names
18
+ * (`__proto__`, `constructor`, `prototype`), closing the
19
+ * `item.constructor.constructor("…")()` chain that the old `with()`-based
20
+ * evaluator left open. Method calls still run real JS — if a component
21
+ * method touches `window`, that works; treat directive templates as
22
+ * trusted code regardless.
23
+ *
24
+ * Grammar (precedence low→high):
25
+ * ternary ?: | || | && | == != === !== | < <= > >= | + - |
26
+ * * / % | unary ! - | call() / member. | primary
27
+ * primary = number | string | true | false | null | undefined |
28
+ * identifier | ( expr )
22
29
  */
23
30
 
24
31
  import type { StateRecord } from '../types'
25
32
 
26
- // ── Expression cache ──────────────────────────────────────────────────────────
27
- // Compiled functions are keyed by expression string — Function() is only called
28
- // once per unique expression across the entire app lifetime.
33
+ // ── Whitelisted globals ─────────────────────────────────────────────────────
34
+ const ALLOWED_GLOBALS = new Set<string>(
35
+ 'Math,JSON,Date,String,Number,Boolean,Array,Object,parseInt,parseFloat,isNaN,isFinite,NaN,Infinity,undefined'.split(','),
36
+ )
29
37
 
30
- // LLM NOTE: exprCache is module-level (shared across all components).
31
- // This is intentional most apps reuse the same expressions.
38
+ // Property names that would let an expression climb back to the Function
39
+ // constructor / prototype. Blocked on every member access.
40
+ const BLOCKED_PROPS = new Set<string>(['__proto__', 'constructor', 'prototype'])
32
41
 
33
- // Compiled fn for complex expressions; pre-split parts for simple dot-paths.
34
- // Storing parts once avoids the SIMPLE_PATH regex test + split on every evalExpr call.
35
- type CompiledFn = (state: object, safe: object) => unknown
36
- type CachedEntry =
37
- | { kind: 'fn'; fn: CompiledFn }
38
- | { kind: 'path'; parts: string[] }
42
+ /** @internal Names that live on Object.prototype (constructor, toString, …). */
43
+ const OBJ_PROTO_KEYS = new Set<string>(Object.getOwnPropertyNames(Object.prototype))
39
44
 
40
- const exprCache = new Map<string, CachedEntry>()
41
- // Expressions whose runtime error we have already warned about. Prevents log spam
42
- // when the same `data-text="item.naame"` typo fires every render.
43
- const warnedRuntime = new Set<string>()
45
+ // ── AST ─────────────────────────────────────────────────────────────────────
46
+ // Compact node shapes; `k` is the kind tag.
47
+ type Node =
48
+ | { k: 'lit'; v: unknown }
49
+ | { k: 'id'; n: string }
50
+ | { k: 'mem'; o: Node; p: string }
51
+ | { k: 'call'; c: Node; a: Node[] }
52
+ | { k: 'un'; op: string; x: Node }
53
+ | { k: 'bin'; op: string; l: Node; r: Node }
54
+ | { k: 'tern'; c: Node; a: Node; b: Node }
44
55
 
45
- // Simple identifier or dot-path: "count", "user.name", "item.email"
46
- // Matches: letter/$/_ followed by word chars, optionally with .property chains
47
- const SIMPLE_PATH = /^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/
56
+ // ── Tokenizer ────────────────────────────────────────────────────────────────
57
+ type Tok = { t: 'num' | 'str' | 'id' | 'p'; v: string }
48
58
 
49
- // ── Safe scope ────────────────────────────────────────────────────────────────
59
+ const PUNCT = [
60
+ '===', '!==', '==', '!=', '<=', '>=', '&&', '||',
61
+ '(', ')', '.', ',', '?', ':', '!', '<', '>', '+', '-', '*', '/', '%',
62
+ ]
50
63
 
51
- /**
52
- * Globals reachable from directive expressions. Anything else (window, fetch,
53
- * constructor, eval, ...) is shadowed by SAFE_OUTER and resolves to undefined.
54
- */
55
- const ALLOWED_GLOBALS = new Set<string>([
56
- 'Math', 'JSON', 'Date', 'String', 'Number', 'Boolean', 'Array', 'Object',
57
- 'parseInt', 'parseFloat', 'isNaN', 'isFinite', 'NaN', 'Infinity', 'undefined',
58
- ])
64
+ function tokenize(src: string): Tok[] {
65
+ const toks: Tok[] = []
66
+ let i = 0
67
+ const n = src.length
68
+ while (i < n) {
69
+ const c = src[i]!
70
+ if (c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f') { i++; continue }
71
+ // string
72
+ if (c === '"' || c === "'") {
73
+ let s = ''
74
+ i++
75
+ while (i < n && src[i] !== c) {
76
+ if (src[i] === '\\') { s += src[i + 1] ?? ''; i += 2 }
77
+ else { s += src[i]; i++ }
78
+ }
79
+ if (src[i] !== c) throw 0 // unterminated
80
+ i++
81
+ toks.push({ t: 'str', v: s })
82
+ continue
83
+ }
84
+ // number
85
+ if (c >= '0' && c <= '9') {
86
+ let s = ''
87
+ while (i < n && ((src[i]! >= '0' && src[i]! <= '9') || src[i] === '.')) { s += src[i]; i++ }
88
+ toks.push({ t: 'num', v: s })
89
+ continue
90
+ }
91
+ // identifier
92
+ if (/[A-Za-z_$]/.test(c)) {
93
+ let s = ''
94
+ while (i < n && /[A-Za-z0-9_$]/.test(src[i]!)) { s += src[i]; i++ }
95
+ toks.push({ t: 'id', v: s })
96
+ continue
97
+ }
98
+ // punctuator (longest match first)
99
+ const m = PUNCT.find(p => src.startsWith(p, i))
100
+ if (!m) throw 0 // unknown char
101
+ toks.push({ t: 'p', v: m })
102
+ i += m.length
103
+ }
104
+ return toks
105
+ }
59
106
 
60
- /**
61
- * Outer `with()` scope. Its `has` trap claims every non-whitelisted identifier
62
- * is "in scope" so the JS engine resolves the read on this Proxy (which returns
63
- * undefined) instead of walking up to the global object. Whitelisted names fall
64
- * through to globalThis.
65
- */
66
- // Sentinel parameter names used by the compiled function. SAFE_OUTER must NOT
67
- // shadow them, or `with($s)` would resolve to `undefined` via SAFE_OUTER.
68
- const PARAM_S = '$s'
69
- const PARAM_SAFE = '$safe'
70
-
71
- const SAFE_OUTER: object = new Proxy(Object.create(null) as object, {
72
- has(_target, key): boolean {
73
- if (typeof key !== 'string') return false
74
- if (key === PARAM_S || key === PARAM_SAFE) return false
75
- return !ALLOWED_GLOBALS.has(key)
76
- },
77
- get(): undefined {
78
- return undefined
79
- },
80
- })
107
+ // ── Parser (Pratt) ────────────────────────────────────────────────────────────
108
+ const BIN_PREC: Record<string, number> = {
109
+ '||': 1, '&&': 2,
110
+ '==': 3, '!=': 3, '===': 3, '!==': 3,
111
+ '<': 4, '<=': 4, '>': 4, '>=': 4,
112
+ '+': 5, '-': 5,
113
+ '*': 6, '/': 6, '%': 6,
114
+ }
81
115
 
82
- /**
83
- * @internal Per-state safe wrappers — one per source state object. WeakMap so
84
- * short-lived itemStates get GC'd with their wrappers.
85
- */
86
- const safeWrapCache = new WeakMap<object, object>()
116
+ function parse(toks: Tok[]): Node {
117
+ let pos = 0
118
+ const peek = () => toks[pos]
119
+ const next = () => toks[pos++]
120
+ const eat = (v: string) => { if (peek()?.v !== v) throw 0; pos++ }
87
121
 
88
- /**
89
- * @internal Pre-computed names that live on `Object.prototype`
90
- * (constructor, toString, hasOwnProperty, ...). Used by safeStateHas to detect
91
- * built-in keys without re-walking the chain on every call.
92
- */
93
- const OBJ_PROTO_KEYS = new Set<string>(Object.getOwnPropertyNames(Object.prototype))
122
+ function parseExpr(): Node {
123
+ const c = parseBin(1)
124
+ if (peek()?.v === '?') {
125
+ next()
126
+ const a = parseExpr()
127
+ eat(':')
128
+ const b = parseExpr()
129
+ return { k: 'tern', c, a, b }
130
+ }
131
+ return c
132
+ }
94
133
 
95
- /**
96
- * Wrap a state object so its `has` trap reports only "real" keys — own
97
- * properties or keys reachable up to (but not including) `Object.prototype`.
98
- * This blocks `'constructor' in state` from leaking the prototype.
99
- */
100
- function safeStateWrap(state: object): object {
101
- const cached = safeWrapCache.get(state)
102
- if (cached) return cached
103
- const wrapped = new Proxy(state, {
104
- has(target, key) {
105
- return safeStateHas(target, key)
106
- },
107
- get(target, key) {
108
- return Reflect.get(target, key)
109
- },
110
- })
111
- safeWrapCache.set(state, wrapped)
112
- return wrapped
134
+ function parseBin(minPrec: number): Node {
135
+ let left = parseUnary()
136
+ for (;;) {
137
+ const t = peek()
138
+ const prec = t && t.t === 'p' ? BIN_PREC[t.v] : undefined
139
+ if (prec === undefined || prec < minPrec) break
140
+ next()
141
+ const right = parseBin(prec + 1)
142
+ left = { k: 'bin', op: t!.v, l: left, r: right }
143
+ }
144
+ return left
145
+ }
146
+
147
+ function parseUnary(): Node {
148
+ const t = peek()
149
+ if (t && t.t === 'p' && (t.v === '!' || t.v === '-')) {
150
+ next()
151
+ return { k: 'un', op: t.v, x: parseUnary() }
152
+ }
153
+ return parsePostfix()
154
+ }
155
+
156
+ function parsePostfix(): Node {
157
+ let node = parsePrimary()
158
+ for (;;) {
159
+ const t = peek()
160
+ if (t?.v === '.') {
161
+ next()
162
+ const id = next()
163
+ if (!id || id.t !== 'id') throw 0
164
+ node = { k: 'mem', o: node, p: id.v }
165
+ } else if (t?.v === '(') {
166
+ next()
167
+ const args: Node[] = []
168
+ if (peek()?.v !== ')') {
169
+ args.push(parseExpr())
170
+ while (peek()?.v === ',') { next(); args.push(parseExpr()) }
171
+ }
172
+ eat(')')
173
+ node = { k: 'call', c: node, a: args }
174
+ } else break
175
+ }
176
+ return node
177
+ }
178
+
179
+ function parsePrimary(): Node {
180
+ const t = next()
181
+ if (!t) throw 0
182
+ if (t.t === 'num') return { k: 'lit', v: Number(t.v) }
183
+ if (t.t === 'str') return { k: 'lit', v: t.v }
184
+ if (t.v === '(') { const e = parseExpr(); eat(')'); return e }
185
+ if (t.t === 'id') {
186
+ if (t.v === 'true') return { k: 'lit', v: true }
187
+ if (t.v === 'false') return { k: 'lit', v: false }
188
+ if (t.v === 'null') return { k: 'lit', v: null }
189
+ if (t.v === 'undefined') return { k: 'lit', v: undefined }
190
+ return { k: 'id', n: t.v }
191
+ }
192
+ throw 0
193
+ }
194
+
195
+ const ast = parseExpr()
196
+ if (pos !== toks.length) throw 0 // trailing garbage
197
+ return ast
113
198
  }
114
199
 
200
+ // ── Identifier resolution ─────────────────────────────────────────────────────
201
+
115
202
  /**
116
203
  * Return true iff `key` is reachable on `state` without walking into
117
- * `Object.prototype`. Works for plain objects, prototype-chained objects, and
118
- * Proxies with their own `has` trap.
204
+ * `Object.prototype`. Blocks `'constructor' in state` from leaking the
205
+ * prototype while still allowing user-defined keys that happen to share a
206
+ * built-in name.
119
207
  */
120
- function safeStateHas(state: object, key: PropertyKey): boolean {
121
- if (typeof key !== 'string') return false
208
+ function safeStateHas(state: object, key: string): boolean {
122
209
  if (!Reflect.has(state, key)) return false
123
- // Identifiers that are NOT on Object.prototype are always safe — accept them
124
- // immediately without walking the chain.
125
210
  if (!OBJ_PROTO_KEYS.has(key)) return true
126
- // Built-in Object.prototype names (constructor, toString, hasOwnProperty, ...)
127
- // are only accepted when they have been explicitly placed on the state chain.
128
211
  let obj: object | null = state
129
212
  while (obj && obj !== Object.prototype) {
130
213
  if (Object.prototype.hasOwnProperty.call(obj, key)) return true
@@ -133,53 +216,126 @@ function safeStateHas(state: object, key: PropertyKey): boolean {
133
216
  return false
134
217
  }
135
218
 
136
- // ── evalExpr ──────────────────────────────────────────────────────────────────
219
+ function resolveIdent(name: string, scope: StateRecord): unknown {
220
+ if (safeStateHas(scope, name)) return scope[name]
221
+ if (ALLOWED_GLOBALS.has(name)) return (globalThis as Record<string, unknown>)[name]
222
+ return undefined
223
+ }
224
+
225
+ // ── Interpreter ────────────────────────────────────────────────────────────────
226
+
227
+ function evalNode(node: Node, scope: StateRecord): unknown {
228
+ switch (node.k) {
229
+ case 'lit': return node.v
230
+ case 'id': return resolveIdent(node.n, scope)
231
+ case 'mem': {
232
+ const o = evalNode(node.o, scope)
233
+ if (o == null || BLOCKED_PROPS.has(node.p)) return undefined
234
+ return (o as Record<string, unknown>)[node.p]
235
+ }
236
+ case 'un': {
237
+ const x = evalNode(node.x, scope)
238
+ return node.op === '!' ? !x : -(x as number)
239
+ }
240
+ case 'tern':
241
+ return evalNode(node.c, scope) ? evalNode(node.a, scope) : evalNode(node.b, scope)
242
+ case 'bin': {
243
+ const op = node.op
244
+ // short-circuit logicals (return operand value, JS semantics)
245
+ if (op === '&&') { const l = evalNode(node.l, scope); return l ? evalNode(node.r, scope) : l }
246
+ if (op === '||') { const l = evalNode(node.l, scope); return l ? l : evalNode(node.r, scope) }
247
+ const l = evalNode(node.l, scope) as never
248
+ const r = evalNode(node.r, scope) as never
249
+ switch (op) {
250
+ case '+': return (l as number) + (r as number)
251
+ case '-': return (l as number) - (r as number)
252
+ case '*': return (l as number) * (r as number)
253
+ case '/': return (l as number) / (r as number)
254
+ case '%': return (l as number) % (r as number)
255
+ case '<': return l < r
256
+ case '<=': return l <= r
257
+ case '>': return l > r
258
+ case '>=': return l >= r
259
+ case '==': return l == r
260
+ case '!=': return l != r
261
+ case '===': return l === r
262
+ case '!==': return l !== r
263
+ }
264
+ return undefined
265
+ }
266
+ case 'call': {
267
+ // member call binds `this` to the object (item.fmt(), Math.round(x));
268
+ // bare call leaves `this` undefined (instance methods are pre-bound).
269
+ let fn: unknown
270
+ let self: unknown
271
+ if (node.c.k === 'mem') {
272
+ self = evalNode(node.c.o, scope)
273
+ fn = self == null || BLOCKED_PROPS.has(node.c.p)
274
+ ? undefined
275
+ : (self as Record<string, unknown>)[node.c.p]
276
+ } else {
277
+ fn = evalNode(node.c, scope)
278
+ }
279
+ if (typeof fn !== 'function') throw new TypeError('not a function')
280
+ return (fn as (...a: unknown[]) => unknown).apply(self, node.a.map(x => evalNode(x, scope)))
281
+ }
282
+ }
283
+ }
284
+
285
+ // ── Cache + public API ──────────────────────────────────────────────────────
286
+
287
+ type CachedEntry =
288
+ | { kind: 'path'; parts: string[] }
289
+ | { kind: 'ast'; ast: Node }
290
+ | { kind: 'err' }
291
+
292
+ const exprCache = new Map<string, CachedEntry>()
293
+ const warnedRuntime = new Set<string>()
294
+
295
+ // Simple identifier or dot-path: "count", "user.name", "item.email".
296
+ const SIMPLE_PATH = /^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/
137
297
 
138
298
  /**
139
- * Evaluate a JS expression string against a state object.
299
+ * Evaluate an expression string against a state object.
140
300
  *
141
- * Results are cached by expression string repeated evaluations hit the cache.
142
- * Uses a fast-path for simple dot-paths (e.g. "count", "user.name") that avoids
143
- * Function() overhead.
301
+ * Cached by string. Simple dot-paths take a fast path that skips tokenizing.
302
+ * Parse errors warn once and thereafter resolve to `undefined`; runtime
303
+ * errors (e.g. calling a non-function) warn once per expression.
144
304
  *
145
305
  * @example
146
- * evalExpr('count > 0', { count: 5 }) // → true
147
- * evalExpr('user.name', { user: { name: 'Alice' } }) // → 'Alice'
148
- * evalExpr('price * qty', { price: 9.99, qty: 3 }) // → 29.97
306
+ * evalExpr('count > 0', { count: 5 }) // → true
307
+ * evalExpr('user.name', { user: { name: 'Alice' } }) // → 'Alice'
308
+ * evalExpr('price * qty', { price: 9.99, qty: 3 }) // → 29.97
149
309
  */
150
310
  export function evalExpr(expr: string, state: StateRecord): unknown {
151
311
  let cached = exprCache.get(expr)
152
312
 
153
313
  if (!cached) {
154
- // Determine once whether this is a simple dot-path and cache the result.
155
314
  if (SIMPLE_PATH.test(expr)) {
156
315
  cached = { kind: 'path', parts: expr.split('.') }
157
316
  } else {
158
317
  try {
159
- cached = {
160
- kind: 'fn',
161
- fn: new Function('$s', '$safe', `with($safe){with($s){return (${expr})}}`) as CompiledFn,
162
- }
318
+ cached = { kind: 'ast', ast: parse(tokenize(expr)) }
163
319
  } catch {
164
320
  warn(`invalid expression "${expr}"`)
165
- cached = { kind: 'fn', fn: () => undefined }
321
+ cached = { kind: 'err' }
166
322
  }
167
323
  }
168
324
  exprCache.set(expr, cached)
169
325
  }
170
326
 
171
- // Fast-path: simple property access — no Function() needed.
172
- // Still guarded so bare access to Object.prototype names returns undefined.
173
327
  if (cached.kind === 'path') {
174
- if (!safeStateHas(state, cached.parts[0]!)) return undefined
175
- return cached.parts.reduce<unknown>(
176
- (obj, key) => (obj != null ? (obj as StateRecord)[key] : undefined),
177
- state,
178
- )
328
+ const parts = cached.parts
329
+ if (!safeStateHas(state, parts[0]!)) return undefined
330
+ let obj: unknown = state
331
+ for (const key of parts) obj = obj != null ? (obj as StateRecord)[key] : undefined
332
+ return obj
179
333
  }
180
334
 
335
+ if (cached.kind === 'err') return undefined
336
+
181
337
  try {
182
- return cached.fn(safeStateWrap(state), SAFE_OUTER)
338
+ return evalNode(cached.ast, state)
183
339
  } catch (e) {
184
340
  if (!warnedRuntime.has(expr)) {
185
341
  warnedRuntime.add(expr)