/** * Evaluator for the computed-variable DSL. * * Pure: given an AST (from `parse.ts`) and a `lookup` callback that * resolves a variable reference to a value, it produces the computed * value. No DB, no I/O, no `ResolutionContext` coupling — the caller * supplies `lookup`, which keeps this unit-testable and lets the resolver * integration (a later step) decide how refs map onto live data. * * The function allow-list is the v1 set from * openspec/specs/internal-dns-split-horizon/spec.md (D1.1): * keys, values, map, concat, unique, format * * Secret-projection rule: `values(secret.*)` is rejected — it would leak * secret values. `keys(secret.*)` is allowed (key names are non-sensitive). */ import { asComputedExpression } from './marker'; import { type Arg, type Node, type RefNode, parseComputed } from './parse'; export class ComputedEvalError extends Error { constructor(message: string) { super(message); this.name = 'ComputedEvalError'; } } /** * Resolves a variable reference to its value. * `root` is e.g. "secret", `path` is the remaining segments. * Returns `undefined` if the reference doesn't exist. */ export type LookupFn = (root: string, path: string[]) => unknown; const ALLOWED_FUNCTIONS = new Set(['keys', 'values', 'map', 'concat', 'unique', 'format']); interface EvalState { lookup: LookupFn; } function refLabel(node: RefNode): string { return `${node.root}.${node.path.join('.')}`; } function evalNode(node: Node, state: EvalState): unknown { switch (node.kind) { case 'str': return node.value; case 'bare': throw new ComputedEvalError( `Unexpected identifier '${node.name}' — expected a function call, a quoted string, or a variable reference like self.x / secret.y`, ); case 'ref': { const value = state.lookup(node.root, node.path); if (value === undefined) { throw new ComputedEvalError(`Reference '${refLabel(node)}' could not be resolved`); } return value; } case 'call': return evalCall(node.fn, node.args, state); } } function asObject(value: unknown, fn: string): Record { if (value === null || typeof value !== 'object' || Array.isArray(value)) { throw new ComputedEvalError(`${fn}() expects an object/map argument`); } return value as Record; } function asArray(value: unknown, fn: string): unknown[] { if (!Array.isArray(value)) { throw new ComputedEvalError(`${fn}() expects an array argument`); } return value; } /** A `secret`/`system_secret`-rooted ref argument, or null if not one. */ function secretRefArg(arg: Arg): RefNode | null { if ( arg.value.kind === 'ref' && (arg.value.root === 'secret' || arg.value.root === 'system_secret') ) { return arg.value; } return null; } function evalCall(fn: string, args: Arg[], state: EvalState): unknown { if (!ALLOWED_FUNCTIONS.has(fn)) { throw new ComputedEvalError( `Unknown function '${fn}'. Allowed: keys, values, map, concat, unique, format`, ); } switch (fn) { case 'keys': { requireArity(fn, args, 1); requirePositional(fn, args); const obj = asObject(evalNode(args[0].value, state), fn); return Object.keys(obj); } case 'values': { requireArity(fn, args, 1); requirePositional(fn, args); // Secret-projection rule: surfacing the VALUES of a secret map would // leak secrets. Reject statically (before evaluation). const secretRef = secretRefArg(args[0]); if (secretRef) { throw new ComputedEvalError( `values(${refLabel(secretRef)}) is not allowed — exposing the values of a secret would leak it. Use keys() to surface its key names.`, ); } const obj = asObject(evalNode(args[0].value, state), fn); return Object.values(obj); } case 'unique': { requireArity(fn, args, 1); requirePositional(fn, args); const arr = asArray(evalNode(args[0].value, state), fn); return dedupe(arr); } case 'concat': { requirePositional(fn, args); if (args.length === 0) { throw new ComputedEvalError('concat() requires at least one argument'); } const out: unknown[] = []; for (const arg of args) { const v = evalNode(arg.value, state); if (Array.isArray(v)) out.push(...v); else out.push(v); } return out; } case 'map': { // map(list, field) — field is a bare identifier naming the property. requireArity(fn, args, 2); requirePositional(fn, args); const arr = asArray(evalNode(args[0].value, state), fn); const fieldNode = args[1].value; if (fieldNode.kind !== 'bare') { throw new ComputedEvalError( `map()'s second argument must be a bare field name (e.g. map(self.upstreams, ip))`, ); } const field = fieldNode.name; return arr.map((item, idx) => { if (item === null || typeof item !== 'object') { throw new ComputedEvalError( `map() element at index ${idx} is not an object; cannot project field '${field}'`, ); } return (item as Record)[field]; }); } case 'format': { // format('{a}.{b}', a=..., b=...) if (args.length < 1) { throw new ComputedEvalError('format() requires a template string as its first argument'); } if (args[0].name !== undefined) { throw new ComputedEvalError("format()'s first argument (the template) must be positional"); } const tmplVal = evalNode(args[0].value, state); if (typeof tmplVal !== 'string') { throw new ComputedEvalError("format()'s first argument must be a string template"); } const parts: Record = {}; for (let i = 1; i < args.length; i++) { const arg = args[i]; if (!arg.name) { throw new ComputedEvalError( 'format() arguments after the template must be named (e.g. host=self.hostname)', ); } parts[arg.name] = stringifyScalar( evalNode(arg.value, state), `format() part '${arg.name}'`, ); } return tmplVal.replace(/\{([a-zA-Z0-9_]+)\}/g, (_m, key: string) => { if (!(key in parts)) { throw new ComputedEvalError( `format() template references '{${key}}' but no such named argument was supplied`, ); } return parts[key]; }); } default: // Unreachable — guarded by ALLOWED_FUNCTIONS above. throw new ComputedEvalError(`Unhandled function '${fn}'`); } } function requireArity(fn: string, args: Arg[], n: number): void { if (args.length !== n) { throw new ComputedEvalError(`${fn}() expects ${n} argument(s), got ${args.length}`); } } function requirePositional(fn: string, args: Arg[]): void { for (const arg of args) { if (arg.name !== undefined) { throw new ComputedEvalError(`${fn}() does not take named arguments`); } } } function dedupe(arr: unknown[]): unknown[] { const seen = new Set(); const out: unknown[] = []; for (const item of arr) { // Key scalars by value; objects/arrays by JSON form. Good enough for // the homogeneous primitive lists computed variables produce. const key = typeof item === 'object' ? JSON.stringify(item) : `${typeof item}:${String(item)}`; if (!seen.has(key)) { seen.add(key); out.push(item); } } return out; } function stringifyScalar(value: unknown, what: string): string { if (typeof value === 'string') return value; if (typeof value === 'number' || typeof value === 'boolean') return String(value); throw new ComputedEvalError(`${what} must be a string, number, or boolean`); } /** * Parse + evaluate a computed-variable expression against a lookup. * Throws ComputedParseError / ComputedEvalError on failure. */ export function evaluateComputed(expression: string, lookup: LookupFn): unknown { const ast = parseComputed(expression); return evalNode(ast, { lookup }); } /** * Recursively replace every computed marker in `value` with its evaluated * result, using `lookup` (built over the PROVIDER's context). Non-marker * values pass through unchanged; arrays and plain objects are walked. Pure — * returns a new value, never mutates the input. Throws on the first marker * that fails to evaluate (callers in eager paths catch + degrade). */ export function resolveComputedFields(value: unknown, lookup: LookupFn): unknown { const expr = asComputedExpression(value); if (expr !== null) { return evaluateComputed(expr, lookup); } if (Array.isArray(value)) { return value.map((v) => resolveComputedFields(v, lookup)); } if (value !== null && typeof value === 'object') { const out: Record = {}; for (const [k, v] of Object.entries(value as Record)) { out[k] = resolveComputedFields(v, lookup); } return out; } return value; }