* 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
107 lines
4.4 KiB
TypeScript
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}`)
|