diff --git a/packages/compiler/src/frontend/shape-inference/analyze.ts b/packages/compiler/src/frontend/shape-inference/analyze.ts index 55e0e0107..0a7b4d6d9 100644 --- a/packages/compiler/src/frontend/shape-inference/analyze.ts +++ b/packages/compiler/src/frontend/shape-inference/analyze.ts @@ -447,6 +447,10 @@ class Analyzer { id = this.newNode(); m.set(name, id); if (this.traceTop) this.nodeLabel.set(id, `${atom.label}.${name}`); + if (name === "*") { + for (const dst of this.unknownKeyReaders.get(atom) ?? []) this.edge(id, dst); + this.unknownKeyReaders.delete(atom); + } const reason = this.escaped.get(atom); if (reason !== undefined) { this.sink(id, reason); @@ -1367,10 +1371,7 @@ class Analyzer { return this.constNode(this.prim("number")); } if (ts.isSpreadElement(e)) return this.expr(e.expression); - if (ts.isTaggedTemplateExpression(e)) { - this.expr(e.tag); - return this.constNode(this.top); - } + if (ts.isTaggedTemplateExpression(e)) return this.taggedTemplate(e); if (ts.isYieldExpression(e)) { if (e.expression !== undefined) this.sink(this.expr(e.expression), `yielded at ${locLabel(e)}`); @@ -1695,6 +1696,25 @@ class Analyzer { return this.field(atom, "*"); } + /** Loads waiting for an atom's first write under an unknown key. */ + private readonly unknownKeyReaders = new Map(); + + /** dst ⊇ the values written to `atom` under unknown keys, now and later, + * without creating the field for atoms that have none. */ + unknownKeysInto(atom: Atom, dst: number): void { + const id = this.fields.get(atom)?.get("*"); + if (id !== undefined) { + this.edge(id, dst); + return; + } + let readers = this.unknownKeyReaders.get(atom); + if (readers === undefined) { + readers = []; + this.unknownKeyReaders.set(atom, readers); + } + readers.push(dst); + } + copyFields(src: number, dstAtom: Atom, site: Node): void { this.on(src, (a) => { if (a.kind === "top") { @@ -1728,6 +1748,60 @@ class Analyzer { /** Field copies re-run after solving rounds (fields created later). */ private readonly lateCopies: (() => void)[] = []; + /** node ⊇ the own field and element values of every value in it, now and + * later: the values JSON.stringify serializes. */ + childrenInto(node: number): void { + this.on(node, (a) => { + if ( + a.kind !== "object" && + a.kind !== "array" && + a.kind !== "instance" && + a.kind !== "dictionary" + ) + return; + const seen = new Set(); + const copy = (): void => { + for (const [name, id] of this.fields.get(a) ?? new Map()) { + if (seen.has(name) || name.startsWith("#")) continue; + seen.add(name); + this.edge(id, node); + } + const el = this.elements.get(a); + if (el !== undefined && !seen.has("[]")) { + seen.add("[]"); + this.edge(el, node); + } + }; + copy(); + this.lateCopies.push(copy); + }); + } + + /** JSON.stringify's call of `value.toJSON(key)` on a value that has one + * (a class member, or an own field once it exists); the result goes to `out`. */ + callToJSON( + value: Atom, + key: number, + out: number, + site: ts.CallExpression | ts.TaggedTemplateExpression, + ): void { + if (value.kind !== "object" && value.kind !== "instance" && value.kind !== "dictionary") return; + let called = false; + const call = (): void => { + if (called) return; + const has = + this.fields.get(value)?.has("toJSON") === true || + (value.kind === "instance" && this.lookupMember(value.node!, "toJSON", false) !== null); + if (!has) return; + called = true; + const fn = this.newNode(); + this.loadAtom(value, "toJSON", fn, site); + this.invoke(fn, this.constNode(value), [key], false, out, site); + }; + call(); + if (!called) this.lateCopies.push(call); + } + arrayLiteral(e: ts.ArrayLiteralExpression): number { const atom = this.atoms.intern(`arr:${this.nodeKey(e)}`, () => ({ kind: "array", @@ -1948,6 +2022,7 @@ class Analyzer { if (this.classChain(decl).some((c) => this.baseClass(c) === undefined)) this.add(dst, this.top); this.edge(this.field(a, name), dst); + this.unknownKeysInto(a, dst); return; } case "class": { @@ -1977,6 +2052,7 @@ class Analyzer { for (const c of this.classChain(decl)) { const cls = this.classAtoms(c).cls; this.edge(this.field(cls, name), dst); + this.unknownKeysInto(cls, dst); } return; } @@ -1986,7 +2062,10 @@ class Analyzer { else if (name === "prototype") this.add(dst, this.top); else if (name === "name") this.add(dst, this.prim("string")); else if (name === "length") this.add(dst, this.prim("number")); - else this.edge(this.field(a, name), dst); + else { + this.edge(this.field(a, name), dst); + this.unknownKeysInto(a, dst); + } return; case "array": if (name === "length") this.add(dst, this.prim("number")); @@ -2081,6 +2160,7 @@ class Analyzer { return; case "array": if (/^\d+$/.test(name)) this.edge(value, this.element(a)); + else if (name !== "length") this.edge(value, this.field(a, name)); return; case "prim": return; @@ -2149,6 +2229,37 @@ class Analyzer { // ---- calls --------------------------------------------------------------------------- + /** tag`…${x}…`: a call of the tag with the template's strings array (an + * array of strings with a `raw` array of strings) and each substitution. */ + taggedTemplate(e: ts.TaggedTemplateExpression): number { + this.noteSite(e); + const tag = unparen(e.tag); + const recv = + ts.isPropertyAccessExpression(tag) && tag.expression.kind !== ts.SyntaxKind.SuperKeyword + ? this.expr(tag.expression) + : null; + const callee = recv === null ? this.expr(tag) : null; + const strings = this.syntheticArray(e, "template"); + const raw = this.syntheticArray(e.template, "raw"); + this.add(this.element(strings), this.prim("string")); + this.add(this.element(raw), this.prim("string")); + this.add(this.field(strings, "raw"), raw); + const args = [this.constNode(strings)]; + if (ts.isTemplateExpression(e.template)) + for (const span of list(e.template.templateSpans)) args.push(this.expr(span.expression)); + const dst = this.newNode(); + if (recv !== null && ts.isPropertyAccessExpression(tag)) { + const name = tag.name.text; + this.ops.push({ kind: "read", recv, name, node: tag }); + this.on(recv, (a) => { + const fn = this.newNode(); + this.loadAtom(a, name, fn, tag); + this.invoke(fn, this.constNode(a), args, false, dst, e); + }); + } else this.invoke(callee!, null, args, false, dst, e); + return dst; + } + args(e: ts.CallExpression | ts.NewExpression): { args: number[]; spread: boolean } { const out: number[] = []; let spread = false; @@ -2245,7 +2356,7 @@ class Analyzer { args: number[], spread: boolean, dst: number, - site: ts.CallExpression, + site: ts.CallExpression | ts.TaggedTemplateExpression, ): void { const undefNode = recv === null ? this.constNode(this.prim("undefined")) : recv; this.on(callee, (a) => { @@ -2358,6 +2469,13 @@ class Analyzer { this.add(dst, this.syntheticArray(site, "Array")); return; } + case "Proxy": + // The handler's traps run on every operation with the target. + if (args[0] !== undefined) + this.ops.push({ kind: "proxy", recv: args[0], name: null, node: site }); + for (const x of args) this.sink(x, `argument of new Proxy at ${locLabel(site)}`); + this.add(dst, this.top); + return; default: for (const x of args) this.sink(x, `argument of new ${path} at ${locLabel(site)}`); this.add(dst, this.top); @@ -2370,7 +2488,7 @@ class Analyzer { args: number[], spread: boolean, dst: number, - site: ts.CallExpression, + site: ts.CallExpression | ts.TaggedTemplateExpression, recvNode: number | null, ): void { const path = a.path ?? ""; @@ -2768,12 +2886,62 @@ class Analyzer { return; } case "JSON.parse": + // A reviver runs on every parsed value with its key, the holder as `this`. + if (args[1] !== undefined) + this.on(args[1], (f) => { + if (f.kind === "function") + this.callFunction( + this.infoOfAtom(f)!, + this.constNode(this.top), + [this.constNode(this.prim("string")), this.constNode(this.top)], + false, + this.newNode(), + site, + ); + }); this.add(dst, this.top); return; - case "JSON.stringify": + case "JSON.stringify": { + // Every value it serializes: toJSON methods run on them, and a + // replacer function with each key and value, the holder as `this`. + // Their results are serialized in turn. + const serialized = this.newNode(); + this.childrenInto(serialized); + this.edge(arg(0), serialized); + const key = this.constNode(this.prim("string")); + this.on(serialized, (v) => this.callToJSON(v, key, serialized, site)); + if (args[1] !== undefined) + this.on(args[1], (f) => { + if (f.kind === "function") + this.callFunction( + this.infoOfAtom(f)!, + serialized, + [key, serialized], + false, + serialized, + site, + ); + else if (f.kind === "top") + this.sink(serialized, `JSON.stringify replacer at ${locLabel(site)}`); + }); prim("string"); prim("undefined"); return; + } + case "Reflect.deleteProperty": { + const key = ts.isCallExpression(site) ? unparen(list(site.arguments)[1]!) : undefined; + const name = + key !== undefined && (ts.isStringLiteral(key) || ts.isNoSubstitutionTemplateLiteral(key)) + ? key.text + : null; + this.ops.push({ kind: "delete", recv: arg(0), name, node: site }); + prim("boolean"); + return; + } + case "Proxy.revocable": + this.ops.push({ kind: "proxy", recv: arg(0), name: null, node: site }); + escapeAll(); + return; case "String": case "encodeURIComponent": case "decodeURIComponent": diff --git a/packages/compiler/src/frontend/shape-inference/domain.ts b/packages/compiler/src/frontend/shape-inference/domain.ts index 36978d4c9..d90a83cf2 100644 --- a/packages/compiler/src/frontend/shape-inference/domain.ts +++ b/packages/compiler/src/frontend/shape-inference/domain.ts @@ -105,6 +105,7 @@ export const MODELED_GLOBALS: ReadonlySet = new Set([ "encodeURI", "decodeURI", "BigInt", + "Proxy", ]); /** Results of string (and number) methods, by name. Anything else is ⊤. */ diff --git a/packages/compiler/test/shape-inference.test.ts b/packages/compiler/test/shape-inference.test.ts index 3cd6e809d..b4c898025 100644 --- a/packages/compiler/test/shape-inference.test.ts +++ b/packages/compiler/test/shape-inference.test.ts @@ -49,6 +49,36 @@ export const str = () => new Str({ checks: [] }); export const obj = (shape) => new Obj({ shape }); export function leak(value) { globalThis.leaked = value; return value; } export function reflect() { const o = { a: 1 }; delete o.a; return o; } +class Box {} +function tag(strings, ...values) { leak(values[0]); return strings.raw.length; } +export function arrayProp() { const list = []; list.meta = { listed: 1 }; return leak(list.meta); } +export function computedField(k) { + const box = new Box(); + box[k] = { boxed: 1 }; + Box[k] = { onClass: 1 }; + const fn = function () {}; + fn[k] = { onFunction: 1 }; + return [leak(box.held), leak(Box.held), leak(fn.held)]; +} +export function revive(text) { + const seen = { revived: 1 }; + return JSON.parse(text, function (k, v) { globalThis.revived = seen; return v; }); +} +class Doc { + constructor() { this.body = { doc: 1 }; } + toJSON() { return leak(this.body); } +} +export function stringified() { + const own = { owned: 1 }; + return [ + JSON.stringify({ inner: { replaced: 1 } }, (k, x) => leak(x)), + JSON.stringify(new Doc()), + JSON.stringify({ toJSON() { return leak(own); } }), + ]; +} +export function proxied() { return new Proxy({ proxied: 1 }, {}); } +export function reflectDelete() { const o = { gone: 1 }; Reflect.deleteProperty(o, "gone"); return o; } +export function tagged() { return tag\`a\${{ part: 1 }}b\`; } `; const packageTypes = ` @@ -59,14 +89,43 @@ export declare const str: () => Str; export declare const obj: (shape: Record) => Obj; export declare function leak(value: T): T; export declare function reflect(): object; +export declare function arrayProp(): unknown; +export declare function computedField(k: string): unknown; +export declare function revive(text: string): unknown; +export declare function stringified(): unknown; +export declare function proxied(): unknown; +export declare function reflectDelete(): unknown; +export declare function tagged(): unknown; `; const mainSource = ` -import { leak, obj, reflect, str } from "shapes-fixture"; +import { + arrayProp, + computedField, + leak, + obj, + proxied, + reflect, + reflectDelete, + revive, + str, + stringified, + tagged, +} from "shapes-fixture"; const schema = obj({ a: str(), b: str() }); const input: unknown = JSON.parse('{"a":"x","b":"y"}'); console.log(JSON.stringify(schema.parse(input))); console.log(JSON.stringify(leak({ kept: 1 })), JSON.stringify(reflect())); +const more = [ + arrayProp(), + computedField("held"), + revive("{}"), + stringified(), + proxied(), + reflectDelete(), + tagged(), +]; +console.log(more.length); `; let dir: string; @@ -152,6 +211,28 @@ test("S1 report: per-field temporaries are provable records, dynamic objects sta expect(lines.find((l) => l.startsWith("class Str"))).toMatch(/_def: \{checks\}@/); }); +test("S1 report follows values through array properties, unknown keys, callbacks and reflection", async () => { + const lines = await report(); + // a named property of an array, read back and leaked + expect(site(lines, /^site \{listed\}@/)).toMatch(/: P-escaping/); + // computed writes to an instance, a class and a function, read by name + expect(site(lines, /^site \{boxed\}@/)).toMatch(/: P-escaping/); + expect(site(lines, /^site \{onClass\}@/)).toMatch(/: P-escaping/); + expect(site(lines, /^site \{onFunction\}@/)).toMatch(/: P-escaping/); + // a JSON.parse reviver, a JSON.stringify replacer, and toJSON methods + expect(site(lines, /^site \{revived\}@/)).toMatch(/: P-escaping — escapes: stored into ⊤/); + expect(site(lines, /^site \{replaced\}@/)).toMatch(/: P-escaping/); + expect(site(lines, /^site \{doc\}@/)).toMatch(/: P-escaping/); + expect(site(lines, /^site \{owned\}@/)).toMatch(/: P-escaping/); + // a Proxy target and Reflect.deleteProperty + expect(site(lines, /^site \{proxied\}@/)).toMatch(/: G — proxy at/); + expect(site(lines, /^site \{gone\}@/)).toMatch(/: G — delete at/); + // a tagged template's substitution, passed to its tag + expect(site(lines, /^site \{part\}@/)).toMatch(/: P-escaping/); + // the existing verdicts hold + expect(site(lines, /^site \{kept\}@main\.ts/)).toMatch(/: P-escaping — escapes: stored into ⊤/); +}); + test("S1 report is off by default and changes nothing", async () => { delete process.env["SCRIPTC_DEBUG_SHAPES"]; const writes = vi.spyOn(process.stderr, "write");