feat(cfg): defensive per-statement cap on harvested taint sites (#2195 U11)

A statement's harvested sites[] had no explicit bound — a pathological or
machine-generated statement (hundreds of nested calls) could grow it without
limit. Add DEFAULT_PDG_MAX_SITES_PER_STATEMENT (512, mirroring the PDG edge/fact
cap style): openCallSite/addMemberRead check-before-push and stop at the cap,
keeping the first 512 sites fully intact and setting an observable
sitesTruncated flag. A cap-dropped openCallSite returns a -1 sentinel that
pushFrame/setSite*/the occurrence fan-out all tolerate (no dangling parent/via,
no clobber of kept sites). Generous enough that no real statement is affected:
bench --check fingerprints unchanged, cfg unit suite 617 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-06-14 17:08:15 +00:00
parent 85d25c87cf
commit da1154f53e
2 changed files with 112 additions and 6 deletions

View file

@ -116,6 +116,18 @@ export class DefUseAccumulator {
}
}
/**
* Defensive per-statement cap on harvested taint `sites` (#2195 U11). A real
* statement carries a handful of call / member-read sites; this only bounds a
* pathological or machine-generated statement (e.g. hundreds of nested calls)
* from producing an unbounded site list. Mirrors the PDG edge/fact caps' style
* (a generous-but-finite limit, checked before each push). Overflow is silent
* but observable via {@link CallSiteFactAccumulator.sitesTruncated}; the first
* `DEFAULT_PDG_MAX_SITES_PER_STATEMENT` sites are kept fully intact (callee,
* args, parent), the over-cap tail is dropped.
*/
export const DEFAULT_PDG_MAX_SITES_PER_STATEMENT = 512;
/**
* Ordered, deduplicating def/use collector for one statement record, PLUS the
* call-site harvest machinery (#2195 U6). A drop-in superset of the simple
@ -137,9 +149,16 @@ export class CallSiteFactAccumulator {
private readonly memberReadKeys = new Set<string>();
/** Stack of open call/new sites — the occurrence fan-out targets. */
private readonly frames: SiteFrame[] = [];
/** Set once the per-statement site cap is hit; over-cap sites are dropped. */
private _sitesTruncated = false;
constructor(private readonly line: number) {}
/** True iff this statement hit {@link DEFAULT_PDG_MAX_SITES_PER_STATEMENT}. */
get sitesTruncated(): boolean {
return this._sitesTruncated;
}
addDef(idx: number): void {
if (this.defSeen.has(idx)) return;
this.defSeen.add(idx);
@ -190,8 +209,17 @@ export class CallSiteFactAccumulator {
return [...this.defs.slice(snap[0]), ...this.mayDefs.slice(snap[1])];
}
/** Open a call/new site; parent = innermost enclosing argument position. */
/**
* Open a call/new site; parent = innermost enclosing argument position.
* Returns the new site index, or -1 when the per-statement site cap is hit
* (the caller threads -1 through `pushFrame`/`setSite*`, all of which no-op on
* a sentinel index — see {@link DEFAULT_PDG_MAX_SITES_PER_STATEMENT}).
*/
openCallSite(kind: 'call' | 'new'): number {
if (this.sites.length >= DEFAULT_PDG_MAX_SITES_PER_STATEMENT) {
this._sitesTruncated = true;
return -1;
}
const site: MutableSite = { kind };
const parent = this.innermostArgPosition();
if (parent) site.parent = parent;
@ -232,20 +260,23 @@ export class CallSiteFactAccumulator {
}
setSiteCallee(siteIdx: number, callee: string): void {
this.sites[siteIdx].callee = callee;
const site = this.sites[siteIdx];
if (site) site.callee = callee;
}
setSiteReceiver(siteIdx: number, receiver: number): void {
this.sites[siteIdx].receiver = receiver;
const site = this.sites[siteIdx];
if (site) site.receiver = receiver;
}
setSiteResultDefs(siteIdx: number, resultDefs: readonly number[]): void {
this.sites[siteIdx].resultDefs = [...resultDefs];
const site = this.sites[siteIdx];
if (site) site.resultDefs = [...resultDefs];
}
setSiteSpread(siteIdx: number, firstSpreadArg: number): void {
const site = this.sites[siteIdx];
if (site.spread === undefined) site.spread = firstSpreadArg;
if (site && site.spread === undefined) site.spread = firstSpreadArg;
}
/**
@ -257,6 +288,10 @@ export class CallSiteFactAccumulator {
const parent = this.innermostArgPosition();
const dedupKey = `${object}|${property}|${parent ? `${parent[0]}:${parent[1]}` : 'top'}`;
if (this.memberReadKeys.has(dedupKey)) return;
if (this.sites.length >= DEFAULT_PDG_MAX_SITES_PER_STATEMENT) {
this._sitesTruncated = true;
return;
}
this.memberReadKeys.add(dedupKey);
const site: MutableSite = { kind: 'member-read' };
if (parent) site.parent = parent;
@ -283,7 +318,9 @@ export class CallSiteFactAccumulator {
for (let i = this.frames.length - 1; i >= 0; i--) {
const f = this.frames[i];
if (f.argIdx < 0) continue;
const via = i + 1 < this.frames.length ? this.frames[i + 1].siteIdx : undefined;
// A nested frame whose site was cap-dropped (siteIdx -1) is not a real via.
const next = i + 1 < this.frames.length ? this.frames[i + 1].siteIdx : undefined;
const via = next !== undefined && next >= 0 ? next : undefined;
this.pushArgEntry(f.siteIdx, f.argIdx, idx, via);
}
}
@ -295,6 +332,7 @@ export class CallSiteFactAccumulator {
via: number | undefined,
): void {
const site = this.sites[siteIdx];
if (!site) return; // cap-dropped frame (siteIdx -1) — no target to fan into
const args = (site.args ??= []);
while (args.length <= argIdx) args.push([]);
const list = args[argIdx];

View file

@ -710,3 +710,71 @@ describe('M3 U1 — taint-site harvest: templates, callbacks, statement granular
expect(execSite.args).toEqual([[bindingIdx(cfg, 'x')]]);
});
});
import {
CallSiteFactAccumulator,
DEFAULT_PDG_MAX_SITES_PER_STATEMENT as MAX_SITES,
} from '../../../src/core/ingestion/cfg/visitors/call-site-harvest.js';
describe('U11 — per-statement site cap (defensive bound on harvested sites[])', () => {
it('records every site for a statement below the cap (unchanged)', () => {
const acc = new CallSiteFactAccumulator(1);
for (let i = 0; i < 5; i++) acc.setSiteCallee(acc.openCallSite('call'), `f${i}`);
const facts = acc.finish();
expect(facts.sites).toHaveLength(5);
expect(acc.sitesTruncated).toBe(false);
expect(facts.sites!.map((s) => s.callee)).toEqual(['f0', 'f1', 'f2', 'f3', 'f4']);
});
it('caps a pathological statement at exactly the limit and flags truncation', () => {
const acc = new CallSiteFactAccumulator(1);
const indices: number[] = [];
for (let i = 0; i < MAX_SITES + 50; i++) {
const idx = acc.openCallSite('call');
acc.setSiteCallee(idx, `f${i}`);
indices.push(idx);
}
const facts = acc.finish();
// exactly the cap recorded — not the requested over-count, not unbounded
expect(facts.sites).toHaveLength(MAX_SITES);
expect(acc.sitesTruncated).toBe(true);
// under-cap opens get 0..cap-1; the first over-cap open gets the -1 sentinel
expect(indices[MAX_SITES - 1]).toBe(MAX_SITES - 1);
expect(indices[MAX_SITES]).toBe(-1);
// KEPT sites stay fully intact (no clobber from the dropped tail)
expect(facts.sites![0].callee).toBe('f0');
expect(facts.sites![MAX_SITES - 1].callee).toBe(`f${MAX_SITES - 1}`);
});
it('member-reads past the cap are dropped, not unbounded', () => {
const acc = new CallSiteFactAccumulator(1);
for (let i = 0; i < MAX_SITES; i++) acc.openCallSite('call'); // fill to the cap
acc.addMemberRead(0, 'body'); // would-be site #cap+1
expect(acc.finish().sites).toHaveLength(MAX_SITES);
expect(acc.sitesTruncated).toBe(true);
});
it('occurrence machinery stays sound when a nested frame is cap-dropped', () => {
const acc = new CallSiteFactAccumulator(1);
const outer = acc.openCallSite('call'); // a KEPT outer sink site
acc.setSiteCallee(outer, 'sink');
acc.pushFrame(outer);
acc.setFrameArg(0);
// Saturate the remaining budget so the next openCallSite is cap-dropped.
for (let i = 1; i < MAX_SITES; i++) acc.openCallSite('call');
const nested = acc.openCallSite('call'); // over cap → -1 sentinel
expect(nested).toBe(-1);
acc.setSiteCallee(nested, 'dropped'); // must no-op, not throw
acc.pushFrame(nested);
acc.setFrameArg(0);
acc.addUse(7); // a use inside the dropped nested call — must not crash
acc.popFrame();
acc.popFrame();
const facts = acc.finish();
expect(facts.uses).toContain(7); // still recorded statement-level
// outer (kept) fanned the use in as a PLAIN occurrence — no dangling -1 via
const flat = (facts.sites![outer].args ?? []).flat();
expect(flat).toContain(7);
expect(flat.some((e) => Array.isArray(e) && e[1] === -1)).toBe(false);
});
});