/** * @factory/database-engine/formula — safe AST evaluator (TASK-0084 R5). * * Walks the AST only (zero eval — the CI grep gate). Type rules: * - arithmetic on non-numbers → TYPE; `date + 1` → TYPE with a "use dateAdd" * hint; * - cross-type comparison (number < date) → TYPE; string < string and * date < date allowed; * - null in arithmetic → null propagation (`null + 1` = null); * `null == null` = true, `null == 0` = false; * - `if`/`and`/`or` are lazy (only the taken branch / until first match). * Budget (TASK-0134): two cooperating DoS guards, both enforced at every * charge point (node visits AND per-argument charges inside function impls): * - `EVAL_OP_LIMIT` (100k node visits) → `FormulaError("FORMULA_TIMEOUT")`; * - `EVAL_TIMEOUT_MS` (50 ms wall-clock, cooperative — checked between node * steps via an injectable `now()`; deterministic in tests, `Date.now()` * in production) → `FormulaError("FORMULA_TIMEOUT")`. * `FORMULA_TIMEOUT` is the canonical error code (docs/api/authentication.md * §9.1); the API maps it to a 422 with `details[0] = { reason: * "formula_timeout" }`. * There is no separate hard-kill: the AST walk has no yield points beyond * node boundaries, so the per-step cooperative check + op counter IS the cap * (recorded decision — an AbortController-style hard cap would require * re-architecting the walk and buys nothing over the two budgets). * Any output string > 1 MB → RUNTIME (separate output guard, unchanged). */ import type { AstNode, EValue, FormulaContext, FormulaType } from "./types.js"; import { FormulaError, EVAL_OP_LIMIT, EVAL_TIMEOUT_MS, MAX_STEPS, MAX_OUTPUT_BYTES } from "./types.js"; import { FUNCTIONS, type Budget, normalizeProperty, unknownPropertyMessage } from "./functions.js"; export interface EvalResult { value: EValue; steps: number; } /** Options for `evaluateFormula`. All optional — back-compat with Phase 6. */ export interface EvalOptions { /** * Injectable wall clock (epoch ms) for the `EVAL_TIMEOUT_MS` budget. * Defaults to `Date.now`. Tests pin this to make the wall-clock guard * deterministic (e.g. advance it 51 ms mid-evaluation). */ now?: () => number; } function runtime(message: string): never { throw new FormulaError("RUNTIME", message); } function formulaTimeout(message: string): never { throw new FormulaError("FORMULA_TIMEOUT", message); } /** * The internal budget carries the wall-clock deadline alongside the public * `Budget` surface (`{ steps, charge }` — unchanged for function impls). * Both DoS guards are enforced in ONE place (`checkBudget`), called from * every charge point (node visits AND `budget.charge(n)` inside impls), so * a hostile expression cannot escape either budget via the other's path. */ interface InternalBudget extends Budget { deadline: number; now: () => number; } /** Enforce both budgets; throws FORMULA_TIMEOUT on either violation. */ function checkBudget(b: InternalBudget): void { if (b.steps > EVAL_OP_LIMIT) formulaTimeout(`Evaluation op limit exceeded (${EVAL_OP_LIMIT} ops)`); if (b.now() > b.deadline) formulaTimeout(`Evaluation wall-clock budget exceeded (${EVAL_TIMEOUT_MS} ms)`); } export function evaluateFormula(ast: AstNode, ctx: FormulaContext, opts: EvalOptions = {}): EvalResult { const now = opts.now ?? Date.now; const budget: InternalBudget = { steps: 0, now, deadline: now() + EVAL_TIMEOUT_MS, charge(n: number) { budget.steps += n; checkBudget(budget); }, }; const ev = walk(ast, ctx, budget); checkBudget(budget); checkOutput(ev); return { value: ev, steps: budget.steps }; } function checkOutput(ev: EValue): void { if (ev.t === "string" && (ev.v as string).length > MAX_OUTPUT_BYTES) runtime("Evaluation output exceeds 1 MB"); if (ev.t === "array") for (const x of ev.v as EValue[]) checkOutput(x); } function charge(budget: InternalBudget): void { budget.steps++; checkBudget(budget); } function walk(node: AstNode, ctx: FormulaContext, budget: InternalBudget): EValue { charge(budget); switch (node.type) { case "Literal": { if (node.literalType === "number") return { v: node.value as number, t: "number" }; if (node.literalType === "string") return { v: node.value as string, t: "string" }; if (node.literalType === "date") return { v: node.value as string, t: "date" }; return { v: node.value as boolean, t: "bool" }; } case "NullLiteral": return { v: null, t: "null" }; case "ArrayLiteral": return { v: node.elements.map((e) => walk(e, ctx, budget)), t: "array" }; case "Prop": { const v = ctx.properties[node.name]; if (v === undefined) throw new FormulaError( "TYPE", unknownPropertyMessage(node.name, ctx), undefined, node.name, ); return normalizeProperty(v, ctx, node.name); } case "Call": { const fn = FUNCTIONS[node.name]; if (!fn) throw new FormulaError("UNKNOWN_FUNCTION", `unknown function "${node.name}"`); const [min, max] = fn.arity; if (node.args.length < min || (Number.isFinite(max) && node.args.length > max)) { throw new FormulaError( "TYPE", `${node.name}() expects ${min === max ? min : `${min}–${Number.isFinite(max) ? max : "∞"}`} arguments, got ${node.args.length}`, ); } // `and(...)` / `or(...)` are LAZY: walk args left→right, short-circuit on // the first decisive value, propagate null only when nothing decisive. if (node.name === "and" || node.name === "or") { const wantTrue = node.name === "or"; let sawNull = false; for (const a of node.args) { const v = walk(a, ctx, budget); if (v.t === "null") { sawNull = true; continue; } if (v.t !== "bool") typeErr(`${node.name}() requires bool arguments, got ${v.t}`); // decisive value: and → false, or → true; otherwise keep walking if ((v.v as boolean) === wantTrue) return { v: wantTrue, t: "bool" }; } return sawNull ? { v: null, t: "null" } : { v: !wantTrue, t: "bool" }; // all-true and / all-false or } // `prop(name)` is evaluated with the literal argument (it IS the lookup) const args = node.args.map((a): EValue => node.name === "prop" && a.type === "Literal" ? { v: a.value as string, t: "string" } : walk(a, ctx, budget), ); return fn.impl(args, ctx, budget); } case "UnaryOp": { if (node.op === "-") { const v = walk(node.operand, ctx, budget); if (v.t === "null") return { v: null, t: "null" }; if (v.t !== "number") typeErr(`-x requires a number, got ${v.t}`); return { v: -(v.v as number), t: "number" }; } const v = walk(node.operand, ctx, budget); if (v.t === "null") return { v: null, t: "null" }; if (v.t !== "bool") typeErr(`not requires a bool, got ${v.t}`); return { v: !(v.v as boolean), t: "bool" }; } case "BinaryOp": return binaryOp(node, ctx, budget); case "If": { const cond = walk(node.cond, ctx, budget); if (cond.t === "null") return { v: null, t: "null" }; // null cond → null propagation (documented) if (cond.t !== "bool") typeErr(`if(cond) requires a bool, got ${cond.t}`); // lazy: only the taken branch is walked return (cond.v as boolean) ? walk(node.then, ctx, budget) : walk(node.else, ctx, budget); } } } function typeErr(message: string): never { throw new FormulaError("TYPE", message); } function isNumericType(t: FormulaType): boolean { return t === "number"; } function isComparablePair(a: FormulaType, b: FormulaType): boolean { if (a === "null" || b === "null") return true; // equality with null handled below if (a === b) return a === "number" || a === "string" || a === "date" || a === "bool"; return false; } function binaryOp( node: Extract, ctx: FormulaContext, budget: InternalBudget, ): EValue { const op = node.op; const logical = op === "and" || op === "or"; const comparison = op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">="; // lazy logical ops if (logical) { const l = walk(node.left, ctx, budget); if (l.t === "null") return { v: null, t: "null" }; if (l.t !== "bool") typeErr(`${op} requires bool operands, got ${l.t}`); const lBool = l.v as boolean; if ((op === "and" && !lBool) || (op === "or" && lBool)) return { v: lBool, t: "bool" }; const r = walk(node.right, ctx, budget); if (r.t === "null") return { v: null, t: "null" }; if (r.t !== "bool") typeErr(`${op} requires bool operands, got ${r.t}`); return { v: op === "and" ? (r.v as boolean) : true, t: "bool" }; } const l = walk(node.left, ctx, budget); const r = walk(node.right, ctx, budget); // equality / inequality: null semantics — null == null → true, null == 0 → false if (op === "==" || op === "!=") { const eq = (a: EValue, b: EValue): boolean => { if (a.t === "null" || b.t === "null") return a.t === "null" && b.t === "null"; if (a.t !== b.t) return false; if (a.t === "array") { const av = a.v as EValue[], bv = b.v as EValue[]; if (av.length !== bv.length) return false; return av.every((x, i) => eq(x, bv[i]!)); } return (a.v as unknown) === (b.v as unknown); }; const result = op === "==" ? eq(l, r) : !eq(l, r); return { v: result, t: "bool" }; } // ordering comparisons if (comparison) { if (l.t === "null" || r.t === "null") return { v: false, t: "bool" }; // nulls never order if (!isComparablePair(l.t, r.t)) typeErr(`cannot compare ${l.t} with ${r.t}`); let cmp = 0; if (l.t === "number" && r.t === "number") cmp = (l.v as number) - (r.v as number); else if (l.t === "date" && r.t === "date") { const lt = new Date( (l.v as string).length === 10 ? (l.v as string) + "T00:00:00Z" : (l.v as string), ).getTime(); const rt = new Date( (r.v as string).length === 10 ? (r.v as string) + "T00:00:00Z" : (r.v as string), ).getTime(); cmp = lt - rt; } else if (l.t === "string" && r.t === "string") cmp = (l.v as string) < (r.v as string) ? -1 : (l.v as string) > (r.v as string) ? 1 : 0; else if (l.t === "bool" && r.t === "bool") cmp = Number(l.v) - Number(r.v); const result = op === "<" ? cmp < 0 : op === "<=" ? cmp <= 0 : op === ">" ? cmp > 0 : cmp >= 0; return { v: result, t: "bool" }; } // arithmetic if (op === "+" || op === "-" || op === "*" || op === "/" || op === "%") { if (op === "+" && (l.t === "date" || r.t === "date")) { typeErr(`date arithmetic requires dateAdd(date, amount, unit), got date ${op} value`); } // null propagation in arithmetic (documented: null + 1 = null) if (l.t === "null" || r.t === "null") return { v: null, t: "null" }; if (!isNumericType(l.t) || !isNumericType(r.t)) typeErr(`arithmetic ${op} requires number operands, got ${l.t} and ${r.t}`); const a = l.v as number; const b = r.v as number; let out: number; switch (op) { case "+": out = a + b; break; case "-": out = a - b; break; case "*": out = a * b; break; case "/": if (b === 0) return { v: null, t: "null" }; // 1/0 = null (no Infinity/NaN leaks) out = a / b; break; case "%": if (b === 0) return { v: null, t: "null" }; out = a % b; break; default: out = a; } if (!Number.isFinite(out)) return { v: null, t: "null" }; return { v: out, t: "number" }; } typeErr(`unknown operator "${op}"`); } export { FUNCTIONS as _FUNCTIONS };