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:
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -83,7 +83,16 @@ class BinaryWriter {
|
||||
|
||||
class BinaryReader {
|
||||
constructor(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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user