From bdc3befb5263ae2c150db0866a400dc2160ea796 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 17 Jun 2026 06:40:45 +0200 Subject: [PATCH] fix(runtime): loose equality treats SSO short strings as strings js_jsvalue_loose_equals used is_string() (heap STRING_TAG only), missing SHORT_STRING_TAG SSO-inlined strings. Two short-string operands matched no arm and fell through to return 0, so "ab" == "ab" and loose assert.equal/assert.deepEqual on JSON-parsed short strings wrongly reported not-equal. to_number had the same gap ("5" == 5 failed for SSO). Both sites now use is_any_string() and decode via str_bytes_from_jsvalue, mirroring the already-SSO-aware strict-equality path. Adds loose_eq_sso_tests. Found via external code audit; verified reproducible. --- CHANGELOG.md | 31 +++++ CLAUDE.md | 2 +- Cargo.lock | 148 ++++++++++----------- Cargo.toml | 2 +- crates/perry-runtime/src/value/equality.rs | 105 ++++++++++++--- 5 files changed, 195 insertions(+), 93 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 807dc76fd0..bc20a947cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,34 @@ +## v0.5.1176 — fix(runtime): loose equality (`==`) now treats SSO short strings as strings + +`js_jsvalue_loose_equals` (the helper behind `assert.equal`/`assert.deepEqual` +loose comparison) detected string operands with `is_string()`, which only +matches heap `STRING_TAG` values and **not** `SHORT_STRING_TAG` SSO-inlined +strings (length ≤ 5). When both operands were short strings, the function +matched no arm — not number, not string, not bool — and fell through to +`return 0`, so `"ab" == "ab"` and loose `assert.equal` on JSON-parsed short +strings wrongly reported not-equal. The `to_number` coercion helper had the +same `is_string()` gap, so `"5" == 5` also failed for SSO operands. + +Both sites now use `is_any_string()` and decode operands via +`str_bytes_from_jsvalue` (a stack scratch buffer that handles SSO + heap), +mirroring the strict-equality (`===`) path, which was already SSO-aware. This +is a runtime-only change: codegen routes most `==` through other helpers +(`js_loose_eq`, `js_dynamic_string_equals`), so the user-visible symptom was +`assert.equal`/`assert.deepEqual` on short strings reporting inequality. + +Repro (failed before, passes now, matches `node --experimental-strip-types`): + +```ts +import assert from "node:assert"; +const j: any = JSON.parse('{"k":"ab"}'); +assert.equal(j.k, "ab"); // was: "Expected values to be loosely equal" +assert.deepEqual({ x: "ab" }, JSON.parse('{"x":"ab"}')); +``` + +Found via an external code audit; verified reproducible before fixing. +Added `value::equality::loose_eq_sso_tests` (SSO==SSO, SSO==heap, SSO vs +number, empty-string coercion). + ## v0.5.1175 — fix(http): `IncomingMessage.resume()`/`.pause()` return `this` so `res.resume().on('end', …)` chains (#4975) Part of the node:http/https behavioral-parity tail (#4975). `Readable.pause()` diff --git a/CLAUDE.md b/CLAUDE.md index f97ea8625c..3707a4a109 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1175 +**Current Version:** 0.5.1176 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index a3a5261d09..dae80fa371 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5283,7 +5283,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "anyhow", "base64", @@ -5340,14 +5340,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "cc", "libc", @@ -5355,7 +5355,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "anyhow", "log", @@ -5370,7 +5370,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "anyhow", "perry-hir", @@ -5379,7 +5379,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "anyhow", "perry-hir", @@ -5387,7 +5387,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "anyhow", "perry-dispatch", @@ -5397,7 +5397,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "anyhow", "perry-hir", @@ -5406,7 +5406,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "anyhow", "base64", @@ -5419,7 +5419,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "anyhow", "perry-hir", @@ -5427,7 +5427,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "anyhow", "async-trait", @@ -5456,14 +5456,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "serde", "serde_json", @@ -5471,7 +5471,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1175" +version = "0.5.1176" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5482,7 +5482,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "anyhow", "clap", @@ -5497,14 +5497,14 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-argon2" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "argon2", "perry-ffi", @@ -5512,7 +5512,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "perry-ffi", "reqwest", @@ -5521,7 +5521,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "bcrypt", "perry-ffi", @@ -5529,7 +5529,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "perry-ffi", "rusqlite", @@ -5537,7 +5537,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "perry-ffi", "scraper", @@ -5545,7 +5545,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "perry-ffi", "perry-runtime", @@ -5553,7 +5553,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "chrono", "cron 0.16.0", @@ -5563,7 +5563,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "chrono", "perry-ffi", @@ -5571,7 +5571,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "perry-ffi", "rust_decimal", @@ -5579,7 +5579,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "perry-ffi", "serde_json", @@ -5587,7 +5587,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "perry-ffi", "rand 0.8.6", @@ -5595,7 +5595,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "perry-ffi", "perry-runtime", @@ -5603,14 +5603,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "bytes", "http-body-util", @@ -5627,7 +5627,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "lazy_static", "perry-ffi", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "lazy_static", "perry-ext-http-server", @@ -5652,7 +5652,7 @@ dependencies = [ [[package]] name = "perry-ext-http-server" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "bytes", "h2", @@ -5675,7 +5675,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "lazy_static", "perry-ffi", @@ -5685,7 +5685,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "base64", "jsonwebtoken", @@ -5696,7 +5696,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "lru", "perry-ffi", @@ -5704,7 +5704,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "chrono", "perry-ffi", @@ -5712,7 +5712,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "bson", "futures-util", @@ -5724,7 +5724,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "chrono", "perry-ffi", @@ -5734,7 +5734,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "nanoid", "perry-ffi", @@ -5743,7 +5743,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "perry-ffi", "perry-runtime", @@ -5755,7 +5755,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "lettre", "perry-ffi", @@ -5765,7 +5765,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "perry-ffi", "printpdf", @@ -5773,7 +5773,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "perry-ffi", "sqlx", @@ -5782,7 +5782,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "governor", "perry-ffi", @@ -5790,7 +5790,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "base64", "image", @@ -5799,14 +5799,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "lazy_static", "perry-ffi", @@ -5815,7 +5815,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "perry-ffi", "uuid", @@ -5823,7 +5823,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "perry-ffi", "regex", @@ -5833,7 +5833,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "futures-util", "lazy_static", @@ -5845,7 +5845,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "brotli", "flate2", @@ -5854,7 +5854,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "dashmap", "once_cell", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "anyhow", "perry-api-manifest", @@ -5880,7 +5880,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "anyhow", "perry-diagnostics", @@ -5892,7 +5892,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "anyhow", "base64", @@ -5924,7 +5924,7 @@ dependencies = [ [[package]] name = "perry-stdlib" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "aes 0.8.4", "aes-gcm", @@ -6016,7 +6016,7 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "anyhow", "perry-hir", @@ -6026,7 +6026,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -6034,14 +6034,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "base64", "itoa", @@ -6058,7 +6058,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "rand 0.8.6", "serde", @@ -6068,7 +6068,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "base64", "cairo-rs", @@ -6091,7 +6091,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "base64", "block2", @@ -6107,7 +6107,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "base64", "block2", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1175" +version = "0.5.1176" [[package]] name = "perry-ui-test" @@ -6130,11 +6130,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.1175" +version = "0.5.1176" [[package]] name = "perry-ui-tvos" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "base64", "block2", @@ -6150,7 +6150,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "base64", "block2", @@ -6166,7 +6166,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "block2", "libc", @@ -6179,7 +6179,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "base64", "libc", @@ -6196,14 +6196,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "base64", "ed25519-dalek", @@ -6217,7 +6217,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1175" +version = "0.5.1176" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 8847c6979b..eaf1f53f1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -215,7 +215,7 @@ strip = false codegen-units = 16 [workspace.package] -version = "0.5.1175" +version = "0.5.1176" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/crates/perry-runtime/src/value/equality.rs b/crates/perry-runtime/src/value/equality.rs index c2f8e2fdb6..e6883caddd 100644 --- a/crates/perry-runtime/src/value/equality.rs +++ b/crates/perry-runtime/src/value/equality.rs @@ -214,19 +214,18 @@ pub extern "C" fn js_jsvalue_loose_equals(a: f64, b: f64) -> i32 { Some(raw) } else if val.is_bool() { Some(if val.as_bool() { 1.0 } else { 0.0 }) - } else if val.is_string() { - let ptr = val.as_string_ptr(); - if ptr.is_null() { + } else if val.is_any_string() { + // Accept both STRING_TAG heap strings and SHORT_STRING_TAG + // inline SSO values via str_bytes_from_jsvalue (decodes SSO + // into the scratch buffer). Using is_string() alone here + // misses SSO strings, so `"5" == 5` returned 0 for short + // string operands. + let mut scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let Some((ptr, len)) = crate::string::str_bytes_from_jsvalue(raw, &mut scratch) else { return Some(f64::NAN); - } - let header = unsafe { &*ptr }; + }; let s = unsafe { - let data = - (ptr as *const u8).add(std::mem::size_of::()); - std::str::from_utf8_unchecked(std::slice::from_raw_parts( - data, - header.byte_len as usize, - )) + std::str::from_utf8_unchecked(std::slice::from_raw_parts(ptr, len as usize)) }; let trimmed = s.trim(); if trimmed.is_empty() { @@ -242,16 +241,37 @@ pub extern "C" fn js_jsvalue_loose_equals(a: f64, b: f64) -> i32 { // If both are same type, delegate to strict equals let a_is_num = a_val.is_int32() || is_plain_number(abits); let b_is_num = b_val.is_int32() || is_plain_number(bbits); - let a_is_str = a_val.is_string(); - let b_is_str = b_val.is_string(); + // Must accept SHORT_STRING_TAG (SSO) operands too, not just + // STRING_TAG heap strings — otherwise short strings fall through + // every arm below to `return 0`, so `"ab" == "ab"` (and loose + // assert.equal on SSO values) wrongly reported not-equal. Mirrors + // the strict-equality path above, which already uses is_any_string. + let a_is_str = a_val.is_any_string(); + let b_is_str = b_val.is_any_string(); let a_is_bool = a_val.is_bool(); let b_is_bool = b_val.is_bool(); - // Both strings: strict string comparison + // Both strings: compare by content, handling SSO on either side via + // a stack scratch buffer (js_string_equals only accepts heap ptrs). if a_is_str && b_is_str { - let a_ptr = a_val.as_string_ptr(); - let b_ptr = b_val.as_string_ptr(); - return crate::string::js_string_equals(a_ptr, b_ptr); + let mut a_scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let mut b_scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let a_view = crate::string::str_bytes_from_jsvalue(a, &mut a_scratch); + let b_view = crate::string::str_bytes_from_jsvalue(b, &mut b_scratch); + if let (Some((a_ptr, a_len)), Some((b_ptr, b_len))) = (a_view, b_view) { + if a_len != b_len { + return 0; + } + if a_len == 0 { + return 1; + } + unsafe { + let a_slice = std::slice::from_raw_parts(a_ptr, a_len as usize); + let b_slice = std::slice::from_raw_parts(b_ptr, b_len as usize); + return if a_slice == b_slice { 1 } else { 0 }; + } + } + return 0; } // Both numbers: numeric comparison @@ -452,3 +472,54 @@ fn extract_string_ptr(value: f64) -> *const crate::StringHeader { std::ptr::null() } + +#[cfg(test)] +mod loose_eq_sso_tests { + //! Regression: `js_jsvalue_loose_equals` must treat SHORT_STRING_TAG + //! (SSO-inlined) operands as strings. Before the fix it used + //! `is_string()` (heap-only), so two short strings fell through every + //! arm to `return 0` — e.g. loose `assert.equal` on JSON-parsed short + //! strings wrongly reported not-equal. + use super::*; + + fn sso(bytes: &[u8]) -> f64 { + f64::from_bits(JSValue::short_string_unchecked(bytes).bits()) + } + + fn heap(bytes: &[u8]) -> f64 { + let ptr = crate::string::js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32); + js_nanbox_string(ptr as i64) + } + + #[test] + fn sso_eq_sso_same_content() { + assert_eq!(js_jsvalue_loose_equals(sso(b"ab"), sso(b"ab")), 1); + } + + #[test] + fn sso_neq_sso_different_content() { + assert_eq!(js_jsvalue_loose_equals(sso(b"ab"), sso(b"ac")), 0); + assert_eq!(js_jsvalue_loose_equals(sso(b"ab"), sso(b"abc")), 0); + } + + #[test] + fn sso_eq_heap_same_content() { + assert_eq!(js_jsvalue_loose_equals(sso(b"ab"), heap(b"ab")), 1); + assert_eq!(js_jsvalue_loose_equals(heap(b"ab"), sso(b"ab")), 1); + } + + #[test] + fn sso_string_loose_eq_number() { + // ToNumber("5") == 5 + assert_eq!(js_jsvalue_loose_equals(sso(b"5"), 5.0), 1); + assert_eq!(js_jsvalue_loose_equals(5.0, sso(b"5")), 1); + assert_eq!(js_jsvalue_loose_equals(sso(b"5"), 6.0), 0); + } + + #[test] + fn empty_sso_loose_eq_zero() { + // ToNumber("") == 0 + assert_eq!(js_jsvalue_loose_equals(sso(b""), 0.0), 1); + assert_eq!(js_jsvalue_loose_equals(sso(b""), sso(b"")), 1); + } +}