micra.js 2.3.2 → 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,129 +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,parseInt,parseFloat,isNaN,isFinite,NaN,Infinity,undefined'.split(','),
57
- )
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
+ }
58
106
 
59
- /**
60
- * Outer `with()` scope. Its `has` trap claims every non-whitelisted identifier
61
- * is "in scope" so the JS engine resolves the read on this Proxy (which returns
62
- * undefined) instead of walking up to the global object. Whitelisted names fall
63
- * through to globalThis.
64
- */
65
- // Sentinel parameter names used by the compiled function. SAFE_OUTER must NOT
66
- // shadow them, or `with($s)` would resolve to `undefined` via SAFE_OUTER.
67
- const PARAM_S = '$s'
68
- const PARAM_SAFE = '$safe'
69
-
70
- const SAFE_OUTER: object = new Proxy(Object.create(null) as object, {
71
- has(_target, key): boolean {
72
- if (typeof key !== 'string') return false
73
- if (key === PARAM_S || key === PARAM_SAFE) return false
74
- return !ALLOWED_GLOBALS.has(key)
75
- },
76
- get(): undefined {
77
- return undefined
78
- },
79
- })
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
+ }
80
115
 
81
- /**
82
- * @internal Per-state safe wrappers — one per source state object. WeakMap so
83
- * short-lived itemStates get GC'd with their wrappers.
84
- */
85
- 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++ }
86
121
 
87
- /**
88
- * @internal Pre-computed names that live on `Object.prototype`
89
- * (constructor, toString, hasOwnProperty, ...). Used by safeStateHas to detect
90
- * built-in keys without re-walking the chain on every call.
91
- */
92
- 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
+ }
93
133
 
94
- /**
95
- * Wrap a state object so its `has` trap reports only "real" keys — own
96
- * properties or keys reachable up to (but not including) `Object.prototype`.
97
- * This blocks `'constructor' in state` from leaking the prototype.
98
- */
99
- function safeStateWrap(state: object): object {
100
- const cached = safeWrapCache.get(state)
101
- if (cached) return cached
102
- const wrapped = new Proxy(state, {
103
- has(target, key) {
104
- return safeStateHas(target, key)
105
- },
106
- get(target, key) {
107
- return Reflect.get(target, key)
108
- },
109
- })
110
- safeWrapCache.set(state, wrapped)
111
- 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
112
198
  }
113
199
 
200
+ // ── Identifier resolution ─────────────────────────────────────────────────────
201
+
114
202
  /**
115
203
  * Return true iff `key` is reachable on `state` without walking into
116
- * `Object.prototype`. Works for plain objects, prototype-chained objects, and
117
- * 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.
118
207
  */
119
- function safeStateHas(state: object, key: PropertyKey): boolean {
120
- if (typeof key !== 'string') return false
208
+ function safeStateHas(state: object, key: string): boolean {
121
209
  if (!Reflect.has(state, key)) return false
122
- // Identifiers that are NOT on Object.prototype are always safe — accept them
123
- // immediately without walking the chain.
124
210
  if (!OBJ_PROTO_KEYS.has(key)) return true
125
- // Built-in Object.prototype names (constructor, toString, hasOwnProperty, ...)
126
- // are only accepted when they have been explicitly placed on the state chain.
127
211
  let obj: object | null = state
128
212
  while (obj && obj !== Object.prototype) {
129
213
  if (Object.prototype.hasOwnProperty.call(obj, key)) return true
@@ -132,53 +216,126 @@ function safeStateHas(state: object, key: PropertyKey): boolean {
132
216
  return false
133
217
  }
134
218
 
135
- // ── 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_$]*)*$/
136
297
 
137
298
  /**
138
- * Evaluate a JS expression string against a state object.
299
+ * Evaluate an expression string against a state object.
139
300
  *
140
- * Results are cached by expression string repeated evaluations hit the cache.
141
- * Uses a fast-path for simple dot-paths (e.g. "count", "user.name") that avoids
142
- * 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.
143
304
  *
144
305
  * @example
145
- * evalExpr('count > 0', { count: 5 }) // → true
146
- * evalExpr('user.name', { user: { name: 'Alice' } }) // → 'Alice'
147
- * 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
148
309
  */
149
310
  export function evalExpr(expr: string, state: StateRecord): unknown {
150
311
  let cached = exprCache.get(expr)
151
312
 
152
313
  if (!cached) {
153
- // Determine once whether this is a simple dot-path and cache the result.
154
314
  if (SIMPLE_PATH.test(expr)) {
155
315
  cached = { kind: 'path', parts: expr.split('.') }
156
316
  } else {
157
317
  try {
158
- cached = {
159
- kind: 'fn',
160
- fn: new Function('$s', '$safe', `with($safe){with($s){return (${expr})}}`) as CompiledFn,
161
- }
318
+ cached = { kind: 'ast', ast: parse(tokenize(expr)) }
162
319
  } catch {
163
320
  warn(`invalid expression "${expr}"`)
164
- cached = { kind: 'fn', fn: () => undefined }
321
+ cached = { kind: 'err' }
165
322
  }
166
323
  }
167
324
  exprCache.set(expr, cached)
168
325
  }
169
326
 
170
- // Fast-path: simple property access — no Function() needed.
171
- // Still guarded so bare access to Object.prototype names returns undefined.
172
327
  if (cached.kind === 'path') {
173
- if (!safeStateHas(state, cached.parts[0]!)) return undefined
174
- return cached.parts.reduce<unknown>(
175
- (obj, key) => (obj != null ? (obj as StateRecord)[key] : undefined),
176
- state,
177
- )
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
178
333
  }
179
334
 
335
+ if (cached.kind === 'err') return undefined
336
+
180
337
  try {
181
- return cached.fn(safeStateWrap(state), SAFE_OUTER)
338
+ return evalNode(cached.ast, state)
182
339
  } catch (e) {
183
340
  if (!warnedRuntime.has(expr)) {
184
341
  warnedRuntime.add(expr)