openpencil/tools/generate/visual-oracles/src/operations/interpret-instance.ts
Danila Poyarkov e2a3aa3f80
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

107 lines
4.4 KiB
TypeScript

import { mkdir } from 'node:fs/promises'
import { join } from 'node:path'
import { parseArgs } from 'node:util'
import { $ } from 'bun'
import { toUint8Array } from 'js-base64'
import * as v from 'valibot'
import { SkiaRenderer } from '@open-pencil/core/canvas'
import { initCanvasKit, renderNodesToImage } from '@open-pencil/core/io'
import { prepareGraphFonts } from '@open-pencil/core/text'
import { parseFigBuffer } from '@open-pencil/fig'
import {
interpretInstance,
materializeInstance,
type InstanceOccurrence,
type InstancePathDiagnostic
} from '@open-pencil/fig/instance-overrides'
import { nodeChangeToProps } from '@open-pencil/fig/node-change'
import { guidToString } from '@open-pencil/kiwi/fig/guid'
import { SceneGraph } from '@open-pencil/scene-graph'
// Render the interpreter output directly. No legacy import, export/reimport, or layout pass.
const { values } = parseArgs({
options: {
file: { type: 'string' },
node: { type: 'string' },
output: { type: 'string' },
scale: { type: 'string', default: '2' },
'figma-key': { type: 'string' }
}
})
if (!values.file || !values.node || !values.output)
throw new Error('Required: --file --node --output')
const scale = Number(values.scale)
if (!Number.isFinite(scale) || scale <= 0 || scale > 8)
throw new Error('Scale must be > 0 and <= 8')
await mkdir(values.output, { recursive: true })
const { nodeChanges, blobs, images } = parseFigBuffer(await Bun.file(values.file).arrayBuffer())
const diagnostics: InstancePathDiagnostic[] = []
const occurrence = interpretInstance(nodeChanges, values.node, {
derivedBounds: true,
onUnresolvedProperty: (d) => diagnostics.push(d)
})
const graph = new SceneGraph()
const page = graph.getPages()[0]
for (const [hash, bytes] of images) graph.images.set(hash, bytes)
const sources = new Map(
nodeChanges.flatMap((node) => (node.guid ? [[guidToString(node.guid), node] as const] : []))
)
const components = new Map<string, string>()
function prepare(node: InstanceOccurrence): void {
if (node.mainComponentId && !components.has(node.mainComponentId)) {
const source = sources.get(node.mainComponentId)
if (!source) throw new Error('Missing source component')
const { nodeType, ...props } = nodeChangeToProps(source, blobs)
if (nodeType !== 'COMPONENT') throw new Error('Expected terminal component')
components.set(node.mainComponentId, graph.createNode('COMPONENT', page.id, props).id)
}
node.children.forEach(prepare)
}
prepare(occurrence)
const result = materializeInstance(graph, page.id, occurrence, components, { blobs })
const bounds = () => [...result.nodes.values()].map((n) => [n.id, n.x, n.y, n.width, n.height])
const before = JSON.stringify(bounds())
const ck = await initCanvasKit()
const surface = ck.MakeSurface(1, 1)
if (!surface) throw new Error('No Skia surface')
const renderer = new SkiaRenderer(ck, surface)
try {
await renderer.loadFonts()
const fonts = await prepareGraphFonts(graph, [result.root.id])
if (!fonts.faithful) throw new Error(`Missing fonts: ${JSON.stringify(fonts)}`)
const png = renderNodesToImage(ck, renderer, graph, page.id, [result.root.id], {
scale,
format: 'PNG',
trimTransparent: false
})
if (!png) throw new Error('No rendered image')
if (before !== JSON.stringify(bounds()))
throw new Error('Render preparation changed occurrence bounds')
await Bun.write(join(values.output, 'interpreted.png'), png)
await Bun.write(
join(values.output, 'report.json'),
JSON.stringify(
{ file: values.file, node: values.node, scale, fonts, diagnostics, bounds: bounds() },
null,
2
)
)
} finally {
renderer.destroy()
}
if (values['figma-key']) {
const code = `if(figma.fileKey!==${JSON.stringify(values['figma-key'])})throw Error("Wrong Figma document");const n=await figma.getNodeByIdAsync(${JSON.stringify(values.node)});if(!n||!("exportAsync"in n))throw Error("Missing export node");return {png:figma.base64Encode(await n.exportAsync({format:"PNG",constraint:{type:"SCALE",value:${scale}}})),bounds:n.absoluteRenderBounds}`
const exported = v.parse(
v.pipe(v.string(), v.parseJson(), v.object({ png: v.string(), bounds: v.unknown() })),
await $`figma-use eval --json --timeout 60 ${code}`.text()
)
await Bun.write(join(values.output, 'figma.png'), toUint8Array(exported.png))
await Bun.write(
join(values.output, 'figma-bounds.json'),
JSON.stringify(exported.bounds, null, 2)
)
}
console.log(`Wrote direct-render artifacts to ${values.output}`)