* fix(tools): evaluate calc expressions without expr-eval expr-eval has an unpatched critical advisory for code execution through toJSFunction(), which compiles expressions with new Function (GHSA-q9v2-7m5w-4693). The advisory covers every published version, so `bun run check:audit` fails on every branch and `bun audit fix` has nothing to upgrade to. The calc tool only ever called evaluate(), so the advisory's own vector was not reachable, but the dependency stays flagged and the evaluator's surface was far wider than the tool documents: random(), factorials, trigonometry, constants, strings, array indexing, property access and statement sequences all evaluated, while the documented ** operator did not parse at all, since expr-eval spells power as ^. Replace it with a recursive-descent evaluator for exactly the documented grammar. It compiles nothing, reaches no host object, and fixes **, which is right-associative and binds tighter than a leading sign, so -2 ** 2 is -4 as in ordinary notation. Non-finite results are still reported by the tool rather than the evaluator, so 1 / 0 keeps its "Produced Infinity" message. The tool had no tests; both the evaluator and the tool's JSON-array and error-reporting paths are covered now. * refactor(tools): parse calc expressions with jsep Replace the hand-written tokenizer and recursive-descent parser with jsep, a maintained zero-dependency expression parser with no advisory history, and keep only the tree walk: an allowlist of node types, arithmetic operators and the documented functions. Parsing, where the vulnerabilities in this class of library live, is no longer ours to maintain. Behaviour follows the parser rather than the previous hand-written precedence, so a leading sign now binds tighter than '**' and '-2 ** 2' is 4; the tests pin that alongside right-associativity. Parse errors keep jsep's own wording and character positions. * fix(tools): reject inherited names and fold long calc argument lists Two findings from review of this branch. The function lookup used `in`, so an inherited key such as `constructor(1)` passed the guard and then failed while destructuring a missing arity, reporting a TypeError instead of an unknown function; it now uses Object.hasOwn. min and max spread their arguments, which overflows the call stack on V8 at roughly 125k arguments, so they fold instead. Both paths are covered by tests.
196 lines
6.5 KiB
TypeScript
196 lines
6.5 KiB
TypeScript
import { describe, expect, test } from 'bun:test'
|
|
|
|
import {
|
|
CALC_FUNCTIONS,
|
|
CalcSyntaxError,
|
|
evaluateExpression
|
|
} from '@open-pencil/core/tools/calc/expression'
|
|
|
|
describe('calc expression evaluator', () => {
|
|
test.each([
|
|
['844 - 56 - 96 - 82', 610],
|
|
['1440 * 8 / 12', 960],
|
|
['(952 - 16) / 2', 468],
|
|
[' 8 * 4 ', 32],
|
|
['2 + 2 * 2', 6],
|
|
['(2 + 2) * 2', 8],
|
|
['7 / 2', 3.5],
|
|
['10 % 3', 1],
|
|
['-10 % 3', -1],
|
|
['1e3', 1000],
|
|
['1E3 + 1', 1001],
|
|
['.5 + 1', 1.5],
|
|
['1.', 1],
|
|
['2.5e-2', 0.025]
|
|
])('evaluates %s', (expression, expected) => {
|
|
expect(evaluateExpression(expression)).toBe(expected)
|
|
})
|
|
|
|
test.each([
|
|
['-5', -5],
|
|
['+5', 5],
|
|
['3 - -2', 5],
|
|
['3 * -2', -6],
|
|
['-(2 + 3)', -5]
|
|
])('applies the sign in %s', (expression, expected) => {
|
|
expect(evaluateExpression(expression)).toBe(expected)
|
|
})
|
|
|
|
describe('exponentiation', () => {
|
|
test('is right-associative', () => {
|
|
expect(evaluateExpression('2 ** 3 ** 2')).toBe(512)
|
|
})
|
|
|
|
// jsep binds a leading sign tighter than `**`, so `-2 ** 2` is `(-2) ** 2`.
|
|
// Pinned because Python and ordinary notation instead read it as `-(2 ** 2)`.
|
|
test('applies a leading sign before the exponent', () => {
|
|
expect(evaluateExpression('-2 ** 2')).toBe(4)
|
|
expect(evaluateExpression('(-2) ** 2')).toBe(4)
|
|
expect(evaluateExpression('-(2 ** 2)')).toBe(-4)
|
|
})
|
|
|
|
test('binds tighter than multiplication and accepts a signed exponent', () => {
|
|
expect(evaluateExpression('3 * 2 ** 3')).toBe(24)
|
|
expect(evaluateExpression('2 ** -2')).toBe(0.25)
|
|
})
|
|
})
|
|
|
|
describe('functions', () => {
|
|
test.each([
|
|
['floor(390 * 0.6)', 234],
|
|
['ceil(1.2)', 2],
|
|
['round(2.5)', 3],
|
|
['round(-2.5)', -2],
|
|
['abs(-4)', 4],
|
|
['sqrt(16)', 4],
|
|
['pow(2, 10)', 1024],
|
|
['min(3, 1, 2)', 1],
|
|
['max(3, 1, 2)', 3],
|
|
['min(3)', 3],
|
|
['max(1 + 1, 3 - 2)', 2],
|
|
['floor(min(4.7, 9))', 4]
|
|
])('evaluates %s', (expression, expected) => {
|
|
expect(evaluateExpression(expression)).toBe(expected)
|
|
})
|
|
|
|
test('rejects a wrong argument count', () => {
|
|
expect(() => evaluateExpression('round(2.345, 2)')).toThrow(/takes 1 argument, received 2/)
|
|
expect(() => evaluateExpression('pow(2)')).toThrow(/takes 2 arguments, received 1/)
|
|
})
|
|
|
|
test.each(['min', 'max'])('folds a long %s argument list without spreading it', (name) => {
|
|
// Spreading overflows the call stack on V8 at roughly 125k arguments.
|
|
const args = Array.from({ length: 200_000 }, (_, index) => index)
|
|
expect(evaluateExpression(`${name}(${args.join(',')})`)).toBe(name === 'min' ? 0 : 199_999)
|
|
})
|
|
|
|
test('names the supported functions when one is unknown', () => {
|
|
expect(() => evaluateExpression('sin(0)')).toThrow(/Unknown function 'sin'/)
|
|
for (const name of CALC_FUNCTIONS) {
|
|
expect(() => evaluateExpression('sin(0)')).toThrow(new RegExp(`\\b${name}\\b`))
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('results outside the real numbers', () => {
|
|
test.each([
|
|
['1 / 0', Number.POSITIVE_INFINITY],
|
|
['-1 / 0', Number.NEGATIVE_INFINITY]
|
|
])('returns %s as a non-finite number for the caller to report', (expression, expected) => {
|
|
expect(evaluateExpression(expression)).toBe(expected)
|
|
})
|
|
|
|
test.each(['0 / 0', 'sqrt(-1)', '0 % 0'])('returns NaN for %s', (expression) => {
|
|
expect(evaluateExpression(expression)).toBeNaN()
|
|
})
|
|
})
|
|
|
|
describe('input the evaluator refuses', () => {
|
|
test.each([
|
|
['', 'Expression is empty'],
|
|
[' ', 'Expression is empty'],
|
|
['1 +', 'Expected expression after +'],
|
|
['(1 + 2', 'Unclosed ('],
|
|
['1 + 2)', 'Unexpected ")"'],
|
|
['pow(2, 10', 'Expected )'],
|
|
['5!', 'missing unaryOp argument'],
|
|
['1_000 + 1', 'Variable names cannot start with a number'],
|
|
['0x10', 'Variable names cannot start with a number'],
|
|
['{"a": 1}', 'Unexpected "{"']
|
|
])("reports the parser's own message for %s", (expression, message) => {
|
|
expect(() => evaluateExpression(expression)).toThrow(CalcSyntaxError)
|
|
expect(() => evaluateExpression(expression)).toThrow(message)
|
|
})
|
|
|
|
test.each([
|
|
['1 2', 'more than one expression'],
|
|
['1 ; 2', 'more than one expression'],
|
|
['3 and 4', 'more than one expression'],
|
|
['floor 1', 'more than one expression'],
|
|
['x + 1', 'a name'],
|
|
['PI', 'a name'],
|
|
['12 + $', 'a name'],
|
|
['a.b', 'property access'],
|
|
['[1,2][0]', 'property access'],
|
|
['1 ? 2 : 3', 'a conditional']
|
|
])('names the construct it refuses in %s', (expression, description) => {
|
|
expect(() => evaluateExpression(expression)).toThrow(CalcSyntaxError)
|
|
expect(() => evaluateExpression(expression)).toThrow(description)
|
|
})
|
|
|
|
test.each([
|
|
['2 < 3', "Unsupported operator '<'"],
|
|
['1 == 1', "Unsupported operator '=='"],
|
|
['1 || 2', "Unsupported operator '||'"],
|
|
['3 & 4', "Unsupported operator '&'"]
|
|
])('refuses the non-arithmetic operator in %s', (expression, message) => {
|
|
expect(() => evaluateExpression(expression)).toThrow(message)
|
|
})
|
|
|
|
test.each([
|
|
['"a" + "b"', 'is not a number'],
|
|
['true', "'true' is not a number"],
|
|
['null', "'null' is not a number"]
|
|
])('refuses the non-numeric literal in %s', (expression, message) => {
|
|
expect(() => evaluateExpression(expression)).toThrow(message)
|
|
})
|
|
|
|
test.each([
|
|
['random()', "Unknown function 'random'"],
|
|
['if(1, 2, 3)', "Unknown function 'if'"],
|
|
['sin(0)', "Unknown function 'sin'"]
|
|
])('refuses the undocumented function in %s', (expression, message) => {
|
|
expect(() => evaluateExpression(expression)).toThrow(message)
|
|
})
|
|
|
|
test('keeps the character position the parser reports', () => {
|
|
expect(() => evaluateExpression('(1 + 2')).toThrow('character 6')
|
|
})
|
|
|
|
test.each([
|
|
'constructor(1)',
|
|
'toString(1)',
|
|
'valueOf(1)',
|
|
'hasOwnProperty(1)',
|
|
'__defineGetter__(1)'
|
|
])('refuses %s, an inherited name rather than a function', (expression) => {
|
|
expect(() => evaluateExpression(expression)).toThrow(CalcSyntaxError)
|
|
expect(() => evaluateExpression(expression)).toThrow('Unknown function')
|
|
})
|
|
})
|
|
|
|
test.each([
|
|
'constructor',
|
|
'globalThis',
|
|
'this',
|
|
'process',
|
|
'Math',
|
|
'Math.random()',
|
|
'Function("return 1")()',
|
|
'__proto__',
|
|
'constructor.constructor("return 1")()'
|
|
])('evaluates %s without reaching a host object', (expression) => {
|
|
expect(() => evaluateExpression(expression)).toThrow(CalcSyntaxError)
|
|
})
|
|
})
|