457 lines
15 KiB
TypeScript
457 lines
15 KiB
TypeScript
/**
|
||
* 表达式安全性测试(对应 §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)
|
||
})
|
||
})
|