import { Parser } from "acorn"; import jsx from "acorn-jsx"; import { CUSTOM_JSX_BLOCKED_PROPERTIES, CUSTOM_JSX_LIMITS, isBlockedCustomJsxLexicalBinding, normalizeCustomJsxProperty, } from "./policy"; import type { AstNode } from "./analyzer-ast"; import { nodeOf, nodesOf, staticPropertyName } from "./analyzer-ast"; import { createCustomJsxParseDiagnostic } from "./analyzer-diagnostics"; import type { CustomJsxTemplateDiagnostic } from "./analyzer-diagnostics"; import { analyzeCustomJsxElement } from "./analyzer-jsx"; import { canStaticallyProduceCallable, isStaticallyCallableBinding, RESERVED_LOCAL_BINDINGS, ROOT_BINDINGS, } from "./analyzer-language"; import { CUSTOM_JSX_BINARY_OPERATORS, CUSTOM_JSX_CALLBACK_METHODS, CUSTOM_JSX_SAFE_VALUE_METHODS, } from "./safe-language-policy"; import { isSafeRegexLiteral } from "./regex-policy"; export type { CustomJsxTemplateDiagnostic } from "./analyzer-diagnostics"; const JsxParser = Parser.extend(jsx()); const requestStatusLabels = new Set(["loading", "success", "error"]); function directRequestStatusId(node: AstNode | null): string | null { if (node?.type !== "MemberExpression") return null; const object = nodeOf(node.object); if (object?.type !== "Identifier" || object.name !== "status") return null; const property = nodeOf(node.property); return node.computed ? (staticPropertyName(property) ?? null) : String(property?.name ?? property?.value ?? "") || null; } function invalidRequestStatusComparison(node: AstNode) { if (!["===", "==", "!==", "!="].includes(String(node.operator))) return null; const left = nodeOf(node.left); const right = nodeOf(node.right); const leftStatus = directRequestStatusId(left); const rightStatus = directRequestStatusId(right); const leftLabel = left?.type === "Literal" && typeof left.value === "string" ? left.value : null; const rightLabel = right?.type === "Literal" && typeof right.value === "string" ? right.value : null; const requestId = leftStatus && rightLabel && requestStatusLabels.has(rightLabel) ? leftStatus : rightStatus && leftLabel && requestStatusLabels.has(leftLabel) ? rightStatus : null; return requestId ? `INVALID_STATUS_COMPARISON: status.${requestId} is an object. Use status.${requestId}?.loading, status.${requestId}?.ok === true, or status.${requestId}?.ok === false` : null; } export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDiagnostic[] { const diagnostics: CustomJsxTemplateDiagnostic[] = []; let operations = 0; const add = (node: AstNode, message: string, severity: "error" | "warning" = "error") => { const line = node.loc?.start?.line ?? 1; diagnostics.push({ severity, message, index: Math.max(0, (node.start ?? 2) - 2), line, column: Math.max(1, (node.loc?.start?.column ?? 0) + 1 - (line === 1 ? 2 : 0)), }); }; const checkBudget = (node: AstNode, depth: number) => { operations += 1; if (depth > CUSTOM_JSX_LIMITS.astDepth) add(node, `Template exceeds the AST depth limit (${CUSTOM_JSX_LIMITS.astDepth})`); if (operations === CUSTOM_JSX_LIMITS.operations + 1) { add(node, `Template exceeds the operation limit (${CUSTOM_JSX_LIMITS.operations})`); } return depth <= CUSTOM_JSX_LIMITS.astDepth && operations <= CUSTOM_JSX_LIMITS.operations; }; let visit: (node: AstNode, depth: number, bindings: ReadonlySet) => void; const visitArrow = (node: AstNode, depth: number, bindings: ReadonlySet) => { if (node.async || node.generator) { add(node, "CALLBACK_VALUE_NOT_ALLOWED: Async and generator callbacks are not supported"); return; } const callbackBindings = new Set(bindings); const parameterNames = new Set(); for (const parameter of nodesOf(node.params)) { if (parameter.type !== "Identifier") { add(parameter, "INVALID_LOCAL_DECLARATION: Callback parameters must be identifiers"); continue; } const name = String(parameter.name); if (isBlockedCustomJsxLexicalBinding(name)) { add(parameter, `INVALID_LOCAL_DECLARATION: '${name}' is not a safe callback parameter name`); continue; } if (RESERVED_LOCAL_BINDINGS.has(name)) { add(parameter, `RESERVED_LOCAL_BINDING: '${name}' cannot be shadowed`); continue; } if (parameterNames.has(name)) { add(parameter, `DUPLICATE_LOCAL_BINDING: '${name}' is already declared`); continue; } parameterNames.add(name); callbackBindings.add(name); } const body = nodeOf(node.body); if (!body) return; if (body.type === "BlockStatement") add(body, "UNSUPPORTED_BLOCK_STATEMENT: Use one expression in authored callbacks"); else if (canStaticallyProduceCallable(body)) { add(body, "CALLBACK_VALUE_NOT_ALLOWED: Authored callbacks cannot return callable values"); } else visit(body, depth + 1, callbackBindings); }; visit = (node: AstNode, depth: number, bindings: ReadonlySet): void => { if (!checkBudget(node, depth)) return; switch (node.type) { case "Program": nodesOf(node.body).forEach((child) => visit(child, depth + 1, bindings)); return; case "ExpressionStatement": { const expression = nodeOf(node.expression); if (expression) visit(expression, depth + 1, bindings); return; } case "JSXFragment": nodesOf(node.children).forEach((child) => visit(child, depth + 1, bindings)); return; case "JSXElement": { analyzeCustomJsxElement(node, depth, bindings, { add, visit, visitArrow }); return; } case "JSXText": case "JSXEmptyExpression": case "TemplateElement": return; case "JSXExpressionContainer": { const expression = nodeOf(node.expression); if (expression && expression.type !== "JSXEmptyExpression") visit(expression, depth + 1, bindings); return; } case "Literal": if (typeof node.value === "bigint") add(node, "BIGINT_NOT_SUPPORTED: Use Number for widget values"); if (node.regex !== undefined && !isSafeRegexLiteral(node.regex)) add(node, "UNSAFE_REGEX: Use a conservative regex without repeated alternatives or variable quantifiers"); return; case "Identifier": { const name = String(node.name ?? ""); if (!bindings.has(name)) add(node, `Unknown binding '${name}'`); return; } case "ArrayExpression": nodesOf(node.elements).forEach((child) => { const argument = child.type === "SpreadElement" ? nodeOf(child.argument) : child; if (argument) visit(argument, depth + 1, bindings); }); return; case "ObjectExpression": nodesOf(node.properties).forEach((property) => { if (property.type === "SpreadElement") { const argument = nodeOf(property.argument); if (argument) visit(argument, depth + 1, bindings); return; } if (property.type !== "Property" || property.kind !== "init" || property.method || property.shorthand) { add(property, "Only explicit object properties are supported"); return; } const key = nodeOf(property.key); if (property.computed && key) visit(key, depth + 1, bindings); if ( !property.computed && key && CUSTOM_JSX_BLOCKED_PROPERTIES.has(normalizeCustomJsxProperty(key.name ?? key.value)) ) { add(key, "Reflective object properties are not allowed"); } const value = nodeOf(property.value); if (value) visit(value, depth + 1, bindings); }); return; case "UnaryExpression": if (!["!", "+", "-", "typeof"].includes(String(node.operator))) { add(node, `Unary operator '${String(node.operator)}' is not supported`); } if (nodeOf(node.argument)) visit(node.argument as AstNode, depth + 1, bindings); return; case "BinaryExpression": case "LogicalExpression": { if (node.type === "BinaryExpression" && !CUSTOM_JSX_BINARY_OPERATORS.has(String(node.operator))) { add(node, `Binary operator '${String(node.operator)}' is not supported`); } if (node.type === "BinaryExpression") { const statusDiagnostic = invalidRequestStatusComparison(node); if (statusDiagnostic) add(node, statusDiagnostic); } const left = nodeOf(node.left); const right = nodeOf(node.right); if (left) visit(left, depth + 1, bindings); if (right) visit(right, depth + 1, bindings); return; } case "ConditionalExpression": { [node.test, node.consequent, node.alternate].map(nodeOf).forEach((child) => { if (child) visit(child, depth + 1, bindings); }); return; } case "MemberExpression": { const object = nodeOf(node.object); const property = nodeOf(node.property); if (object) visit(object, depth + 1, bindings); if (node.computed && property) visit(property, depth + 1, bindings); const propertyName = node.computed ? staticPropertyName(property) : String(property?.name ?? property?.value ?? ""); if (propertyName && CUSTOM_JSX_BLOCKED_PROPERTIES.has(normalizeCustomJsxProperty(propertyName))) { add(property ?? node, "Reflective property access is not allowed"); } return; } case "CallExpression": { const callee = nodeOf(node.callee); const callArguments = nodesOf(node.arguments); if (node.optional) { add(node, "CALL_TARGET_NOT_ALLOWED: Optional calls are not supported"); } if (callee?.type === "ArrowFunctionExpression") { add(callee, "CALL_TARGET_NOT_ALLOWED: Inline IIFEs are not supported"); return; } if (callee) visit(callee, depth + 1, bindings); const property = callee?.type === "MemberExpression" ? nodeOf(callee.property) : null; const method = property?.type === "Identifier" ? String(property.name) : staticPropertyName(property); if ( callee && !isStaticallyCallableBinding(callee) && (callee.type !== "MemberExpression" || !method || !CUSTOM_JSX_SAFE_VALUE_METHODS.has(method)) ) { add(callee, "CALL_TARGET_NOT_ALLOWED: Only documented safe helpers and value methods can be called"); } if (method && CUSTOM_JSX_CALLBACK_METHODS.has(method)) { const callback = callArguments[0]; if (!callback) { add(node, `CALLBACK_VALUE_NOT_ALLOWED: '${method}' requires a callback`); } else if (["reduce", "sort"].includes(method) && callback.type !== "ArrowFunctionExpression") { add(callback, `CALLBACK_VALUE_NOT_ALLOWED: '${method}' requires an inline arrow callback`); } else if (callback.type !== "ArrowFunctionExpression" && !isStaticallyCallableBinding(callback)) { add(callback, `CALLBACK_VALUE_NOT_ALLOWED: '${method}' requires an inline arrow or safe helper callback`); } } callArguments.forEach((argument, argumentIndex) => { if (argument.type === "SpreadElement") add(argument, "Spread call arguments are not supported"); else if ( argument.type === "ArrowFunctionExpression" && argumentIndex === 0 && method && CUSTOM_JSX_CALLBACK_METHODS.has(method) ) { visitArrow(argument, depth + 1, bindings); } else if (argument.type === "ArrowFunctionExpression") { add(argument, "CALLBACK_VALUE_NOT_ALLOWED: Callback arguments are only allowed in the first callback slot"); } else visit(argument, depth + 1, bindings); }); return; } case "ArrowFunctionExpression": { add( node, "CALLBACK_VALUE_NOT_ALLOWED: Callbacks are only allowed in safe collection methods and trusted slots", ); return; } case "BlockStatement": add(node, "UNSUPPORTED_BLOCK_STATEMENT: Authored callbacks must use one expression"); return; case "AssignmentExpression": case "UpdateExpression": case "NewExpression": case "AwaitExpression": case "YieldExpression": add(node, `UNSUPPORTED_BLOCK_STATEMENT: '${node.type}' is not allowed in safe expressions`); return; case "TemplateLiteral": nodesOf(node.expressions).forEach((expression) => visit(expression, depth + 1, bindings)); return; case "ChainExpression": { const expression = nodeOf(node.expression); if (expression) visit(expression, depth + 1, bindings); return; } default: add(node, `Unsupported expression '${node.type}'`); } }; try { const program = JsxParser.parse(`<>${template}`, { ecmaVersion: "latest", sourceType: "module", locations: true, }) as unknown as AstNode; visit(program, 0, ROOT_BINDINGS); } catch (error) { diagnostics.push(createCustomJsxParseDiagnostic(template, error)); } return diagnostics; }