Skip to content

Commit 20c21d4

Browse files
proggeramlugRalph
andauthored
fix(hir): #5912 — new URL()/TextEncoder()/etc. ignore lexical shadowing (#5913)
* fix(hir): #5912 — new URL()/TextEncoder()/etc. ignore lexical shadowing `new URL(...)` (and URLSearchParams/URLPattern/TextEncoder/TextDecoder) were dispatched by bare identifier name with no check for whether the name actually resolves to the global constructor or is shadowed by a local function/class — unlike sibling well-known constructors (Function, Object) in the same match, which already guard on lookup_local/lookup_func/lookup_class. Real packages ship their own tolerant polyfills under these names (found via @mixmark-io/domino's lib/URL.js, which defines `function URL(url) { ... }` and calls `new URL()` with zero args against its own constructor) and hit perry's native WHATWG URL constructor instead, which requires at least one argument. The same unguarded name match also existed in a second, independent spot: `static_receiver_class` (used to decide whether `.toString()` / `.toJSON()` / `JSON.stringify()` on a receiver should route through the native Date/URL fast paths) classified any `new URL(...)` — or a local typed as `URL` — as the native type by name alone, so even after fixing the construction site, printing/serializing a shadowed instance still silently substituted native URL output. Fixes both sites by checking `lookup_local`/`lookup_func`/ `lookup_class` before applying the native lowering, matching the existing shadowing-guard pattern used elsewhere in expr_new.rs. * fix(hir): #5912 CodeRabbit follow-up — globalThis escape hatch, alias, imported bindings Three issues from CodeRabbit's review of the initial fix: 1. Real regression: the static_receiver_class shadowing guard collapsed `new globalThis.URL(...)` and bare `new URL(...)` into the same class_name capture, so a locally-shadowed URL made even the explicit globalThis-qualified form misclassify as "Object" and lose the native fast path. Track which callee shape matched and only apply the shadow check to the bare-identifier form. 2. Coverage gap: `const MyURL = URL; new MyURL()` bypassed the shadowing guard entirely via the resolve_class_alias branch, which runs before the later callee_local_at_entry/lookup_func/lookup_class checks. Added the same shadowing check there, against the ALIAS-RESOLVED name (aliases are name-keyed, not scope-aware). 3. Coverage gap: an imported binding shadowing one of these names (`import { URL } from "./polyfill"`) wasn't covered by the lookup_local/lookup_func/lookup_class triplet. Added lookup_imported_func alongside it in expr_new.rs (module-level registry, safe to query fresh — unlike lookup_local, this one isn't subject to the scope-stack-disturbance issue callee_local_at_entry exists to avoid, so no special snapshotting needed). static_receiver.rs's own guard now calls the existing `shadows_unqualified_global` helper (already covers all four cases) instead of reimplementing three of them by hand. Extended the regression test with both the globalThis-escape-hatch and alias cases; both match node byte-for-byte. --------- Co-authored-by: Ralph <ralph@skelpo.com>
1 parent 8426fa3 commit 20c21d4

3 files changed

Lines changed: 123 additions & 11 deletions

File tree

