/** * 表达式安全性测试(对应 §4.2、§10、§14 的"表达式安全性测试")。 * * 这是本包最重要的测试:受限求值器必须无法执行任意 JavaScript, * 也无法通过原型链或作用域外标识符逃逸。 */ import { describe, expect, it } from "vitest" import { parse } from "../src/parser.js" import { evaluateExpression, evaluateValue, compile, evaluateCompiled } from "../src/evaluator.js" import { tokenize } from "../src/tokenizer.js" import type { EvalScope } from "../src/scope.js" const scope: EvalScope = { row: { amount: 100, price: 3, quantity: 4, name: "张三", empty: null }, params: { tax: 0.1, customerName: "李四" }, index: 0, rowNumber: 1, } describe("禁止任意代码执行(§4.2)", () => { it("不允许调用未登记的函数", () => { const result = parse("eval('1+1')") expect(result.ast).toBeNull() expect(result.error?.message).toContain("不允许调用函数") }) it("eval 在解析期即被拒绝", () => { expect(parse("eval(\"alert(1)\")").ast).toBeNull() }) it("Function 构造器不可用", () => { expect(parse("Function('return 1')()").ast).toBeNull() }) it("setTimeout 等宿主 API 不可用", () => { expect(parse("setTimeout('x', 1)").ast).toBeNull() }) it("require / import 不可用", () => { expect(parse("require('fs')").ast).toBeNull() expect(parse("import('fs')").ast).toBeNull() }) it("无法通过 constructor 逃逸", () => { const value = evaluateValue("row.constructor", scope) expect(value).toBeUndefined() }) it("无法通过 __proto__ 逃逸", () => { expect(evaluateValue("row.__proto__", scope)).toBeUndefined() expect(evaluateValue("row['__proto__']", scope)).toBeUndefined() }) it("无法通过 prototype 逃逸", () => { expect(evaluateValue("row.prototype", scope)).toBeUndefined() }) it("constructor.constructor 组合逃逸失败", () => { expect(evaluateValue("row.constructor.constructor", scope)).toBeUndefined() }) it("全局对象标识符解析为 undefined", () => { for (const name of ["window", "globalThis", "process", "document", "global"]) { expect(evaluateValue(name, scope)).toBeUndefined() } }) it("模板字符串语法不被支持", () => { expect(parse("`${row.amount}`").ast).toBeNull() }) it("箭头函数语法不被支持", () => { expect(parse("(x) => x").ast).toBeNull() }) it("赋值表达式不被支持", () => { expect(parse("row.amount = 1").ast).toBeNull() }) it("不支持语句分隔与多表达式", () => { expect(parse("1; 2").ast).toBeNull() }) it("不支持注释语法", () => { expect(parse("1 // comment").ast).toBeNull() }) it("不允许成员方法调用", () => { // row.name.toUpperCase() 中 callee 不是简单标识符,解析失败 expect(parse("row.name.toUpperCase()").ast).toBeNull() }) it("字符串不提供任何方法", () => { // "张三" 是两个字符 expect(evaluateValue("row.name.length", scope)).toBe(2) expect(evaluateValue("row.name.toUpperCase", scope)).toBeUndefined() }) it("数组不提供任何方法", () => { const s: EvalScope = { row: { list: [1, 2, 3] } } expect(evaluateValue("row.list.length", s)).toBe(3) expect(evaluateValue("row.list.map", s)).toBeUndefined() }) it("new 关键字不被支持", () => { expect(parse("new Date()").ast).toBeNull() }) it("不支持 this", () => { expect(evaluateValue("this", scope)).toBeUndefined() }) }) describe("作用域白名单", () => { it("只认识约定的根名", () => { expect(evaluateValue("row.amount", scope)).toBe(100) expect(evaluateValue("params.tax", scope)).toBe(0.1) expect(evaluateValue("index", scope)).toBe(0) expect(evaluateValue("rownumber", scope)).toBe(1) }) it("未知根名返回 undefined", () => { expect(evaluateValue("nonexistent.field", scope)).toBeUndefined() }) it("extra 中的自定义根可访问", () => { const s: EvalScope = { extra: { custom: { x: 7 } } } expect(evaluateValue("custom.x", s)).toBe(7) }) it("extra 的 Object.prototype 属性不可访问", () => { expect(evaluateValue("toString", { extra: {} })).toBeUndefined() }) }) describe("tokenizer", () => { it("识别数字", () => { const { tokens } = tokenize("1 + 2.5 + 1e3") const nums = tokens.filter((t) => t.type === "number").map((t) => t.numberValue) expect(nums).toEqual([1, 2.5, 1000]) }) it("拒绝小数点后无数字", () => { expect(tokenize("1.").error?.message).toContain("小数点后必须有数字") }) it("识别字符串与转义", () => { const { tokens } = tokenize("'a\\nb'") expect(tokens[0]?.stringValue).toBe("a\nb") }) it("拒绝未闭合字符串", () => { expect(tokenize("'abc").error?.message).toContain("未闭合") }) it("拒绝跨行字符串", () => { expect(tokenize("'a\nb'").error?.message).toContain("不能跨行") }) it("识别中文标识符", () => { const { tokens } = tokenize("row.客户名称") expect(tokens.some((t) => t.value === "客户名称")).toBe(true) }) it("运算符最长匹配", () => { const { tokens } = tokenize("a >= b") expect(tokens.some((t) => t.value === ">=")).toBe(true) }) it("拒绝非法字符", () => { expect(tokenize("a # b").error?.message).toContain("无法识别的字符") }) it("记录行列位置", () => { const { tokens } = tokenize("a +\n b") const b = tokens.find((t) => t.value === "b") expect(b?.line).toBe(1) expect(b?.column).toBe(2) }) }) describe("parser 结构与错误", () => { it("拒绝空表达式", () => { expect(parse("").error?.message).toContain("表达式为空") expect(parse(" ").error?.message).toContain("表达式为空") }) it("括号必须闭合", () => { expect(parse("(1 + 2").error?.message).toContain("未闭合") }) it("三元必须有冒号", () => { expect(parse("1 ? 2").error?.message).toContain('缺少 ":"') }) it("拒绝多余内容", () => { expect(parse("1 2").error?.message).toContain("多余内容") }) it("运算符优先级正确", () => { expect(evaluateValue("1 + 2 * 3", scope)).toBe(7) expect(evaluateValue("(1 + 2) * 3", scope)).toBe(9) }) it("比较与逻辑优先级正确", () => { expect(evaluateValue("1 + 1 == 2 && 3 > 1", scope)).toBe(true) }) it("成员访问右侧必须是标识符", () => { expect(parse("row.1").error?.message).toContain("必须是标识符") }) it("下标必须闭合", () => { expect(parse("row[0").error?.message).toContain('缺少 "]"') }) }) describe("运算语义", () => { it("算术运算", () => { expect(evaluateValue("row.price * row.quantity", scope)).toBe(12) expect(evaluateValue("10 / 4", scope)).toBe(2.5) expect(evaluateValue("10 % 3", scope)).toBe(1) }) it("字符串拼接", () => { expect(evaluateValue("'a' + 'b'", scope)).toBe("ab") expect(evaluateValue("row.name + '先生'", scope)).toBe("张三先生") }) it("数字与字符串相加按拼接处理", () => { expect(evaluateValue("1 + '2'", scope)).toBe("12") }) it("除零返回 null 并产生诊断", () => { const result = evaluateExpression("1 / 0", { scope }) expect(result.value).toBeNull() expect(result.diagnostics.some((d) => d.message.includes("除数为 0"))).toBe(true) }) it("null 参与算术视为 0", () => { expect(evaluateValue("row.empty + 1", scope)).toBe(1) }) it("空值不产生 NaN", () => { const result = evaluateExpression("row.empty * 2", { scope }) expect(Number.isNaN(result.value as number)).toBe(false) }) it("一元运算", () => { expect(evaluateValue("-5", scope)).toBe(-5) expect(evaluateValue("!true", scope)).toBe(false) expect(evaluateValue("!row.empty", scope)).toBe(true) }) it("比较运算", () => { expect(evaluateValue("2 > 1", scope)).toBe(true) expect(evaluateValue("'a' < 'b'", scope)).toBe(true) expect(evaluateValue("2 >= 2", scope)).toBe(true) }) it("相等比较", () => { expect(evaluateValue("1 == 1", scope)).toBe(true) expect(evaluateValue("1 == '1'", scope)).toBe(true) expect(evaluateValue("1 === '1'", scope)).toBe(false) expect(evaluateValue("null == null", scope)).toBe(true) }) it("逻辑运算短路", () => { // 右侧会触发除零诊断;短路后不应产生该诊断 const result = evaluateExpression("false && (1 / 0)", { scope }) expect(result.diagnostics).toEqual([]) }) it("?? 只在空值时取右侧", () => { expect(evaluateValue("row.empty ?? 'fallback'", scope)).toBe("fallback") expect(evaluateValue("row.amount ?? 0", scope)).toBe(100) }) it("三元条件", () => { expect(evaluateValue("row.amount > 0 ? '有' : '无'", scope)).toBe("有") expect(evaluateValue("row.amount < 0 ? '有' : '无'", scope)).toBe("无") }) it("嵌套成员访问", () => { const s: EvalScope = { row: { a: { b: { c: 42 } } } } expect(evaluateValue("row.a.b.c", s)).toBe(42) }) it("下标访问数组", () => { const s: EvalScope = { row: { list: [10, 20] } } expect(evaluateValue("row.list[1]", s)).toBe(20) expect(evaluateValue("row.list[9]", s)).toBeUndefined() }) it("越界下标返回 undefined 而非报错", () => { const result = evaluateExpression("row.list[9]", { scope: { row: { list: [1] } } }) expect(result.diagnostics.filter((d) => d.severity === "error")).toEqual([]) }) }) describe("内置函数", () => { it("if 惰性求值,不执行未命中分支", () => { const result = evaluateExpression("if(true, 1, 1 / 0)", { scope }) expect(result.value).toBe(1) expect(result.diagnostics).toEqual([]) }) it("if 命中 else 分支", () => { expect(evaluateValue("if(false, 'a', 'b')", scope)).toBe("b") }) it("and / or / not", () => { expect(evaluateValue("and(true, true)", scope)).toBe(true) expect(evaluateValue("and(true, false)", scope)).toBe(false) expect(evaluateValue("or(false, true)", scope)).toBe(true) expect(evaluateValue("not(false)", scope)).toBe(true) }) it("concat", () => { expect(evaluateValue("concat('a', 'b', 'c')", scope)).toBe("abc") expect(evaluateValue("concat(row.empty, 'x')", scope)).toBe("x") }) it("round / floor / ceil / abs", () => { expect(evaluateValue("round(1.234, 2)", scope)).toBe(1.23) expect(evaluateValue("round(1.5)", scope)).toBe(2) expect(evaluateValue("floor(1.9)", scope)).toBe(1) expect(evaluateValue("ceil(1.1)", scope)).toBe(2) expect(evaluateValue("abs(-3)", scope)).toBe(3) }) it("min / max 忽略非数字", () => { expect(evaluateValue("min(3, 1, 2)", scope)).toBe(1) expect(evaluateValue("max(3, 1, 2)", scope)).toBe(3) expect(evaluateValue("min('a', 5)", scope)).toBe(5) }) it("len / upper / lower / trim", () => { expect(evaluateValue("len('abc')", scope)).toBe(3) expect(evaluateValue("upper('ab')", scope)).toBe("AB") expect(evaluateValue("lower('AB')", scope)).toBe("ab") expect(evaluateValue("trim(' a ')", scope)).toBe("a") }) it("substr", () => { expect(evaluateValue("substr('abcdef', 2)", scope)).toBe("cdef") expect(evaluateValue("substr('abcdef', 2, 3)", scope)).toBe("cde") }) it("replace 只做字面量替换", () => { expect(evaluateValue("replace('a.b.c', '.', '-')", scope)).toBe("a-b-c") }) it("replace 不接受正则元字符作为模式", () => { // 正则语义下 '.' 应匹配任意字符,这里必须只匹配字面点 expect(evaluateValue("replace('abc', '.', 'X')", scope)).toBe("abc") }) it("isnull / coalesce", () => { expect(evaluateValue("isnull(row.empty)", scope)).toBe(true) expect(evaluateValue("isnull(row.amount)", scope)).toBe(false) expect(evaluateValue("coalesce(row.empty, '', 'x')", scope)).toBe("x") expect(evaluateValue("coalesce(row.amount, 0)", scope)).toBe(100) }) it("number 转失败返回 null 而非 NaN", () => { expect(evaluateValue("number('12')", scope)).toBe(12) expect(evaluateValue("number('abc')", scope)).toBeNull() }) it("string 对象不隐式序列化", () => { expect(evaluateValue("string(row)", scope)).toBe("") }) it("format 日期", () => { expect(evaluateValue("format('2024-03-05T08:09:10Z', 'YYYY-MM-DD')", scope)).toBe("2024-03-05") }) it("format 数字千分位", () => { expect(evaluateValue("format(1234567.891, '#,##0.00')", scope)).toBe("1,234,567.89") }) it("format 百分比", () => { expect(evaluateValue("format(0.1234, '0.0%')", scope)).toBe("12.3%") }) it("format 负数千分位", () => { expect(evaluateValue("format(-1234.5, '#,##0.00')", scope)).toBe("-1,234.50") }) it("date 返回可序列化的 ISO 字符串", () => { const value = evaluateValue("date('2024-03-05T08:09:10Z')", scope) expect(typeof value).toBe("string") expect(value).toBe("2024-03-05T08:09:10.000Z") }) it("表达式结果永远是纯数据(不含 Date 对象)", () => { const value = evaluateValue("date('2024-01-01')", scope) expect(value instanceof Date).toBe(false) }) }) describe("错误处理(§10 单个字段错误不影响整体)", () => { it("解析失败返回诊断而非抛异常", () => { const result = evaluateExpression("1 +", { scope }) expect(result.value).toBeNull() expect(result.diagnostics).toHaveLength(1) expect(result.diagnostics[0]?.code).toBe("expression.parse-error") }) it("诊断携带元素 ID 与路径", () => { const result = evaluateExpression("1 +", { scope, elementId: "el-1", path: "/sections/1/children/0/binding/expression", }) expect(result.diagnostics[0]?.elementId).toBe("el-1") expect(result.diagnostics[0]?.path).toBe("/sections/1/children/0/binding/expression") }) it("函数内部异常被捕获", () => { const result = evaluateExpression("round('abc')", { scope }) expect(result.diagnostics.filter((d) => d.severity === "error")).toEqual([]) }) it("诊断不包含原始表达式外的敏感内容", () => { const result = evaluateExpression("nosuchfn(1)", { scope }) expect(result.diagnostics[0]?.message).toBeTruthy() }) }) describe("编译与缓存", () => { it("compile 返回可复用的 AST", () => { const c = compile("row.amount + 1") expect(c.ast).not.toBeNull() expect(c.error).toBeNull() }) it("compile 记录解析错误", () => { const c = compile("1 +") expect(c.ast).toBeNull() expect(c.error).toBeTruthy() }) it("evaluateCompiled 复用 AST", () => { const c = compile("row.amount * 2") expect(evaluateCompiled(c, { scope }).value).toBe(200) expect(evaluateCompiled(c, { scope: { row: { amount: 5 } } }).value).toBe(10) }) it("evaluateCompiled 对坏表达式返回诊断", () => { const result = evaluateCompiled(compile("1 +"), { scope }) expect(result.value).toBeNull() expect(result.diagnostics).toHaveLength(1) }) })