/** * rigor/snapshot-adversarial-fuzz.test.js — greybox-fuzzer campaign over * malformed snapshot buffers. * * The needle class this hunts: silently-accepted corruption in * deserializeArbiterSnapshot / CondensedGraphBinary reads. A snapshot is * caller-supplied restore input, so every read must either succeed into a * structurally sound graph or throw a clean, bounded error — never an * internal TypeError, never a graph whose edges point outside the node * table, never a hang or oversized allocation. * * Three pinned oracle layers: * 1. Error whitelist — failures must be clean (Error/RangeError/SyntaxError * from the guards, JSON, or structural gates), never "Cannot read * properties..."-style internal crashes. * 2. Structural integrity on success — every edge endpoint in range, * node keys unique and non-null, and a post-restore smoke check() * never crashes. * 3. Hand-crafted directed needles — truncations, corrupt length fields, * legacy version, non-object JSON payloads — documented behaviors, * deterministic. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { rigor } from '@rigor/core'; import { Arbiter } from '../../src/index.js'; import { serializeArbiterSnapshot } from '../../src/core/SnapshotBinary.js'; function buildSeed() { const arbiter = new Arbiter(); arbiter.addNode('u:1', 'user'); arbiter.addNode('u:2', 'user'); arbiter.addNode('g:2', 'group'); arbiter.addNode('doc:9', 'doc'); arbiter.setRelationConfig('owner', { type: 'direct' }); arbiter.setRelationConfig('member_of', { type: 'direct' }); arbiter.addRelation('u:1', 'owner', 'doc:9', { possibility: 0.9 }); arbiter.addRelation('u:1', 'member_of', 'g:2', { possibility: 0.7 }); arbiter.addRelation('u:2', 'member_of', 'g:2', { possibility: 0.8 }); arbiter.addRelation('g:2', 'owner', 'doc:9', { possibility: 1.0 }); arbiter.valueManager.setTTL('member_of', 30 * 24 * 60 * 60 * 1000); arbiter.enableCondensedSnapshot(); return serializeArbiterSnapshot(arbiter); } const SEED = buildSeed(); const FACTORY = () => new Arbiter(); // Buffer.from(ArrayBuffer) is a shared VIEW, not a copy — every mutation // must copy first or the module-scope seed gets corrupted under node:test's // parallel execution. const copySeed = () => Buffer.from(new Uint8Array(SEED)); const readBuffer = (bytes) => { return Arbiter.fromSnapshotBinary(Buffer.from(bytes)); }; const STRUCTURAL = (graph) => { const nodeIds = graph.nodeIds; if (!nodeIds) return 'graph has no node table'; const count = nodeIds.length; if (count < 0) return 'negative node count'; const seen = new Set(); for (let i = 0; i < count; i++) { const key = graph.getNodeKey(i); if (key === null || key === undefined) return `node ${i} has no key`; if (seen.has(key)) return `duplicate node key ${key}`; seen.add(key); } // Raw edge arrays: snapshot graphs carry a relation-specific wavelet, so // getOutEdges is not available — the arrays ARE the integrity surface. const srcs = graph.edgeSrcIds; const dsts = graph.edgeDstIds; const rels = graph.edgeRelIds; if (srcs && dsts && rels) { const n = srcs.length; if (dsts.length !== n || rels.length !== n) return 'edge array length mismatch'; for (let i = 0; i < n; i++) { if (srcs[i] < 0 || srcs[i] >= count) return `out-of-range src ${srcs[i]}`; if (dsts[i] < 0 || dsts[i] >= count) return `out-of-range dst ${dsts[i]}`; if (rels[i] < 0) return `negative rel ${rels[i]}`; } } return null; }; const CLEAN_ERROR = /Invalid|Unsupported|past end of buffer|out of range|Unexpected|parse|not valid|mismatch|must be an|JSON|Expected/i; const INTERNAL_CRASH = /Cannot read propert|is not a function|is not iterable|Cannot use/; describe('Adversarial snapshot reads (directed needles)', () => { const needles = []; const mk = (name, mutate, expect = /Error/) => needles.push({ name, mutate, expect }); mk('empty buffer', (b) => b.subarray(0, 0), /past end of buffer|Invalid/i); mk('one byte', (b) => b.subarray(0, 1), /past end of buffer|Invalid/i); mk('header only', (b) => b.subarray(0, 8), /past end of buffer|Invalid/i); mk('truncate inside graph section', (b) => b.subarray(0, 14), /past end of buffer|Invalid|out of range/i); mk('truncate inside json section', (b) => b.subarray(0, b.length - 7), /past end of buffer|Invalid|Unexpected|parse/i); mk('bad magic', (b) => { const c = Buffer.from(b); c[0] = 0xde; c[1] = 0xad; return c; }, /Invalid arbiter snapshot/); mk('legacy version 1', (b) => { const c = Buffer.from(b); c[5] = 1; return c; }, /Unsupported arbiter snapshot version/); mk('graph length max uint32', (b) => { const c = Buffer.from(b); c.writeUInt32LE(0xffffffff, 8); return c; }, /past end of buffer|out of range/i); mk('json length max uint32', (b) => { const c = Buffer.from(b); const gl = c.readUInt32LE(8); c.writeUInt32LE(0xffffffff, 12 + gl); return c; }, /past end of buffer|parse/i); mk('json payload is null', (b) => { const c = Buffer.from(b); const gl = c.readUInt32LE(8); const json = Buffer.from('null'); c.writeUInt32LE(json.length, 12 + gl); return Buffer.concat([c.subarray(0, 12 + gl + 4), json]); }, /Invalid arbiter snapshot payload/); mk('json payload is array', (b) => { const c = Buffer.from(b); const gl = c.readUInt32LE(8); const json = Buffer.from('[]'); c.writeUInt32LE(json.length, 12 + gl); return Buffer.concat([c.subarray(0, 12 + gl + 4), json]); }, /Invalid arbiter snapshot payload/); for (const { name, mutate, expect } of needles) { it(name, () => { const bytes = mutate(copySeed()); assert.throws(() => readBuffer(bytes), (err) => { assert.ok(err instanceof Error, `${name}: non-Error thrown: ${String(err)}`); assert.ok(expect.test(err.message), `${name}: message ${JSON.stringify(err.message)} !~ ${expect}`); return true; }); }); } it('unmutated seed round-trips intact', () => { const restored = readBuffer(SEED); const g = restored.snapshotGraph; assert.equal(STRUCTURAL(g), null); const result = restored.check('u:1', 'owner', 'doc:9'); assert.ok(Math.abs(result.possibility - 0.9) < 1e-4, `possibility ${result.possibility}`); const result2 = restored.check('u:1', 'member_of', 'g:2'); assert.ok(Math.abs(result2.possibility - 0.7) < 1e-4, `possibility ${result2.possibility}`); assert.equal(restored.valueManager.getTTL('member_of'), 30 * 24 * 60 * 60 * 1000); }); }); describe('Adversarial snapshot reads (fuzz campaign)', () => { it('NEEDLE HUNT: mutated buffers never crash internally or corrupt structurally', async () => { const mutations = []; const flipBit = (src) => { const b = Buffer.from(src); // copy: src is already an independent Buffer const at = Math.floor(Math.random() * b.length); b[at] ^= 1 << Math.floor(Math.random() * 8); return b; }; const truncate = (src) => Buffer.from(src).subarray(0, Math.floor(Math.random() * src.length)); const corruptLength = (src) => { const b = Buffer.from(src); const at = [8, b.length - 4][Math.floor(Math.random() * 2)]; b.writeUInt32LE(Math.floor(Math.random() * 0xffffffff), at); return b; }; const spliceGarbage = (src) => { const b = Buffer.from(src); const at = Math.floor(Math.random() * b.length); const junk = Buffer.alloc(1 + Math.floor(Math.random() * 32)); for (let i = 0; i < junk.length; i++) junk[i] = Math.floor(Math.random() * 256); return Buffer.concat([b.subarray(0, at), junk, b.subarray(at)]); }; // Seed-derived mutations: copySeed() first so the shared module-scope // seed is never written through (Buffer views share memory). for (let i = 0; i < 24; i++) { const s = copySeed(); mutations.push(flipBit(s)); mutations.push(truncate(s)); mutations.push(corruptLength(s)); mutations.push(spliceGarbage(s)); mutations.push(Buffer.alloc(1 + Math.floor(Math.random() * 96), Math.floor(Math.random() * 256))); } const pool = mutations.map((b) => Buffer.from(b)); // Stable scalar args: the rigor fuzzer's storage layer BigInt-hashes // recorded args, so raw byte arrays (NaN under conversion) crash the // fuzzer itself — index args keep the fuzzer machinery safe while the // pool carries the adversarial byte payloads. const indexArg = rigor.gen.int(0, pool.length - 1); const wrapper = { read(idx) { try { const restored = Arbiter.fromSnapshotBinary(pool[idx]); return { ok: true, graph: restored.snapshotGraph }; } catch (err) { return { ok: false, error: err }; } } }; const result = await rigor.campaign( [rigor.object('snap', () => wrapper, [ rigor.method('read', function (idx) { return this.read(idx); }, rigor.args(indexArg)) ])], rigor.crucible([ rigor.invariant('failures are clean guard errors, never internal crashes', (ctx) => { if (ctx.error !== null) return true; const r = ctx.actual; if (r.ok) return true; const err = r.error; if (!(err instanceof Error)) return false; const msg = err.message || String(err); if (INTERNAL_CRASH.test(msg)) return false; return CLEAN_ERROR.test(msg); }), rigor.invariant('successful reads yield structurally sound graphs', (ctx) => { if (ctx.error !== null) return true; const r = ctx.actual; if (!r.ok) return true; const problem = STRUCTURAL(r.graph); return problem === null; }), rigor.after('snap.read', ({ objects, error }) => { return error === null || objects.snap.ok; }) ]) ).run({ effort: 600, seed: 'snapshot-adversarial-fuzz', maxTraceLength: 2, fuzzer: { enabled: true, maxCorpusSize: 300, strategies: ['random', 'mutation', 'replay-near-failure'], mutationInterval: 3 } }); const inv = result.crucibleVerdict; const failedInvariants = (inv.invariants || []).filter((i) => i.passed === false); const json = result.toJSONReport(); const stats = json && json.fuzzerStats; assert.equal(inv.passed, true, [ `adversarial snapshot reads violated in ${failedInvariants.length} invariant(s):`, ...failedInvariants.map((i) => ` invariant ${i.name}: ${i.failureCount} failures`), `fuzzer stats: ${JSON.stringify(stats || null).slice(0, 300)}` ].join('\n')); assert.ok(stats && typeof stats === 'object', 'fuzzer stats must be populated'); }); });