From 27d04e405862920aa0a4d27807f32f158cf5bc14 Mon Sep 17 00:00:00 2001 From: John Dvorak Date: Sun, 2 Aug 2026 11:36:50 -0700 Subject: [PATCH] snapshot restore: adversarial fuzzing + structural integrity gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new rigor campaign (snapshot-adversarial-fuzz) hunts malformed restore buffers: every read must succeed into a structurally sound graph or throw a clean bounded error. It surfaced three real bugs now fixed: 1. BinaryReader threw a caller-contract TypeError on Buffer/Uint8Array input (fs-style restore) instead of reading it — normalized to a DataView over the real ArrayBuffer. 2. readBytes built its slice with this.view.buffer + this.offset, ignoring view.byteOffset — pooled Buffers (byteOffset 768+) read the wrong memory region entirely, corrupting restored graphs. 3. The edge gate validated array contents but not the header count fields: a desynced edgeIndex (indices build iterates edgeIndex, not array length) turned a one-byte flip into a 13-second effective hang. The gate now cross-validates numNodes/numEdges/edgeIndex/ nextRelationId/valueCount/degreeCount against their sections, and LazyNodeIdTable bounds-guards garbage offset slices. Binary mode now carries validity on every return site (direct, logical, loop, early-termination, structural), gated like the normal path. --- src/authorization/AuthorizationChecker.js | 14 + src/core/SnapshotBinary.js | 3 + src/core/graph/CondensedGraphBinary.js | 60 ++++- src/core/graph/LazyNodeIdTable.js | 4 + tests/rigor/snapshot-adversarial-fuzz.test.js | 248 ++++++++++++++++++ 5 files changed, 327 insertions(+), 2 deletions(-) create mode 100644 tests/rigor/snapshot-adversarial-fuzz.test.js diff --git a/src/authorization/AuthorizationChecker.js b/src/authorization/AuthorizationChecker.js index 96396e0..8d004bf 100644 --- a/src/authorization/AuthorizationChecker.js +++ b/src/authorization/AuthorizationChecker.js @@ -649,6 +649,7 @@ export class AuthorizationChecker { if (_visited.has(visitKey)) { return { possibility: 0, + validity: includeMeta ? DEFAULT_VALIDITY : minimalValidity(DEFAULT_VALIDITY), reason: 'cycle', binary: true, ...(evaluation && { evaluation }) @@ -659,6 +660,7 @@ export class AuthorizationChecker { if (visited.userKey === userKey && visited.relation === relation && visited.objectKey === objectKey) { return { possibility: 0, + validity: includeMeta ? DEFAULT_VALIDITY : minimalValidity(DEFAULT_VALIDITY), reason: 'cycle', binary: true, ...(evaluation && { evaluation }) @@ -707,6 +709,13 @@ export class AuthorizationChecker { return { possibility: directRel.possibility, + validity: includeMeta + ? (directRel.validity !== undefined + ? buildValidity('identity', [effectiveRelation], [normalizeValidity(directRel.validity)], 1, directRel.possibility) + : DEFAULT_VALIDITY) + : minimalValidity(directRel.validity !== undefined + ? buildValidity('identity', [effectiveRelation], [normalizeValidity(directRel.validity)], 1, directRel.possibility) + : DEFAULT_VALIDITY), reason: allow ? 'allow' : deny ? 'deny' : 'insufficient_confidence', binary: true, ...(evaluation && { evaluation }), @@ -722,6 +731,7 @@ export class AuthorizationChecker { return { possibility: 0, + validity: includeMeta ? DEFAULT_VALIDITY : minimalValidity(DEFAULT_VALIDITY), reason: 'insufficient_confidence', binary: true, ...(evaluation && { evaluation }), @@ -766,6 +776,7 @@ export class AuthorizationChecker { return { possibility: resAllowPossibility || 0, + validity: includeMeta ? (res.validity || DEFAULT_VALIDITY) : minimalValidity(res.validity || DEFAULT_VALIDITY), reason: allow ? 'allow' : deny ? 'deny' : 'insufficient_confidence', binary: true, ...(evaluation && { evaluation }), @@ -779,6 +790,7 @@ export class AuthorizationChecker { let maxAllow = 0; let maxDeny = 0; + let bestValidity = null; for (const rule of rules) { if (evaluation) { @@ -800,6 +812,7 @@ export class AuthorizationChecker { if (resAllowPossibility > maxAllow) { maxAllow = resAllowPossibility; + if (res.validity) bestValidity = res.validity; } if (resDenyPossibility > maxDeny) { @@ -817,6 +830,7 @@ export class AuthorizationChecker { return { possibility: maxAllow, + validity: includeMeta ? (bestValidity || DEFAULT_VALIDITY) : minimalValidity(bestValidity || DEFAULT_VALIDITY), reason: 'allow', binary: true, ...(evaluation && { evaluation }), diff --git a/src/core/SnapshotBinary.js b/src/core/SnapshotBinary.js index 8cd383c..db9e3a3 100644 --- a/src/core/SnapshotBinary.js +++ b/src/core/SnapshotBinary.js @@ -116,6 +116,9 @@ function deserializeArbiterSnapshot(buffer, arbiterFactory) { const jsonBytes = reader.readBytes(jsonLength); const json = new TextDecoder().decode(jsonBytes); const payload = JSON.parse(json); + if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('Invalid arbiter snapshot payload: expected a JSON object'); + } const arbiter = arbiterFactory(); arbiter.snapshotEnabled = true; diff --git a/src/core/graph/CondensedGraphBinary.js b/src/core/graph/CondensedGraphBinary.js index 9a2937b..cb41ef7 100644 --- a/src/core/graph/CondensedGraphBinary.js +++ b/src/core/graph/CondensedGraphBinary.js @@ -83,7 +83,16 @@ class BinaryWriter { class BinaryReader { constructor(buffer) { - this.view = new DataView(buffer); + // Restore input arrives as ArrayBuffer (serializer) or Buffer/Uint8Array + // (fs, network, mutations) — normalize to a view over a real + // ArrayBuffer so DataView never throws a caller-contract TypeError. + if (buffer instanceof ArrayBuffer) { + this.view = new DataView(buffer); + } else if (ArrayBuffer.isView(buffer)) { + this.view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); + } else { + throw new Error('CondensedGraphBinary: snapshot input must be an ArrayBuffer, Buffer, or Uint8Array'); + } this.offset = 0; this.decoder = new TextDecoder(); } @@ -145,7 +154,11 @@ class BinaryReader { readBytes(length) { this.ensure(length); - const bytes = new Uint8Array(this.view.buffer, this.offset, length); + // this.offset is relative to the VIEW; the underlying ArrayBuffer may + // be a shared node pool with a nonzero byteOffset (Buffer inputs). + // Building the slice from this.view.buffer without byteOffset would + // read the wrong memory region entirely. + const bytes = new Uint8Array(this.view.buffer, this.view.byteOffset + this.offset, length); this.offset += length; return bytes; } @@ -639,12 +652,55 @@ function deserializeCondensedGraph(buffer, graphFactory) { graph.edgeReliabilityBits = new Uint16Array(relBitsBytes.buffer, relBitsBytes.byteOffset, edgeCount); graph.adjacency = null; + // Structural integrity gate: the buffer is caller-supplied (snapshot + // restore input). Garbage counts or ids must be rejected here — a graph + // that silently accepts out-of-range ids crashes or loops in the first + // downstream consumer (the indices build iterates edgeIndex, not the + // array length — a desynced count is a hang, not a crash). Clean Error, + // never a masked corruption. + const numNodes = graph.numNodes; + if (numNodes !== nodeCount) { + throw new Error( + `CondensedGraphBinary: node count mismatch (header ${numNodes}, node table ${nodeCount})` + ); + } + if (graph.numEdges !== edgeCount || graph.edgeIndex !== edgeCount) { + throw new Error( + `CondensedGraphBinary: edge count mismatch (numEdges ${graph.numEdges}, edgeIndex ${graph.edgeIndex}, arrays ${edgeCount})` + ); + } + if (graph.nextRelationId !== relationCount) { + throw new Error( + `CondensedGraphBinary: relation count mismatch (nextRelationId ${graph.nextRelationId}, names ${relationCount})` + ); + } + for (let i = 0; i < edgeCount; i++) { + const src = graph.edgeSrcIds[i]; + const rel = graph.edgeRelIds[i]; + const dst = graph.edgeDstIds[i]; + if (src < 0 || src >= nodeCount || dst < 0 || dst >= nodeCount || rel < 0 || rel >= relationCount) { + throw new Error( + `CondensedGraphBinary: edge ${i} out of range (src ${src}, rel ${rel}, dst ${dst}; nodes ${nodeCount}, relations ${relationCount})` + ); + } + } + const valueCount = reader.readUint32(); + if (valueCount !== edgeCount) { + throw new Error( + `CondensedGraphBinary: value count mismatch (values ${valueCount}, edges ${edgeCount})` + ); + } reader.align(8); const valueBytes = reader.readBytes(valueCount * 8); graph.values = new Float64Array(valueBytes.buffer, valueBytes.byteOffset, valueCount); const degreeCount = reader.readUint32(); + if (degreeCount !== nodeCount) { + throw new Error( + `CondensedGraphBinary: degree count mismatch (degrees ${degreeCount}, nodes ${nodeCount})` + ); + } reader.align(2); const degreeBytes = reader.readBytes(degreeCount * 2); graph.nodeDegrees = new Uint16Array(degreeBytes.buffer, degreeBytes.byteOffset, degreeCount); diff --git a/src/core/graph/LazyNodeIdTable.js b/src/core/graph/LazyNodeIdTable.js index 95c4918..83caf6b 100644 --- a/src/core/graph/LazyNodeIdTable.js +++ b/src/core/graph/LazyNodeIdTable.js @@ -19,6 +19,10 @@ export class LazyNodeIdTable { const start = this.offsets[index]; const end = this.offsets[index + 1]; if (start === undefined || end === undefined || end < start) return undefined; + // Garbage offset tables (caller-supplied buffer) can point far beyond + // the byte section — a slice out of bounds would RangeError downstream + // instead of degrading to a missing key. + if (start > this.bytes.byteLength || end > this.bytes.byteLength) return undefined; const view = new Uint8Array(this.bytes.buffer, this.bytes.byteOffset + start, end - start); const value = this.decoder.decode(view); if (this.cache) { diff --git a/tests/rigor/snapshot-adversarial-fuzz.test.js b/tests/rigor/snapshot-adversarial-fuzz.test.js new file mode 100644 index 0000000..e485c8e --- /dev/null +++ b/tests/rigor/snapshot-adversarial-fuzz.test.js @@ -0,0 +1,248 @@ +/** + * 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'); + }); +});