mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-05 08:13:06 +08:00
Count a computed key as a stack read only when it can be "stack"
A program that spelled "stack" anywhere treated every computed-key read on a checked-dynamic value as a possible read of an error's stack, so it kept a source frame in every function. The key of such a read now counts only when it can be "stack": a string literal answers by its value; a number, bigint or boolean key, or one converted from such a value, cannot be "stack"; an immutable local answers with its one initializer, and a parameter of a function the program only calls by name answers with the arguments its call sites pass. Any other key counts when the program spells "stack", as before.
This commit is contained in:
@@ -84,6 +84,55 @@ test("a computed key reads the stack when the program spells it", () => {
|
||||
expect(observesStacks(mod([fn("%main", [computed])]))).toBe(false);
|
||||
});
|
||||
|
||||
test('a computed key that cannot be "stack" reads no stack', () => {
|
||||
const value: IrExpr = { loc, kind: "varRef", localId: "d", type: DYN };
|
||||
const spelled: IrStmt = {
|
||||
loc,
|
||||
kind: "exprStmt",
|
||||
expr: { loc, kind: "strLit", value: "stack", type: STRING },
|
||||
};
|
||||
const read = (key: IrExpr): IrStmt => ({
|
||||
loc,
|
||||
kind: "exprStmt",
|
||||
expr: { loc, kind: "dynKeyGet", value, key, type: DYN },
|
||||
});
|
||||
const index: IrExpr = { loc, kind: "varRef", localId: "i", type: F64 };
|
||||
const indexText: IrExpr = { loc, kind: "toString", operand: index, type: STRING };
|
||||
// A number key, and a key converted from one.
|
||||
expect(observesStacks(mod([fn("%main", [read(index), spelled])]))).toBe(false);
|
||||
expect(observesStacks(mod([fn("%main", [read(indexText), spelled])]))).toBe(false);
|
||||
// An immutable local answers with its initializer (the shape of the
|
||||
// dispatch helpers' formatted index keys).
|
||||
const key: IrExpr = { loc, kind: "varRef", localId: "key", type: STRING };
|
||||
const local = (init: IrExpr, mutable = false): IrFunction => ({
|
||||
...fn("%main", [{ loc, kind: "varDecl", localId: "key", init }, read(key), spelled]),
|
||||
locals: [{ id: "key", name: "key", type: STRING, mutable }],
|
||||
});
|
||||
expect(observesStacks(mod([local(indexText)]))).toBe(false);
|
||||
expect(observesStacks(mod([local(indexText, true)]))).toBe(true);
|
||||
// A parameter of a function called by name answers with its arguments.
|
||||
const helper: IrFunction = {
|
||||
...fn("readKey", [read(key)]),
|
||||
params: [{ localId: "key", name: "key", type: STRING }],
|
||||
locals: [{ id: "key", name: "key", type: STRING, mutable: false }],
|
||||
};
|
||||
const callWith = (arg: IrExpr): IrStmt => ({
|
||||
loc,
|
||||
kind: "exprStmt",
|
||||
expr: { loc, kind: "call", callee: "readKey", args: [arg], type: DYN },
|
||||
});
|
||||
const numbers = mod([fn("%main", [callWith(indexText), spelled]), helper]);
|
||||
expect(observesStacks(numbers)).toBe(false);
|
||||
const stack = mod([
|
||||
fn("%main", [
|
||||
callWith(indexText),
|
||||
callWith({ loc, kind: "strLit", value: "stack", type: STRING }),
|
||||
]),
|
||||
helper,
|
||||
]);
|
||||
expect(observesStacks(stack)).toBe(true);
|
||||
});
|
||||
|
||||
test("programs without stack reads are not", () => {
|
||||
expect(observesStacks(mod([fn("%main", [call("helper")]), fn("helper", [ret])]))).toBe(false);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* it answers true. */
|
||||
import { exprFunctionReference, moduleFunctionReferences } from "../ir/function-references.js";
|
||||
import { MAY_THROW_LIB_FNS } from "../ir/builtin-effects.js";
|
||||
import type { IrExpr, IrFunction, IrModule, IrStmt } from "../ir/ir.js";
|
||||
import type { IrExpr, IrFunction, IrModule, IrStmt, IrType } from "../ir/ir.js";
|
||||
import { MAY_THROW_ARR_METHODS } from "../ir/ir.js";
|
||||
import { everyExprChild, everyStmtChild, everyStmtList } from "../ir/traverse.js";
|
||||
import { unreachableStatements } from "./may-throw.js";
|
||||
@@ -13,16 +13,122 @@ import { writesPublished } from "./publication.js";
|
||||
/** Lib calls that read a captured stack trace. */
|
||||
const STACK_READS: ReadonlySet<string> = new Set(["error.stack"]);
|
||||
|
||||
/** Key types whose property names are never `stack`: a number, bigint or
|
||||
* boolean key names its decimal or `true`/`false` property. */
|
||||
const NON_STACK_KEY_TYPES: ReadonlySet<IrType["kind"]> = new Set(["f64", "bigint", "bool"]);
|
||||
|
||||
/** Whether a computed key can be the string "stack", the question a read
|
||||
* on a checked-dynamic value asks before it counts as a stack read.
|
||||
* - A string literal answers by its value; a key of a number, bigint or
|
||||
* boolean type, or one converted from such a value, cannot be "stack".
|
||||
* - An immutable local answers with its one initializer, and a parameter of
|
||||
* a function the program only calls by name answers with the arguments
|
||||
* its call sites pass.
|
||||
* - Any other key can be "stack" when the program spells the literal
|
||||
* "stack" somewhere, since the literal can reach the key through fields,
|
||||
* containers or calls; a key the program assembles from other strings or
|
||||
* from data it reads keeps the static answer. */
|
||||
class StackKeys {
|
||||
private readonly spelled: boolean;
|
||||
private readonly calls = new Map<string, { caller: IrFunction; args: IrExpr[] }[]>();
|
||||
private readonly named: Set<string>;
|
||||
private readonly inits = new Map<IrFunction, Map<string, IrExpr | null>>();
|
||||
private readonly visiting = new Set<string>();
|
||||
|
||||
constructor(mod: IrModule) {
|
||||
this.spelled = spellsStack(mod);
|
||||
this.named = new Set(moduleFunctionReferences(mod));
|
||||
// Class methods, accessors and constructors are called through dispatch.
|
||||
const classNames = new Set((mod.classes ?? []).map((cls) => cls.name));
|
||||
for (const fn of mod.functions) {
|
||||
if (isClassMember(fn.name, classNames)) this.named.add(fn.name);
|
||||
everyStmtList(fn.body, {
|
||||
stmt: () => true,
|
||||
expr: (expr) => {
|
||||
if (expr.kind === "call") {
|
||||
const sites = this.calls.get(expr.callee) ?? [];
|
||||
sites.push({ caller: fn, args: expr.args });
|
||||
this.calls.set(expr.callee, sites);
|
||||
}
|
||||
const referenced = exprFunctionReference(expr);
|
||||
if (referenced !== undefined) this.named.add(referenced);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
mayBeStack(fn: IrFunction, key: IrExpr): boolean {
|
||||
if (key.kind === "strLit") return key.value === "stack";
|
||||
if (!this.spelled) return false;
|
||||
if (NON_STACK_KEY_TYPES.has(key.type.kind)) return false;
|
||||
if (key.kind === "toString" && NON_STACK_KEY_TYPES.has(key.operand.type.kind)) return false;
|
||||
if (key.kind === "dynFrom" && NON_STACK_KEY_TYPES.has(key.value.type.kind)) return false;
|
||||
if (key.kind !== "varRef") return true;
|
||||
const visit = `${fn.name}\0${key.localId}`;
|
||||
// A cycle adds no value of its own: the answer comes from its entries.
|
||||
if (this.visiting.has(visit)) return false;
|
||||
this.visiting.add(visit);
|
||||
try {
|
||||
return this.localMayBeStack(fn, key.localId);
|
||||
} finally {
|
||||
this.visiting.delete(visit);
|
||||
}
|
||||
}
|
||||
|
||||
private localMayBeStack(fn: IrFunction, localId: string): boolean {
|
||||
const local = fn.locals.find((candidate) => candidate.id === localId);
|
||||
if (local === undefined || local.mutable) return true;
|
||||
const index = fn.params.findIndex((param) => param.localId === localId);
|
||||
if (index !== -1) {
|
||||
const sites = this.calls.get(fn.name);
|
||||
if (this.named.has(fn.name) || sites === undefined) return true;
|
||||
return sites.some(
|
||||
(site) =>
|
||||
site.args.length !== fn.params.length || this.mayBeStack(site.caller, site.args[index]!),
|
||||
);
|
||||
}
|
||||
const init = this.initializerOf(fn, localId);
|
||||
return init === null || this.mayBeStack(fn, init);
|
||||
}
|
||||
|
||||
/** The one initializer of an immutable local, or null when it has none
|
||||
* or more than one declaration. */
|
||||
private initializerOf(fn: IrFunction, localId: string): IrExpr | null {
|
||||
let inits = this.inits.get(fn);
|
||||
if (inits === undefined) {
|
||||
inits = new Map();
|
||||
everyStmtList(fn.body, {
|
||||
stmt: (stmt) => {
|
||||
if (stmt.kind === "varDecl")
|
||||
inits!.set(stmt.localId, inits!.has(stmt.localId) ? null : (stmt.init ?? null));
|
||||
return true;
|
||||
},
|
||||
expr: () => true,
|
||||
});
|
||||
this.inits.set(fn, inits);
|
||||
}
|
||||
return inits.get(localId) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether `name` follows the `%<class>.<member>` convention of class
|
||||
* methods, accessors and constructors for one of `classNames`. */
|
||||
function isClassMember(name: string, classNames: ReadonlySet<string>): boolean {
|
||||
if (!name.startsWith("%")) return false;
|
||||
for (let dot = name.indexOf("."); dot !== -1; dot = name.indexOf(".", dot + 1)) {
|
||||
if (classNames.has(name.slice(1, dot))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** A typed `error.stack` read, or a read on a checked-dynamic value that
|
||||
* can reach an error's own `stack` accessor: the constant key `stack`, or
|
||||
* a computed key when `computedKeys` (the program spells "stack", so a
|
||||
* computed key can name it). A key the program assembles from other
|
||||
* strings or from data it reads keeps the static answer. */
|
||||
function readsStack(expr: IrExpr, computedKeys: boolean): boolean {
|
||||
* can reach an error's own `stack` accessor: one whose key can be "stack"
|
||||
* (StackKeys). */
|
||||
function readsStack(fn: IrFunction, expr: IrExpr, keys: StackKeys): boolean {
|
||||
if (expr.kind === "libCall") return STACK_READS.has(expr.fn);
|
||||
if (expr.kind !== "dynKeyGet") return false;
|
||||
if (expr.key.kind === "strLit") return expr.key.value === "stack";
|
||||
return computedKeys;
|
||||
return keys.mayBeStack(fn, expr.key);
|
||||
}
|
||||
|
||||
/** Whether the module contains the string literal "stack". */
|
||||
@@ -36,10 +142,10 @@ function spellsStack(mod: IrModule): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function containsStackRead(fn: IrFunction, computedKeys: boolean): boolean {
|
||||
function containsStackRead(fn: IrFunction, keys: StackKeys): boolean {
|
||||
return !everyStmtList(fn.body, {
|
||||
stmt: () => true,
|
||||
expr: (expr) => !readsStack(expr, computedKeys),
|
||||
expr: (expr) => !readsStack(fn, expr, keys),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,22 +171,14 @@ function visitLiveExprs(body: IrStmt[], visit: (expr: IrExpr) => void): void {
|
||||
* and those reads must not cost every call of the program a frame.
|
||||
* Programs without any stack read answer false without the walk. */
|
||||
export function observesStacks(mod: IrModule): boolean {
|
||||
const computedKeys = spellsStack(mod);
|
||||
if (!mod.functions.some((fn) => containsStackRead(fn, computedKeys))) return false;
|
||||
const keys = new StackKeys(mod);
|
||||
if (!mod.functions.some((fn) => containsStackRead(fn, keys))) return false;
|
||||
const byName = new Map(mod.functions.map((fn) => [fn.name, fn]));
|
||||
const classNames = new Set((mod.classes ?? []).map((cls) => cls.name));
|
||||
const pending: string[] = [...moduleFunctionReferences(mod)];
|
||||
// Class methods, accessors and constructors are reachable through
|
||||
// dispatch the IR does not name per site: all of them are roots.
|
||||
for (const fn of mod.functions) {
|
||||
if (!fn.name.startsWith("%")) continue;
|
||||
for (let dot = fn.name.indexOf("."); dot !== -1; dot = fn.name.indexOf(".", dot + 1)) {
|
||||
if (classNames.has(fn.name.slice(1, dot))) {
|
||||
pending.push(fn.name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const fn of mod.functions) if (isClassMember(fn.name, classNames)) pending.push(fn.name);
|
||||
const reached = new Set<string>();
|
||||
let observed = false;
|
||||
while (pending.length > 0 && !observed) {
|
||||
@@ -90,7 +188,7 @@ export function observesStacks(mod: IrModule): boolean {
|
||||
const fn = byName.get(name);
|
||||
if (fn === undefined) continue;
|
||||
visitLiveExprs(fn.body, (expr) => {
|
||||
if (readsStack(expr, computedKeys)) observed = true;
|
||||
if (readsStack(fn, expr, keys)) observed = true;
|
||||
if (expr.kind === "call" && !reached.has(expr.callee)) pending.push(expr.callee);
|
||||
const referenced = exprFunctionReference(expr);
|
||||
if (referenced !== undefined && !reached.has(referenced)) pending.push(referenced);
|
||||
|
||||
Reference in New Issue
Block a user