import {isDeepStrictEqual} from 'node:util'; import type {BranchState} from '../../../types/bot/workflow/runtime.ts'; import type {Condition, Json, ValueSource} from '../../../types/bot/workflow/workflow.ts'; const unsafe_keys = new Set(['__proto__', 'prototype', 'constructor']); const max_depth = 64; const max_entries = 100_000; const max_bytes = 1_048_576; function invalid(code: string, message: string): never { throw Object.assign(new Error(message), {code}); } export function assert_json(value: unknown): asserts value is Json { const ancestors = new Set(); let entries = 0; let bytes = 0; const visit = (item: unknown, depth: number): void => { if (depth > max_depth || ++entries > max_entries) { invalid('INVALID_JSON', 'JSON depth or entry limit exceeded'); } if (item === null || typeof item === 'boolean') { bytes += item === null ? 4 : item ? 4 : 5; } else if (typeof item === 'number') { if (!Number.isFinite(item)) invalid('INVALID_JSON', 'JSON numbers must be finite'); bytes += String(item).length; } else if (typeof item === 'string') { if (item.length > max_bytes) invalid('INVALID_JSON', 'JSON size limit exceeded'); bytes += Buffer.byteLength(JSON.stringify(item)); } else if (typeof item === 'object') { const array = Array.isArray(item); const prototype = Object.getPrototypeOf(item); if (!array && prototype !== Object.prototype && prototype !== null) { invalid('INVALID_JSON', 'JSON objects must have a plain prototype'); } if (ancestors.has(item)) invalid('INVALID_JSON', 'JSON must not contain cycles'); ancestors.add(item); const keys = Reflect.ownKeys(item); if (keys.length > max_entries || (array && item.length > max_entries)) { invalid('INVALID_JSON', 'JSON entry limit exceeded'); } bytes += 2; let array_entries = 0; for (const key of keys) { if (array && key === 'length') continue; if (typeof key !== 'string' || unsafe_keys.has(key)) { invalid('INVALID_JSON', 'JSON contains an unsafe key'); } const descriptor = Object.getOwnPropertyDescriptor(item, key)!; if (!descriptor.enumerable || !('value' in descriptor)) { invalid('INVALID_JSON', 'JSON properties must be enumerable data properties'); } if (array) { const index = Number(key); if (!Number.isSafeInteger(index) || index < 0 || String(index) !== key || index >= item.length) { invalid('INVALID_JSON', 'JSON arrays must only contain indexed values'); } array_entries++; } else { if (key.length > max_bytes) invalid('INVALID_JSON', 'JSON size limit exceeded'); bytes += Buffer.byteLength(JSON.stringify(key)) + 1; } bytes++; visit(descriptor.value, depth + 1); } if (array && array_entries !== item.length) invalid('INVALID_JSON', 'JSON arrays must not contain holes'); ancestors.delete(item); } else { invalid('INVALID_JSON', 'Value is not JSON'); } if (bytes > max_bytes) invalid('INVALID_JSON', 'JSON size limit exceeded'); }; visit(value, 0); } export function resolve_value(source: ValueSource, state: BranchState): Json { if (source.kind === 'literal') { assert_json(source.value); return source.value; } if (source.kind !== 'ref' || !['trigger', 'vars', 'outputs', 'last_result', 'inputs'].includes(source.root) || !Array.isArray(source.path) || source.path.length > max_depth) { invalid('INVALID_VALUE_SOURCE', 'Invalid value reference'); } let value: unknown = state[source.root]; for (const segment of source.path) { if ((typeof segment !== 'string' && typeof segment !== 'number') || (typeof segment === 'number' && (!Number.isSafeInteger(segment) || segment < 0)) || unsafe_keys.has(String(segment))) { invalid('INVALID_VALUE_SOURCE', 'Invalid value reference path'); } if (value === null || typeof value !== 'object') invalid('VALUE_NOT_FOUND', 'Value reference was not found'); const descriptor = Object.getOwnPropertyDescriptor(value, segment); if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) { invalid('VALUE_NOT_FOUND', 'Value reference was not found'); } value = descriptor.value; } assert_json(value); return value; } export function evaluate_condition(condition: Condition, state: BranchState): boolean { let entries = 0; const evaluate = (item: Condition, depth: number): boolean => { if (depth > max_depth || ++entries > 10_000) invalid('INVALID_CONDITION', 'Condition limit exceeded'); switch (item.op) { case 'and': return item.items.every(child => evaluate(child, depth + 1)); case 'or': return item.items.some(child => evaluate(child, depth + 1)); case 'not': return !evaluate(item.item, depth + 1); case 'eq': return isDeepStrictEqual(resolve_value(item.left, state), resolve_value(item.right, state)); case 'contains': { const left = resolve_value(item.left, state); const right = resolve_value(item.right, state); if (typeof left === 'string' && typeof right === 'string') return left.includes(right); if (Array.isArray(left)) return left.some(value => isDeepStrictEqual(value, right)); invalid('INVALID_CONDITION', 'contains requires a string pair or an array'); } case 'gt': case 'lt': { const left = resolve_value(item.left, state); const right = resolve_value(item.right, state); if (typeof left !== 'number' || typeof right !== 'number') { invalid('INVALID_CONDITION', 'Numeric comparison requires numbers'); } return item.op === 'gt' ? left > right : left < right; } default: return invalid('INVALID_CONDITION', 'Unknown condition operator'); } }; return evaluate(condition, 0); }