crates/perry-hir/src/lower/expr_call/static_receiver.rs

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,20 +50,43 @@ pub(super) fn static_receiver_class(
5050
}
5151
}
5252
if let ast::Expr::New(new_expr) = obj {
53-
let class_name = match new_expr.callee.as_ref() {
54-
ast::Expr::Ident(ident) => Some(ident.sym.as_ref()),
53+
// Issue #5912 (CodeRabbit follow-up): `new globalThis.URL(...)`
54+
// always reaches the REAL global regardless of any local `URL`
55+
// shadowing — that's the entire point of the explicit `globalThis.`
56+
// qualifier. Track which callee shape matched instead of collapsing
57+
// both into one `class_name` capture; the original version fed both
58+
// forms into the same shadow check below, incorrectly downgrading
59+
// `new globalThis.URL()` to "Object" whenever a local `URL` shadowed
60+
// the bare name.
61+
let (class_name, is_global_qualified) = match new_expr.callee.as_ref() {
62+
ast::Expr::Ident(ident) => (Some(ident.sym.as_ref()), false),
5563
ast::Expr::Member(member)
5664
if matches!(member.obj.as_ref(), ast::Expr::Ident(obj) if obj.sym.as_ref() == "globalThis")
5765
&& ctx.lookup_local("globalThis").is_none() =>
5866
{
5967
match &member.prop {
60-
ast::MemberProp::Ident(prop) => Some(prop.sym.as_ref()),
61-
_ => None,
68+
ast::MemberProp::Ident(prop) => (Some(prop.sym.as_ref()), true),
69+
_ => (None, false),
6270
}
6371
}
64-
_ => None,
72+
_ => (None, false),
6573
};
6674
if let Some(class_name) = class_name {
75+
// Issue #5912: a local function/const/class/imported-binding
76+
// (`shadows_unqualified_global` covers all four — see #5912
77+
// review follow-up) shadowing one of the well-known names below
78+
// (e.g. a vendored `function URL(url) { ... }` polyfill) is the
79+
// user's own value, never perry's native built-in — classify as
80+
// a generic "Object" (skips the ambiguous Date/URL method arms,
81+
// same treatment as an object-literal receiver below) instead of
82+
// misrouting `.toString()`/`.toJSON()` through the native fast
83+
// paths (`UrlInstanceToJSON` etc.) on a value that was never
84+
// actually constructed via the native path. Doesn't apply to the
85+
// `globalThis.X` form above — see the comment on
86+
// `is_global_qualified`.
87+
if !is_global_qualified && ctx.shadows_unqualified_global(class_name) {
88+
return Some("Object");
89+
}
6790
let resolved_class = ctx
6891
.resolve_class_alias(class_name)
6992
.unwrap_or_else(|| class_name.to_string());
@@ -139,6 +162,14 @@ pub(super) fn static_receiver_class(
139162
_ => None,
140163
};
141164
if let Some(n) = named {
165+
// Issue #5912: the local's inferred type name can legitimately
166+
// be "URL" because it holds an instance of a REAL user class
167+
// named `URL` (shadowing the global) — not perry's native
168+
// WHATWG URL. Route those through generic dispatch too, same
169+
// as the `New`-expression branch above.
170+
if ctx.lookup_class(n).is_some() {
171+
return Some("Object");
172+
}
142173
return match n {
143174
"Date" => Some("Date"),
144175
"URL" => Some("URL"),

crates/perry-hir/src/lower/expr_new.rs

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -416,8 +416,16 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R
416416
}
417417
}
418418

419+
// Issue #5912 (CodeRabbit follow-up): an alias like
420+
// `const MyURL = URL; new MyURL()` must not bind to the native
421+
// constructor either when the ALIASED name is itself shadowed
422+
// (`function URL(url) {...}` in scope) — `resolve_class_alias`
423+
// is name-keyed and not scope-aware, so it happily maps
424+
// `MyURL` -> `"URL"` without knowing `URL` was ever shadowed.
419425
if let Some(resolved) = ctx.resolve_class_alias(&class_name) {
420-
if is_url_encoding_constructor_name(&resolved) {
426+
if is_url_encoding_constructor_name(&resolved)
427+
&& !ctx.shadows_unqualified_global(&resolved)
428+
{
421429
if let Some(expr) =
422430
lower_url_encoding_constructor(ctx, &resolved, new_expr.args.as_deref())?
423431
{
@@ -941,15 +949,41 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R
941949
}
942950
}
943951

944-
// Handle URL class
945-
if class_name == "URL" {
952+
// Handle URL class. #5912: gated on `callee_local_at_entry` /
953+
// `lookup_func` / `lookup_imported_func` so a local function/
954+
// const/imported-binding shadowing the global name (e.g. a
955+
// vendored `function URL(url?) {...}` polyfill, or `import {
956+
// URL } from "./my-url-polyfill"`) routes through the generic
957+
// local-dispatch fallback below instead of always binding to
958+
// perry's native WHATWG URL constructor — matches the
959+
// `lookup_local`/`lookup_func`/`lookup_class` shadowing guard
960+
// used for `Function`/`Object` above (a named function
961+
// declaration is tracked via `lookup_func`, not
962+
// `lookup_local`/`callee_local_at_entry`). Deliberately doesn't
963+
// use the `shadows_unqualified_global` one-liner here: that
964+
// helper's `lookup_local` call is a FRESH scope lookup, but
965+
// `callee_local_at_entry` must stay a pre-captured snapshot (see
966+
// the comment above its definition) — the Error-type branch
967+
// just above already lowers `new_expr.args`, which can disturb
968+
// the locals scope stack before we get here.
969+
if class_name == "URL"
970+
&& callee_local_at_entry.is_none()
971+
&& ctx.lookup_func(&class_name).is_none()
972+
&& ctx.lookup_imported_func(&class_name).is_none()
973+
&& ctx.lookup_class(&class_name).is_none()
974+
{
946975
return Ok(
947976
lower_url_encoding_constructor(ctx, "URL", new_expr.args.as_deref())?.unwrap(),
948977
);
949978
}
950979

951980
// Handle URLSearchParams / URLPattern classes
952-
if matches!(class_name.as_str(), "URLSearchParams" | "URLPattern") {
981+
if matches!(class_name.as_str(), "URLSearchParams" | "URLPattern")
982+
&& callee_local_at_entry.is_none()
983+
&& ctx.lookup_func(&class_name).is_none()
984+
&& ctx.lookup_imported_func(&class_name).is_none()
985+
&& ctx.lookup_class(&class_name).is_none()
986+
{
953987
return Ok(lower_url_encoding_constructor(
954988
ctx,
955989
&class_name,
@@ -993,7 +1027,12 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R
9931027
return Ok(Expr::FinalizationRegistryNew(Box::new(cb)));
9941028
}
9951029
// Handle TextEncoder constructor
996-
if class_name == "TextEncoder" {
1030+
if class_name == "TextEncoder"
1031+
&& callee_local_at_entry.is_none()
1032+
&& ctx.lookup_func(&class_name).is_none()
1033+
&& ctx.lookup_imported_func(&class_name).is_none()
1034+
&& ctx.lookup_class(&class_name).is_none()
1035+
{
9971036
return Ok(lower_url_encoding_constructor(
9981037
ctx,
9991038
"TextEncoder",
@@ -1002,7 +1041,12 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R
10021041
.unwrap());
10031042
}
10041043
// Handle TextDecoder constructor: new TextDecoder(label?, opts?)
1005-
if class_name == "TextDecoder" {
1044+
if class_name == "TextDecoder"
1045+
&& callee_local_at_entry.is_none()
1046+
&& ctx.lookup_func(&class_name).is_none()
1047+
&& ctx.lookup_imported_func(&class_name).is_none()
1048+
&& ctx.lookup_class(&class_name).is_none()
1049+
{
10061050
return Ok(lower_url_encoding_constructor(
10071051
ctx,
10081052
"TextDecoder",
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Issue #5912 — `new URL(...)` (and URLSearchParams/URLPattern/TextEncoder/
2+
// TextDecoder) were dispatched by bare identifier name with no check for
3+
// whether the name is actually the global constructor or shadowed by a
4+
// local function/class. Real packages ship their own tolerant `URL`
5+
// polyfill (e.g. @mixmark-io/domino's lib/URL.js calls `new URL()` with
6+
// zero args against ITS OWN constructor) and hit perry's native URL
7+
// constructor instead, which requires at least one argument.
8+
//
9+
// This exercises the exact shadowing shape: a local function named `URL`
10+
// that tolerates a missing argument, matching Node's output.
11+
12+
function URL(url?: string) {
13+
return { url: url ?? "default", kind: "local" };
14+
}
15+
16+
console.log(JSON.stringify(new URL()));
17+
console.log(JSON.stringify(new URL("explicit")));
18+
19+
function withTextEncoder() {
20+
function TextEncoder(label?: string) {
21+
return { label: label ?? "utf-8", kind: "local" };
22+
}
23+
return new TextEncoder();
24+
}
25+
26+
console.log(JSON.stringify(withTextEncoder()));
27+
28+
// CodeRabbit follow-up on the #5913 PR — an explicit `globalThis.` qualifier
29+
// is an escape hatch to the REAL global and must keep working even while the
30+
// bare `URL` identifier is shadowed above.
31+
console.log(new (globalThis as any).URL("https://example.com/path").hostname);
32+
33+
// CodeRabbit follow-up — a local alias of the shadowed name must not resolve
34+
// back to the native constructor either (`resolve_class_alias` is name-keyed,
35+
// not scope-aware, so this needs its own explicit check).
36+
const MyURL = URL;
37+
console.log(JSON.stringify(new MyURL("via-alias")));

0 commit comments

Comments
 (0)