fix(cfg): gate out-of-range binding indices to the dense fallback (#2201 review)

Tri-review (adversarial lane, reproduced) found the SSA path less tolerant than
the dense oracle it replaced: an out-of-range binding index in defs/uses/mayDefs
(a corrupted/stale durable store) crashed the nBindings-sized arrays
(defBlocks[v]/stacks[u]), where dense tolerated it as a Map key. The throw
escaped the unguarded taint/harvest call sites and lost a whole file's taint
layer. Add a malformed-input gate that falls back to the dense solver (which
handles any index), preserving byte-identity AND the graceful per-function
degradation. Add an OOB canonical CFG to the differential fuzz + a production-
entry no-throw unit test (the generator only ever emitted in-range indices, so
this divergent input was structurally invisible).
This commit is contained in:
Gergo Magyar 2026-06-15 16:09:58 +00:00
parent 703f0b6682
commit 7e78026e1a
3 changed files with 65 additions and 1 deletions

View file

@ -526,8 +526,28 @@ function computeInSetsSparse(
const { preds, succs, throwSuccs } = adj;
const entry = cfg.entryIndex;
// Gate to the dense oracle for the two shapes the SSA path does not model.
// Gate to the dense oracle for the shapes the SSA path does not model.
for (const list of throwSuccs) if (list.length) return computeInSetsDense(cfg, n, h, adj, limits);
// Malformed-input guard: an out-of-range binding index (negative or
// ≥ nBindings — a corrupted/stale durable parsedfile store) would crash the
// SSA path's nBindings-sized arrays (defBlocks[v]/stacks[u]). The dense solver
// tolerates any index (its lattice is a Map), so fall back — keeping the two
// byte-identical AND preserving the graceful per-function degradation the
// dense path gave (a throw here would escape the unguarded taint/harvest call
// sites and lose the whole file's taint layer). See hasEmitSafeFacts (emit.ts).
for (const b of cfg.blocks) {
const stmts = b.statements;
if (!stmts) continue;
for (const s of stmts) {
for (const d of s.defs)
if (d < 0 || d >= nBindings) return computeInSetsDense(cfg, n, h, adj, limits);
for (const u of s.uses)
if (u < 0 || u >= nBindings) return computeInSetsDense(cfg, n, h, adj, limits);
if (s.mayDefs)
for (const d of s.mayDefs)
if (d < 0 || d >= nBindings) return computeInSetsDense(cfg, n, h, adj, limits);
}
}
const reachable = new Array<boolean>(n).fill(false);
{
const q = [entry];

View file

@ -317,6 +317,25 @@ function canonicalHardCfgs(): FunctionCfg[] {
),
);
// (7) Malformed input: an OUT-OF-RANGE binding index (≥ nBindings, e.g. from a
// corrupted/stale durable store) in a looping CFG. The dense solver tolerates
// it (its lattice is a Map keyed by index); the SSA path must fall back to
// dense rather than crash its nBindings-sized arrays. Asserting byte-identity
// here pins that gate — without it, the SSA path throws and the differential
// comparison can never reach this divergent input (the generator only ever
// emits in-range indices).
out.push(
mk(
[blk(0, [st(1, [0], [])]), blk(1, [st(2, [3], [3])]), blk(2, [st(3, [], [0])])],
[
{ from: 0, to: 1, kind: 'seq' },
{ from: 1, to: 1, kind: 'loop-back' },
{ from: 1, to: 2, kind: 'cond-false' },
],
[bind('x', 1)], // nBindings = 1, so binding index 3 in block 1 is out of range
),
);
return out;
}

View file

@ -400,6 +400,31 @@ describe('computeReachingDefs — determinism and convergence', () => {
expect(sparse.status).toBe('computed'); // SSA ignores the ceiling — it never fires
expect(render(sparse.facts)).toEqual(render(denseFull.facts)); // and the facts match
});
it('#2201: an out-of-range binding index in a ≥16-block loop does NOT crash the SSA path', () => {
// A corrupted/stale store can carry a binding index ≥ nBindings. The dense
// solver tolerates it (Map-keyed lattice); the SSA path's nBindings-sized
// arrays would throw. The production dispatcher routes ≥16-block looping
// functions to SSA, so without the malformed-input gate the throw would
// escape the (unguarded) taint/harvest callers and lose a whole file's taint
// layer. The gate falls back to dense — no throw, byte-identical to dense.
const blocks: BlockSpec[] = [{ stmts: [stmt(1, [0], [])] }];
const edges: [number, number][] = [];
for (let i = 1; i <= 18; i++) {
blocks.push({ stmts: [stmt(i + 1, i === 1 ? [5] : [0], [i === 1 ? 5 : 0])] }); // block 1 uses/defs OOB index 5
edges.push([i - 1, i]);
}
edges.push([18, 1]); // back-edge → loop; 19 blocks total, ≥16 → SSA dispatch
const cfg = mkCfg(blocks, edges, ['x']); // nBindings = 1; index 5 is out of range
expect(cfg.blocks.length).toBeGreaterThanOrEqual(16);
let prod: ReturnType<typeof computeReachingDefs> | undefined;
expect(() => {
prod = computeReachingDefs(cfg); // must NOT throw (gate → dense fallback)
}).not.toThrow();
const dense = computeReachingDefsDense(cfg);
expect(prod!.status).toBe(dense.status);
expect(render(prod!.facts)).toEqual(render(dense.facts)); // byte-identical to the tolerant dense path
});
});
describe('computeReachingDefs — parser-direct acceptance (with U1/U2)', () => {