snapshot restore: adversarial fuzzing + structural integrity gate

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.
This commit is contained in:
John Dvorak
2026-08-02 11:36:50 -07:00
parent 257c52ea91
commit 27d04e4058
5 changed files with 327 additions and 2 deletions
+14
View File
@@ -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 }),
+3
View File
@@ -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;
+58 -2
View File
@@ -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);
+4
View File
@@ -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) {