diff --git a/crates/perry-codegen/src/expr/helpers.rs b/crates/perry-codegen/src/expr/helpers.rs index 12ad7c24b83..75f954d4fc8 100644 --- a/crates/perry-codegen/src/expr/helpers.rs +++ b/crates/perry-codegen/src/expr/helpers.rs @@ -358,6 +358,11 @@ pub(crate) fn is_global_this_builtin_name(name: &str) -> bool { | "decodeURI" | "encodeURIComponent" | "decodeURIComponent" + // #4511: legacy escape/unescape (ES Annex B). + | "escape" + | "unescape" + // #4511: Node's `global` alias (typeof === "object"). + | "global" // Test262 installs this dynamically on globalThis. Route reads // through the singleton so bare `print(...)` can call it once the // harness assigns the property. @@ -403,6 +408,8 @@ pub(crate) fn is_global_this_builtin_function_name(name: &str) -> bool { | "crypto" | "localStorage" | "sessionStorage" + // #4511: `typeof global === "object"`. + | "global" ) } diff --git a/crates/perry-hir/src/analysis/builtins.rs b/crates/perry-hir/src/analysis/builtins.rs index 9ebdbf80ee1..75c6a9f460d 100644 --- a/crates/perry-hir/src/analysis/builtins.rs +++ b/crates/perry-hir/src/analysis/builtins.rs @@ -144,6 +144,15 @@ pub(crate) fn is_builtin_global_value_name(name: &str) -> bool { | "decodeURI" | "encodeURIComponent" | "decodeURIComponent" + // #4511: legacy escape/unescape (ES Annex B). Bare calls and value + // reads both resolve through `globalThis.` → the runtime + // thunk (no dedicated HIR variant). Used by `qs` (→ `stripe`). + | "escape" + | "unescape" + // #4511: Node's `global` alias for the global object. Resolves to + // `globalThis.global`, a self-reference installed in + // `populate_global_this_builtins`. + | "global" ) } diff --git a/crates/perry-runtime/src/builtins/globals.rs b/crates/perry-runtime/src/builtins/globals.rs index 65cbe2c2a26..9540ecc1576 100644 --- a/crates/perry-runtime/src/builtins/globals.rs +++ b/crates/perry-runtime/src/builtins/globals.rs @@ -156,6 +156,90 @@ pub extern "C" fn js_decode_uri_component(value: f64) -> i64 { ptr as i64 } +// ============================================================ +// escape / unescape (ECMAScript Annex B B.2.1) +// ============================================================ + +/// Characters `escape()` leaves unencoded (ES Annex B B.2.1.1): ASCII +/// letters, digits, and `@ * _ + - . /`. Unlike `encodeURIComponent`, the +/// escape set keeps `+` and `@` and encodes everything else — code units +/// < 256 as `%XX`, the rest as `%uXXXX`. +const ESCAPE_UNESCAPED: &[u8] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@*_+-./"; + +/// escape(string) -> string (legacy, ES Annex B B.2.1.1) +#[no_mangle] +pub extern "C" fn js_escape(value: f64) -> i64 { + let input = extract_str_from_nanbox(value); + let mut result = String::with_capacity(input.len() * 3); + let mut buf = [0u16; 2]; + for c in input.chars() { + let cp = c as u32; + if cp < 0x80 && ESCAPE_UNESCAPED.contains(&(cp as u8)) { + result.push(c); + } else { + // Encode per UTF-16 code unit so astral code points emit the two + // `%uXXXX` escapes for their surrogate pair, matching Node. + for unit in c.encode_utf16(&mut buf) { + if *unit < 0x100 { + result.push_str(&format!("%{:02X}", *unit)); + } else { + result.push_str(&format!("%u{:04X}", *unit)); + } + } + } + } + let ptr = js_string_from_bytes(result.as_ptr(), result.len() as u32); + ptr as i64 +} + +/// unescape(string) -> string (legacy, ES Annex B B.2.1.2) +#[no_mangle] +pub extern "C" fn js_unescape(value: f64) -> i64 { + let input = extract_str_from_nanbox(value); + let chars: Vec = input.chars().collect(); + // Reassemble into UTF-16 code units, then decode, so `%uD835%uDFD8`-style + // surrogate pairs recombine into a single astral scalar. + let mut units: Vec = Vec::with_capacity(chars.len()); + let mut i = 0; + while i < chars.len() { + if chars[i] == '%' { + // %uXXXX + if i + 5 < chars.len() && chars[i + 1] == 'u' { + if let Some(u) = hex4_chars(&chars[i + 2..i + 6]) { + units.push(u); + i += 6; + continue; + } + } + // %XX + if i + 2 < chars.len() { + if let (Some(h), Some(l)) = (chars[i + 1].to_digit(16), chars[i + 2].to_digit(16)) { + units.push((h * 16 + l) as u16); + i += 3; + continue; + } + } + } + let mut buf = [0u16; 2]; + for unit in chars[i].encode_utf16(&mut buf) { + units.push(*unit); + } + i += 1; + } + let decoded = String::from_utf16_lossy(&units); + let ptr = js_string_from_bytes(decoded.as_ptr(), decoded.len() as u32); + ptr as i64 +} + +fn hex4_chars(cs: &[char]) -> Option { + let mut v: u16 = 0; + for &c in cs { + v = v.checked_mul(16)?.checked_add(c.to_digit(16)? as u16)?; + } + Some(v) +} + // ============================================================ // structuredClone // ============================================================ diff --git a/crates/perry-runtime/src/builtins/mod.rs b/crates/perry-runtime/src/builtins/mod.rs index 2c034e44a1c..cffa8fa6340 100644 --- a/crates/perry-runtime/src/builtins/mod.rs +++ b/crates/perry-runtime/src/builtins/mod.rs @@ -92,9 +92,9 @@ pub(crate) use formatting::{ pub use globals::{ js_decode_uri, js_decode_uri_component, js_drain_queued_microtasks, js_encode_uri, - js_encode_uri_component, js_queue_microtask, js_queue_next_tick, js_queue_next_tick_args, - js_structured_clone, js_text_decoder_decode, js_text_encoder_encode, - restore_queued_microtask_contexts, scan_queued_microtask_roots, + js_encode_uri_component, js_escape, js_queue_microtask, js_queue_next_tick, + js_queue_next_tick_args, js_structured_clone, js_text_decoder_decode, js_text_encoder_encode, + js_unescape, restore_queued_microtask_contexts, scan_queued_microtask_roots, scan_queued_microtask_roots_mut, }; diff --git a/crates/perry-runtime/src/object/global_this.rs b/crates/perry-runtime/src/object/global_this.rs index 38bc3c4ef98..521b0b868d1 100644 --- a/crates/perry-runtime/src/object/global_this.rs +++ b/crates/perry-runtime/src/object/global_this.rs @@ -866,6 +866,23 @@ extern "C" fn global_this_decode_uri_component_thunk( crate::value::js_nanbox_string(crate::builtins::js_decode_uri_component(value)) } +// #4511: legacy `escape()` / `unescape()` (ES Annex B). Used in the wild by +// `qs` for `%uXXXX` decoding, so any app pulling in `qs` (e.g. via `stripe`) +// needs them as real callable globalThis function values. +extern "C" fn global_this_escape_thunk( + _closure: *const crate::closure::ClosureHeader, + value: f64, +) -> f64 { + crate::value::js_nanbox_string(crate::builtins::js_escape(value)) +} + +extern "C" fn global_this_unescape_thunk( + _closure: *const crate::closure::ClosureHeader, + value: f64, +) -> f64 { + crate::value::js_nanbox_string(crate::builtins::js_unescape(value)) +} + // #2889: call-form thunks for `Number`/`Boolean` global constructor values. // `Object`/`String` already have dedicated thunks above; these mirror the // bare-call HIR lowering (`Expr::NumberCoerce` / `Expr::BooleanCoerce`) so @@ -2266,6 +2283,16 @@ pub(crate) fn populate_global_this_builtins(singleton: *mut ObjectHeader) { let value = crate::value::js_nanbox_pointer(singleton as i64); js_object_set_field_by_name(singleton, key, value); } + { + // #4511: Node exposes the global object as `global` too + // (`global === globalThis`). Install the same self-reference so bare + // `global` / `(global as any).x` reads resolve to the real singleton + // instead of the unknown-identifier sentinel. + let name = b"global"; + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + let value = crate::value::js_nanbox_pointer(singleton as i64); + js_object_set_field_by_name(singleton, key, value); + } // #2145: pre-allocate the shared `%TypedArray%` intrinsic so per-kind // typed-array constructors can link their `__proto__` to it as they're // built below, and the per-kind `.prototype` objects can be flagged with @@ -2593,6 +2620,9 @@ pub(crate) fn populate_global_this_builtins(singleton: *mut ObjectHeader) { 1, false, ), + // #4511: legacy escape/unescape (ES Annex B). + "escape" => (global_this_escape_thunk as *const u8, 1, false), + "unescape" => (global_this_unescape_thunk as *const u8, 1, false), _ => continue, }; let closure_ptr = crate::closure::js_closure_alloc(func_ptr, 0); diff --git a/crates/perry-runtime/src/object/global_this_tables.rs b/crates/perry-runtime/src/object/global_this_tables.rs index faa1648e125..9549f7e9554 100644 --- a/crates/perry-runtime/src/object/global_this_tables.rs +++ b/crates/perry-runtime/src/object/global_this_tables.rs @@ -203,6 +203,9 @@ pub(crate) const GLOBAL_THIS_BUILTIN_FUNCTIONS: &[&str] = &[ "decodeURI", "encodeURIComponent", "decodeURIComponent", + // #4511: legacy escape/unescape (ES Annex B), used by `qs`. + "escape", + "unescape", ]; pub(crate) fn is_web_fetch_constructor(name: &str) -> bool { diff --git a/test-files/test_gap_escape_unescape_global_4511.ts b/test-files/test_gap_escape_unescape_global_4511.ts new file mode 100644 index 00000000000..a9574d54f7b --- /dev/null +++ b/test-files/test_gap_escape_unescape_global_4511.ts @@ -0,0 +1,27 @@ +// #4511 — legacy escape()/unescape() (ES Annex B) + Node `global` alias + +// Bare calls +console.log(escape("a b+c"), unescape("a%20b")); +// Non-ASCII: code units < 256 -> %XX, BMP -> %uXXXX, astral -> surrogate pair +console.log(escape("café ☃ 𝟘")); +console.log(unescape("caf%E9 %u2603")); + +// typeof — both are functions +console.log(typeof escape, typeof unescape); +console.log(typeof globalThis.escape, typeof globalThis.unescape); + +// Rebound as values +const e = escape; +const u = unescape; +console.log(e("100%"), u("%41%42")); + +// Through globalThis explicitly +console.log(globalThis.escape("@*_+-./"), globalThis.unescape("%7E")); + +// Malformed / partial escapes pass through unchanged +console.log(unescape("%"), unescape("%2"), unescape("%zz"), unescape("100%")); + +// Node `global` object: alias for globalThis, typeof "object" +console.log(typeof global, global === globalThis); +(global as any).__perry_4511 = 7; +console.log((global as any).__perry_4511, (globalThis as any).__perry_4511);