openpencil/tests/engine/tools/calc/tool.test.ts

67 lines
2.3 KiB
TypeScript
Raw Normal View History

fix(tools): evaluate calc expressions without expr-eval (#753) * 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.
2026-09-24 21:44:46 +00:00
import { describe, expect, test } from 'bun:test'
import { getTool, setupToolTest, type ToolResult } from '#tests/helpers/tools'
function run(expr: string): ToolResult {
const { figma } = setupToolTest()
return getTool('calc').execute(figma, { expr }) as ToolResult
}
describe('calc tool', () => {
test('returns a single result for one expression', () => {
expect(run('844 - 56 - 96 - 82')).toEqual({ expr: '844 - 56 - 96 - 82', result: 610 })
})
test('evaluates a JSON array in one call, preserving order', () => {
expect(run('["1440 * 8 / 12", "(952 - 16) / 2", "floor(390 * 0.6)"]')).toEqual({
results: [
{ expr: '1440 * 8 / 12', result: 960 },
{ expr: '(952 - 16) / 2', result: 468 },
{ expr: 'floor(390 * 0.6)', result: 234 }
]
})
})
test('treats a single-element array like a bare expression', () => {
expect(run('["2 + 2"]')).toEqual({ expr: '2 + 2', result: 4 })
})
test('reports the documented ** operator', () => {
expect(run('2 ** 10')).toEqual({ expr: '2 ** 10', result: 1024 })
})
test('reports a non-finite result as an error rather than a number', () => {
expect(run('1 / 0')).toEqual({ expr: '1 / 0', error: 'Produced Infinity' })
expect(run('0 / 0')).toEqual({ expr: '0 / 0', error: 'Produced NaN' })
})
test('reports a rejected expression without failing its siblings', () => {
const result = run('["2 + 2", "2 +", "3 * 3"]')
expect(result.results).toEqual([
{ expr: '2 + 2', result: 4 },
{ expr: '2 +', error: 'Expected expression after + at character 3' },
{ expr: '3 * 3', result: 9 }
])
})
test('evaluates text that only looks like JSON as an expression', () => {
expect(run('2 + 2')).toEqual({ expr: '2 + 2', result: 4 })
expect((run('{"a": 1}') as { error: string }).error).toContain('Unexpected "{"')
})
fix: validate parsed JSON at untrusted boundaries with Valibot (#855) * fix: validate parsed JSON at untrusted boundaries with Valibot Clipboard HTML, library revisions from shared storage, MCP and automation WebSocket messages, the MCP discovery file, sidecar output and AI/MCP tool arguments were JSON.parse'd and cast to their expected types, so a malformed payload reached the document or crashed paste. They now go through v.pipe(v.string(), v.parseJson(), Schema), which reports bad JSON and a wrong shape as the same validation failure. The path_set tool rejects an invalid VectorNetwork and shares its parser with create_vector. The CLI library catalog validates its files and runs revisions through the same size, identity and content-hash checks as the app; reading image bytes as index-keyed records also stops them coming back empty. Hand-rolled typeof readers for plugin data, document metadata, caches and preferences become schemas with their behaviour preserved, and readCacheJSON takes a schema for its payload. open-pencil/no-unvalidated-json-parse rejects type assertions on JSON.parse results other than `as unknown` in src and packages/*/src. * refactor: validate parsed JSON in tests and tooling Extend open-pencil/no-unvalidated-json-parse beyond source: tests, helpers and repo tooling now parse JSON through Valibot schemas instead of asserting a type. The shared fixture reader returns a validated object; its old array annotation never matched the fixtures. * fix: validate clipboard geometry bytes, library images and model catalogs Clipboard geometry blobs and library image bytes must be bytes at contiguous indexes, so out-of-range or gapped values are rejected instead of silently becoming different geometry or images; serialized library nodes must carry source metadata. The models.dev and OpenRouter responses are validated like their cached copies, and activate-tab rejects a CDP frame it cannot read instead of hanging. * refactor: extend the JSON validation lint to .json() results no-unvalidated-json-parse now also rejects type assertions on Response, Bun.file and shell .json() results, the same unchecked parse in another form. MCP server tests read /health through a validated readHealth helper and discovery files through parseDiscoveryInfo; the remaining tooling reads its JSON through schemas. * test: validate the RPC request body in the CLI app export test * test: validate CLI JSON output in the tool and app command tests * test: compare the malformed models.dev fallback with the curated list
2026-10-04 17:01:50 +00:00
test('rejects a JSON array that contains anything but expressions', () => {
expect(run('["2 + 2", 3]')).toEqual({
expr: '["2 + 2", 3]',
error: 'A JSON array of expressions must contain only strings'
})
})
fix(tools): evaluate calc expressions without expr-eval (#753) * 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.
2026-09-24 21:44:46 +00:00
test('advertises exactly the functions it supports', () => {
const { description } = getTool('calc')
for (const name of ['min', 'max', 'floor', 'ceil', 'round', 'abs', 'sqrt', 'pow']) {
expect(description).toContain(name)
}
expect(description).toContain('**')
})
})