mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-11 03:38:07 +00:00
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:
parent
85d25c87cf
commit
da1154f53e
2 changed files with 112 additions and 6 deletions
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue