Skip to content

bug(hir): name-keyed weak/proxy intrinsic folds SILENTLY hijack same-named foreign receivers (deref/get/set/has → undefined) #7948

Description

@proggeramlug

Summary

The HIR pre-scan pre_scan_weakref_locals (crates/perry-hir/src/lower/pre_scan/weakref_locals.rs) records bare identifier names bound to new WeakRef/WeakMap/WeakSet/FinalizationRegistry/Proxy(...) and then folds <name>.<method>() straight to the corresponding runtime intrinsic. The sets are name-keyed with no scope discrimination, and the intrinsic helpers do not brand-check their receiver.

Consequence: if any binding anywhere in the module is new WeakRef(x) under the name r, then every r.<method>() in that module is folded onto the intrinsic — including an r that is a plain object, a user class instance, an array, or a function parameter. js_weakref_deref reads its __perry_wr_target slot by name off the foreign object, finds nothing, and answers undefined.

No throw. Exit code 0. Wrong answer. This is #7775 (proxy_locals) one class over, and #7775's fix — subtracting ambiguous names from the set — does not cover it.

Repro (silent, both arms exit 0)

const obj = { tag: "T" };

class Cache {
  v: number;
  constructor(v: number) { this.v = v; }
  deref(): number { return this.v * 10; }
}

function objLiteral()      { const r = { deref: () => 40 }; return r.deref(); }
function userClass()       { const r = new Cache(4);        return r.deref(); }
function arrayWithDeref()  { const r: any = [1,2,3]; r.deref = () => 41; return r.deref(); }
function paramNamed(r: { deref: () => number }) { return r.deref(); }
function genuine()         { const r = new WeakRef(obj); return (r.deref() as any).tag; }
function control()         { const q = new Cache(4);        return q.deref(); }

console.log("A objLiteral      expect 40 ->", objLiteral());
console.log("B userClass       expect 40 ->", userClass());
console.log("C arrayWithDeref  expect 41 ->", arrayWithDeref());
console.log("D paramNamed      expect 42 ->", paramNamed({ deref: () => 42 }));
console.log("E genuine         expect T  ->", genuine());
console.log("F control         expect 40 ->", control());
cell node 26.5.1 perry
A objLiteral ({ deref } literal) 40 undefined ✗ silent
B userClass (class Cache { deref() }) 40 undefined ✗ silent
C arrayWithDeref (arr.deref = …) 41 undefined ✗ silent
D paramNamed (function parameter) 42 undefined ✗ silent
E genuine (the real WeakRef) T T
F control (name q, no collision) 40 40

Delete genuine() — the only function that mentions WeakRef — and A–D all become correct. The presence of an unrelated WeakRef binding elsewhere in the module is the entire trigger.

The same hole exists for WeakMap/WeakSet

weakmap_locals/weakset_locals/proxy_locals are subtracted by the ambiguity poison pass, but that pass only recognises two initializer shapes (new <OtherClass>() and a call/await result). It cannot see an object literal, an array, or a parameter, so the identical hijack goes through with method names that are far more common than derefget, set, has, add, delete:

function objLiteralGet() { const m = { get: (k: string) => "lit:" + k }; return m.get("a"); }
function paramGet(m: { get: (k: string) => string }) { return m.get("b"); }
function arrayGet()      { const m: any = []; m.get = (k: string) => "arr:" + k; return m.get("c"); }
function genuineWeakMap(){ const k = {z:1}; const m = new WeakMap<object,number>(); m.set(k,5); return m.get(k); }
cell node perry
objLiteralGet lit:a undefined
paramGet p:b undefined
arrayGet arr:c undefined
genuineWeakMap 5 5

A one-letter m/r/g/p reused for a WeakMap in one function and a cache/options object in another is exactly what minified bundles look like.

Root cause

Two independent decisions compose into a wrong answer:

  1. pre_scan_weakref_locals is name-keyed and scope-blind (documented, deliberate) and only partially poisoned (new Other() + call/await inits; weakref_locals/finreg_locals are not poisoned at all — the in-file comment justifies that with "have no runtime method-dispatch fallback … so dropping them could regress a genuine instance with no upside").
  2. The intrinsic helpers the fold targets — js_weakref_deref, js_weakmap_get, js_finreg_register, … — accept any pointer and read their internal slot by name. A foreign receiver produces undefined rather than a diagnostic.

Name poisoning can only ever be a partial patch for (1), because the scan cannot enumerate every way a name acquires a non-intrinsic value (parameters and destructuring bindings are not even declarations it visits). The durable fix is (2): every folded intrinsic must brand-check its receiver and fall back to ordinary dynamic method dispatch on mismatch, so a wrong fold degrades to the correct slow path instead of a wrong answer.

Scope / relation to #7947

#7947 is the loud half of the same design: WeakRef.deref / FinalizationRegistry.register have no runtime dispatch fallback at all, so every receiver shape except a directly-named local throws TypeError. The fix for #7947 (adding runtime dispatch + prototype thunks for WeakRef/FinalizationRegistry) supplies exactly the fallback a brand-check needs, and the PR for #7947 also brand-checks js_weakref_deref / js_finreg_register / js_finreg_unregister.

This issue tracks the remaining half: js_weakmap_get/set/has/delete and js_weakset_add/has/delete (and the Proxy property fold) still hijack same-named foreign receivers through initializer shapes the poison pass cannot see.

Found while investigating #7947.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions