84 lines
4.8 KiB
TypeScript
84 lines
4.8 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import { writeFile } from 'node:fs/promises'
|
|
import { performance } from 'node:perf_hooks'
|
|
import { strToU8, zipSync } from 'fflate'
|
|
import { inspectFcstdArchive } from '../src/facade/fcstd'
|
|
|
|
type FuzzCase = {
|
|
name: string
|
|
archive: Uint8Array
|
|
expected: 'accept' | 'reject'
|
|
limits?: Parameters<typeof inspectFcstdArchive>[1]
|
|
}
|
|
|
|
let state = 0x5f3759df
|
|
const random = () => {
|
|
state ^= state << 13
|
|
state ^= state >>> 17
|
|
state ^= state << 5
|
|
return state >>> 0
|
|
}
|
|
const token = () => `Object_${random().toString(16).padStart(8, '0')}`
|
|
const archive = (documentXml: string | Uint8Array, guiXml?: string, extra: Record<string, Uint8Array> = {}) => zipSync({
|
|
'Document.xml': typeof documentXml === 'string' ? strToU8(documentXml) : documentXml,
|
|
...(guiXml === undefined ? {} : { 'GuiDocument.xml': strToU8(guiXml) }),
|
|
...extra,
|
|
})
|
|
|
|
const createCase = (index: number): FuzzCase => {
|
|
const name = token()
|
|
switch (index % 13) {
|
|
case 0: return { name: 'empty-document', archive: archive('<Document/>'), expected: 'accept' }
|
|
case 1: return { name: 'proxy-object', archive: archive(`<Document SchemaVersion="4"><Objects><Object name="${name}" type="Vendor::Proxy"/></Objects><ObjectData><Object name="${name}"><Properties/></Object></ObjectData></Document>`), expected: 'accept' }
|
|
case 2: return { name: 'malformed-close', archive: archive('<Document><Objects></Document>'), expected: 'reject' }
|
|
case 3: return { name: 'wrong-document-root', archive: archive('<Objects/>'), expected: 'reject' }
|
|
case 4: return { name: 'duplicate-object-id', archive: archive(`<Document><Objects><Object name="${name}" type="Part::Feature"/><Object name="${name}" type="Part::Feature"/></Objects></Document>`), expected: 'reject' }
|
|
case 5: return { name: 'empty-object-id', archive: archive('<Document><Objects><Object name="" type="Part::Feature"/></Objects></Document>'), expected: 'reject' }
|
|
case 6: return { name: 'depth-limit', archive: archive(`<Document>${'<Group>'.repeat(12)}${'</Group>'.repeat(12)}</Document>`), expected: 'reject', limits: { maxXmlDepth: 8 } }
|
|
case 7: return { name: 'node-limit', archive: archive(`<Document>${Array.from({ length: 32 }, (_, item) => `<N${item}/>`).join('')}</Document>`), expected: 'reject', limits: { maxXmlNodes: 20 } }
|
|
case 8: return { name: 'entity-declaration', archive: archive('<!DOCTYPE Document [<!ENTITY x "boom">]><Document>&x;</Document>'), expected: 'reject' }
|
|
case 9: return { name: 'invalid-utf8', archive: archive(new Uint8Array([0x3c, 0x44, 0x6f, 0x63, 0x75, 0x6d, 0x65, 0x6e, 0x74, 0x3e, 0xc3, 0x28, 0x3c, 0x2f, 0x44, 0x6f, 0x63, 0x75, 0x6d, 0x65, 0x6e, 0x74, 0x3e])), expected: 'reject' }
|
|
case 10: return { name: 'invalid-gui-root', archive: archive('<Document/>', '<Views/>'), expected: 'reject' }
|
|
case 11: return { name: 'opaque-resource', archive: archive('<Document/>', '<GuiDocument/>', { [`Resources/${name}.bin`]: new Uint8Array([random() & 0xff, random() & 0xff]) }), expected: 'accept' }
|
|
default: return { name: 'traversal-path', archive: archive('<Document/>', undefined, { [`../${name}.bin`]: new Uint8Array([1]) }), expected: 'reject' }
|
|
}
|
|
}
|
|
|
|
const durations: number[] = []
|
|
const counts = { accepted: 0, rejected: 0, validCases: 0, invalidCases: 0 }
|
|
const categories = new Map<string, number>()
|
|
for (let index = 0; index < 1000; index += 1) {
|
|
const fuzzCase = createCase(index)
|
|
categories.set(fuzzCase.name, (categories.get(fuzzCase.name) ?? 0) + 1)
|
|
const started = performance.now()
|
|
let outcome: 'accept' | 'reject' = 'accept'
|
|
let caught: unknown
|
|
try {
|
|
inspectFcstdArchive(fuzzCase.archive, fuzzCase.limits)
|
|
} catch (error) {
|
|
outcome = 'reject'
|
|
caught = error
|
|
}
|
|
durations.push(performance.now() - started)
|
|
if (outcome === 'accept') counts.accepted += 1
|
|
else counts.rejected += 1
|
|
if (fuzzCase.expected === 'accept') counts.validCases += 1
|
|
else counts.invalidCases += 1
|
|
assert.equal(outcome, fuzzCase.expected, `FCStd fuzz category ${fuzzCase.name} produced ${outcome}, expected ${fuzzCase.expected}.`)
|
|
if (outcome === 'reject') assert.ok(caught instanceof Error, `FCStd fuzz category ${fuzzCase.name} rejected with a non-Error value.`)
|
|
}
|
|
|
|
durations.sort((left, right) => left - right)
|
|
const p95Ms = durations[Math.floor(durations.length * 0.95)]
|
|
assert.ok(p95Ms < 50, `FCStd parser fuzz p95 exceeded 50 ms: ${p95Ms.toFixed(3)} ms.`)
|
|
const report = {
|
|
status: 'fcstd-parser-fuzz-pass',
|
|
seed: '0x5f3759df',
|
|
cases: durations.length,
|
|
counts,
|
|
categories: Object.fromEntries([...categories].sort(([left], [right]) => left.localeCompare(right))),
|
|
timing: { p95Ms: Number(p95Ms.toFixed(3)), maxMs: Number(durations.at(-1)?.toFixed(3)) },
|
|
}
|
|
await writeFile(new URL('../config/qa04-fcstd-fuzz-verification.json', import.meta.url), `${JSON.stringify(report, null, 2)}\n`)
|
|
console.log(JSON.stringify(report, null, 2))
|