From 44c4b93fd92faa85d3610629ce01d011ff7dbc99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 06:44:42 +0000 Subject: [PATCH 1/3] fix(stdlib): crypto string arguments materialize an SSO value (#11430) --- crates/perry-stdlib/src/crypto/cipher.rs | 6 +++--- crates/perry-stdlib/src/crypto/ecdh.rs | 4 ++-- crates/perry-stdlib/src/crypto/hash_handles.rs | 7 ++++--- crates/perry-stdlib/src/crypto/x509.rs | 6 ++++++ 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/crates/perry-stdlib/src/crypto/cipher.rs b/crates/perry-stdlib/src/crypto/cipher.rs index dabff10f88..afc3ce61b8 100644 --- a/crates/perry-stdlib/src/crypto/cipher.rs +++ b/crates/perry-stdlib/src/crypto/cipher.rs @@ -725,7 +725,7 @@ pub unsafe fn dispatch_cipher(handle: i64, method: &str, args: &[f64]) -> f64 { decode_string_bytes_with_tag(&str_bytes, in_tag) } None => { - let ptr = (args[0].to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64; + let ptr = arg_ptr(args[0]); bytes_from_ptr(ptr) } }; @@ -1160,7 +1160,7 @@ pub unsafe fn dispatch_cipher(handle: i64, method: &str, args: &[f64]) -> f64 { if state.encrypt || !state.kind.is_gcm() { return nanbox_undefined(); } - let ptr = (args[0].to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64; + let ptr = arg_ptr(args[0]); let tag = bytes_from_ptr(ptr); state.auth_tag = Some(tag); nanbox_pointer_f64(handle as usize) @@ -1172,7 +1172,7 @@ pub unsafe fn dispatch_cipher(handle: i64, method: &str, args: &[f64]) -> f64 { if args.is_empty() { state.aad.clear(); } else { - let ptr = (args[0].to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64; + let ptr = arg_ptr(args[0]); state.aad = bytes_from_ptr(ptr); } nanbox_pointer_f64(handle as usize) diff --git a/crates/perry-stdlib/src/crypto/ecdh.rs b/crates/perry-stdlib/src/crypto/ecdh.rs index bfbf19f477..5b59c5d5a2 100644 --- a/crates/perry-stdlib/src/crypto/ecdh.rs +++ b/crates/perry-stdlib/src/crypto/ecdh.rs @@ -159,7 +159,7 @@ pub unsafe fn dispatch_sign(handle: i64, method: &str, args: &[f64]) -> f64 { } match method { "update" if !args.is_empty() => { - let ptr = (args[0].to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64; + let ptr = arg_ptr(args[0]); let bytes = bytes_from_ptr(ptr); h.data.lock().unwrap().extend_from_slice(&bytes); f64::from_bits(0x7FFD_0000_0000_0000u64 | ((handle as u64) & 0x0000_FFFF_FFFF_FFFF)) @@ -473,7 +473,7 @@ pub unsafe fn dispatch_verify(handle: i64, method: &str, args: &[f64]) -> f64 { } match method { "update" if !args.is_empty() => { - let ptr = (args[0].to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64; + let ptr = arg_ptr(args[0]); let bytes = bytes_from_ptr(ptr); h.data.lock().unwrap().extend_from_slice(&bytes); f64::from_bits(0x7FFD_0000_0000_0000u64 | ((handle as u64) & 0x0000_FFFF_FFFF_FFFF)) diff --git a/crates/perry-stdlib/src/crypto/hash_handles.rs b/crates/perry-stdlib/src/crypto/hash_handles.rs index 29fac6f486..88d7b96214 100644 --- a/crates/perry-stdlib/src/crypto/hash_handles.rs +++ b/crates/perry-stdlib/src/crypto/hash_handles.rs @@ -131,7 +131,8 @@ fn js_true() -> f64 { } fn unbox_to_i64(value: f64) -> i64 { - (value.to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64 + // SSO-aware (#11430): a short string argument is materialized first. + arg_ptr(value) } fn update_hash_state(state: &mut HashState, bytes: &[u8]) { @@ -510,7 +511,7 @@ pub unsafe fn dispatch_hash(handle: i64, method: &str, args: &[f64]) -> f64 { { output_encoding.to_ascii_lowercase() } else { - let enc_ptr = (args[0].to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64; + let enc_ptr = arg_ptr(args[0]); let enc_bytes = bytes_from_ptr(enc_ptr); std::str::from_utf8(&enc_bytes) .unwrap_or("hex") @@ -769,7 +770,7 @@ pub unsafe fn dispatch_hmac(handle: i64, method: &str, args: &[f64]) -> f64 { let buf = alloc_buffer_from_slice(&digest); f64::from_bits(0x7FFD_0000_0000_0000u64 | ((buf as u64) & 0x0000_FFFF_FFFF_FFFF)) } else { - let enc_ptr = (args[0].to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64; + let enc_ptr = arg_ptr(args[0]); let enc_bytes = bytes_from_ptr(enc_ptr); let enc = std::str::from_utf8(&enc_bytes) .unwrap_or("hex") diff --git a/crates/perry-stdlib/src/crypto/x509.rs b/crates/perry-stdlib/src/crypto/x509.rs index e7516c7704..4ab1b386e4 100644 --- a/crates/perry-stdlib/src/crypto/x509.rs +++ b/crates/perry-stdlib/src/crypto/x509.rs @@ -1264,6 +1264,12 @@ pub(super) fn nanbox_ptr(ptr: *mut T) -> f64 { } pub(super) fn arg_ptr(arg: f64) -> i64 { + // #11430: an SSO short string (`String(7)`, `` `${n}` ``) carries its + // characters inline in the NaN-box — masking it yields a garbage address + // that `bytes_from_ptr` then dereferences. Materialize it on the heap. + if perry_runtime::JSValue::from_bits(arg.to_bits()).is_short_string() { + return perry_runtime::js_get_string_pointer_unified(arg); + } (arg.to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64 } From 90135f8e197090e638c334e11cf5d8d4bcb91283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 07:17:36 +0000 Subject: [PATCH 2/3] fix(codegen,runtime): crypto native calls copy an SSO string argument into a scratch header (#11430) --- crates/perry-codegen/src/expr/calls.rs | 2 +- .../src/expr/calls/crypto_hash.rs | 6 +- .../src/expr/calls/crypto_kdf.rs | 48 ++++++------- .../src/expr/calls/crypto_keys.rs | 6 +- .../src/expr/calls/crypto_misc.rs | 36 +++++----- crates/perry-codegen/src/expr/helpers.rs | 8 +++ crates/perry-codegen/src/expr/mod.rs | 2 +- .../src/runtime_decls/strings.rs | 1 + crates/perry-runtime/src/value/mod.rs | 2 +- crates/perry-runtime/src/value/nanbox.rs | 68 +++++++++++++++++++ crates/perry-stdlib/src/crypto/x509.rs | 8 +-- scripts/gc_runtime_root_holders.json | 6 ++ ...test_gap_11430_crypto_short_string_args.ts | 23 +++++++ 13 files changed, 160 insertions(+), 56 deletions(-) create mode 100644 test-files/test_gap_11430_crypto_short_string_args.ts diff --git a/crates/perry-codegen/src/expr/calls.rs b/crates/perry-codegen/src/expr/calls.rs index d2975c8799..0f4375c6a8 100644 --- a/crates/perry-codegen/src/expr/calls.rs +++ b/crates/perry-codegen/src/expr/calls.rs @@ -18,7 +18,7 @@ use crate::types::{DOUBLE, I64}; use super::{ emit_string_literal_global, lower_expr, nanbox_pointer_inline, nanbox_string_inline, - unbox_str_handle, unbox_to_i64, FnCtx, + unbox_ffi_str_arg, unbox_str_handle, FnCtx, }; mod crypto_hash; diff --git a/crates/perry-codegen/src/expr/calls/crypto_hash.rs b/crates/perry-codegen/src/expr/calls/crypto_hash.rs index 1dde185e46..f41b7c8f68 100644 --- a/crates/perry-codegen/src/expr/calls/crypto_hash.rs +++ b/crates/perry-codegen/src/expr/calls/crypto_hash.rs @@ -218,13 +218,13 @@ pub(crate) fn arm_crypto_hash_chain( } } let blk = ctx.block(); - let alg_handle = unbox_to_i64(blk, &alg_box); + let alg_handle = unbox_ffi_str_arg(blk, &alg_box); // Allocate the handle. Both helpers return f64 already // NaN-boxed with POINTER_TAG, suitable as the receiver // for `js_native_call_method`. let recv = if create_method == "createHmac" || create_method == "Hmac" { let key_box = key_box_opt.expect("createHmac needs a key arg"); - let key_handle = unbox_to_i64(blk, &key_box); + let key_handle = unbox_ffi_str_arg(blk, &key_box); blk.call( DOUBLE, "js_crypto_create_hmac", @@ -337,7 +337,7 @@ pub(crate) fn arm_crypto_create_hash( // #2013/#3146: reject a non-string algorithm before unboxing. emit_validate_string_arg(ctx, &alg_box, "algorithm"); let blk = ctx.block(); - let alg_handle = unbox_to_i64(blk, &alg_box); + let alg_handle = unbox_ffi_str_arg(blk, &alg_box); // Returns an already-NaN-boxed f64 (POINTER_TAG + handle id). if let Some(options_box) = options_box { Ok(blk.call( diff --git a/crates/perry-codegen/src/expr/calls/crypto_kdf.rs b/crates/perry-codegen/src/expr/calls/crypto_kdf.rs index 8fbc2e4f18..26c2238c3e 100644 --- a/crates/perry-codegen/src/expr/calls/crypto_kdf.rs +++ b/crates/perry-codegen/src/expr/calls/crypto_kdf.rs @@ -18,7 +18,7 @@ pub(crate) fn arm_crypto_argon2_sync( let alg_box = lower_expr(ctx, &args[0])?; let params_box = lower_expr(ctx, &args[1])?; let blk = ctx.block(); - let alg_handle = unbox_to_i64(blk, &alg_box); + let alg_handle = unbox_ffi_str_arg(blk, &alg_box); let buf_handle = blk.call( I64, "js_crypto_argon2_sync", @@ -40,7 +40,7 @@ pub(crate) fn arm_crypto_argon2( let params_box = lower_expr(ctx, &args[1])?; let cb_box = lower_expr(ctx, &args[2])?; let blk = ctx.block(); - let alg_handle = unbox_to_i64(blk, &alg_box); + let alg_handle = unbox_ffi_str_arg(blk, &alg_box); Ok(blk.call( DOUBLE, "js_crypto_argon2_async", @@ -63,10 +63,10 @@ pub(crate) fn arm_crypto_hkdf_sync_alg( let info_box = lower_expr(ctx, &args[3])?; let len_box = lower_expr(ctx, &args[4])?; let blk = ctx.block(); - let alg_handle = unbox_to_i64(blk, &alg_box); - let ikm_handle = unbox_to_i64(blk, &ikm_box); - let salt_handle = unbox_to_i64(blk, &salt_box); - let info_handle = unbox_to_i64(blk, &info_box); + let alg_handle = unbox_ffi_str_arg(blk, &alg_box); + let ikm_handle = unbox_ffi_str_arg(blk, &ikm_box); + let salt_handle = unbox_ffi_str_arg(blk, &salt_box); + let info_handle = unbox_ffi_str_arg(blk, &info_box); let buf_handle = blk.call( I64, "js_crypto_hkdf_bytes_alg", @@ -97,10 +97,10 @@ pub(crate) fn arm_crypto_hkdf_async_alg( let len_box = lower_expr(ctx, &args[4])?; let cb_box = lower_expr(ctx, &args[5])?; let blk = ctx.block(); - let alg_handle = unbox_to_i64(blk, &alg_box); - let ikm_handle = unbox_to_i64(blk, &ikm_box); - let salt_handle = unbox_to_i64(blk, &salt_box); - let info_handle = unbox_to_i64(blk, &info_box); + let alg_handle = unbox_ffi_str_arg(blk, &alg_box); + let ikm_handle = unbox_ffi_str_arg(blk, &ikm_box); + let salt_handle = unbox_ffi_str_arg(blk, &salt_box); + let info_handle = unbox_ffi_str_arg(blk, &info_box); Ok(blk.call( DOUBLE, "js_crypto_hkdf_async_alg", @@ -137,8 +137,8 @@ pub(crate) fn arm_crypto_scrypt( }; let cb_box = lower_expr(ctx, cb_expr)?; let blk = ctx.block(); - let pwd_handle = unbox_to_i64(blk, &pwd_box); - let salt_handle = unbox_to_i64(blk, &salt_box); + let pwd_handle = unbox_ffi_str_arg(blk, &pwd_box); + let salt_handle = unbox_ffi_str_arg(blk, &salt_box); Ok(blk.call( DOUBLE, "js_crypto_scrypt_async", @@ -180,10 +180,10 @@ pub(crate) fn arm_crypto_pbkdf2_sync( emit_validate_string_arg(ctx, db, "digest"); } let blk = ctx.block(); - let pwd_handle = unbox_to_i64(blk, &pwd_box); - let salt_handle = unbox_to_i64(blk, &salt_box); + let pwd_handle = unbox_ffi_str_arg(blk, &pwd_box); + let salt_handle = unbox_ffi_str_arg(blk, &salt_box); let digest_handle = match &digest_box { - Some(b) => unbox_to_i64(blk, b), + Some(b) => unbox_ffi_str_arg(blk, b), None => "0".to_string(), }; let buf_handle = blk.call( @@ -216,9 +216,9 @@ pub(crate) fn arm_crypto_pbkdf2_async( let alg_box = lower_expr(ctx, &args[4])?; let cb_box = lower_expr(ctx, &args[5])?; let blk = ctx.block(); - let pwd_handle = unbox_to_i64(blk, &pwd_box); - let salt_handle = unbox_to_i64(blk, &salt_box); - let alg_handle = unbox_to_i64(blk, &alg_box); + let pwd_handle = unbox_ffi_str_arg(blk, &pwd_box); + let salt_handle = unbox_ffi_str_arg(blk, &salt_box); + let alg_handle = unbox_ffi_str_arg(blk, &alg_box); Ok(blk.call( DOUBLE, "js_crypto_pbkdf2_async_alg", @@ -253,8 +253,8 @@ pub(crate) fn arm_crypto_scrypt_sync( // #2013/#3146: node validates keylen as an integer in [0, 2^31-1]. emit_validate_integer_arg(ctx, &keylen_box, "keylen", 0.0, i32::MAX as f64); let blk = ctx.block(); - let pwd_handle = unbox_to_i64(blk, &pwd_box); - let salt_handle = unbox_to_i64(blk, &salt_box); + let pwd_handle = unbox_ffi_str_arg(blk, &pwd_box); + let salt_handle = unbox_ffi_str_arg(blk, &salt_box); let buf_handle = blk.call( I64, "js_crypto_scrypt_bytes", @@ -283,10 +283,10 @@ pub(crate) fn arm_crypto_hkdf_sync( let info_box = lower_expr(ctx, &args[3])?; let keylen_box = lower_expr(ctx, &args[4])?; let blk = ctx.block(); - let digest_handle = unbox_to_i64(blk, &digest_box); - let ikm_handle = unbox_to_i64(blk, &ikm_box); - let salt_handle = unbox_to_i64(blk, &salt_box); - let info_handle = unbox_to_i64(blk, &info_box); + let digest_handle = unbox_ffi_str_arg(blk, &digest_box); + let ikm_handle = unbox_ffi_str_arg(blk, &ikm_box); + let salt_handle = unbox_ffi_str_arg(blk, &salt_box); + let info_handle = unbox_ffi_str_arg(blk, &info_box); let buf_handle = blk.call( I64, "js_crypto_hkdf_sync", diff --git a/crates/perry-codegen/src/expr/calls/crypto_keys.rs b/crates/perry-codegen/src/expr/calls/crypto_keys.rs index cc0c22187e..29ffa16337 100644 --- a/crates/perry-codegen/src/expr/calls/crypto_keys.rs +++ b/crates/perry-codegen/src/expr/calls/crypto_keys.rs @@ -24,7 +24,7 @@ pub(crate) fn arm_crypto_create_sign_verify_legacy( }; let alg_box = lower_expr(ctx, &args[0])?; let blk = ctx.block(); - let alg_handle = unbox_to_i64(blk, &alg_box); + let alg_handle = unbox_ffi_str_arg(blk, &alg_box); let fname = if property == "createSign" || property == "Sign" { "js_crypto_create_sign" } else { @@ -44,7 +44,7 @@ pub(crate) fn arm_crypto_create_ecdh( } let curve_box = lower_expr(ctx, &args[0])?; let blk = ctx.block(); - let curve_handle = unbox_to_i64(blk, &curve_box); + let curve_handle = unbox_ffi_str_arg(blk, &curve_box); Ok(blk.call(DOUBLE, "js_crypto_create_ecdh", &[(I64, &curve_handle)])) } @@ -129,7 +129,7 @@ pub(crate) fn arm_crypto_generate_key_pair_async( let options = lower_expr(ctx, &args[1])?; let callback = lower_expr(ctx, &args[2])?; let blk = ctx.block(); - let alg_handle = unbox_to_i64(blk, &alg_box); + let alg_handle = unbox_ffi_str_arg(blk, &alg_box); Ok(blk.call( DOUBLE, "js_crypto_generate_key_pair_async", diff --git a/crates/perry-codegen/src/expr/calls/crypto_misc.rs b/crates/perry-codegen/src/expr/calls/crypto_misc.rs index adcb45142b..843e049583 100644 --- a/crates/perry-codegen/src/expr/calls/crypto_misc.rs +++ b/crates/perry-codegen/src/expr/calls/crypto_misc.rs @@ -27,8 +27,8 @@ pub(crate) fn arm_crypto_create_hmac( emit_validate_string_arg(ctx, &alg_box, "hmac"); emit_validate_crypto_key_arg(ctx, &key_box, "key"); let blk = ctx.block(); - let alg_handle = unbox_to_i64(blk, &alg_box); - let key_handle = unbox_to_i64(blk, &key_box); + let alg_handle = unbox_ffi_str_arg(blk, &alg_box); + let key_handle = unbox_ffi_str_arg(blk, &key_box); Ok(blk.call( DOUBLE, "js_crypto_create_hmac", @@ -59,9 +59,9 @@ pub(crate) fn arm_crypto_create_cipheriv( double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) }; let blk = ctx.block(); - let alg_handle = unbox_to_i64(blk, &alg_box); - let key_handle = unbox_to_i64(blk, &key_box); - let iv_handle = unbox_to_i64(blk, &iv_box); + let alg_handle = unbox_ffi_str_arg(blk, &alg_box); + let key_handle = unbox_ffi_str_arg(blk, &key_box); + let iv_handle = unbox_ffi_str_arg(blk, &iv_box); let fname = if property == "createCipheriv" { "js_crypto_create_cipheriv" } else { @@ -144,7 +144,7 @@ pub(crate) fn arm_crypto_create_sign_verify( } let alg_box = lower_expr(ctx, &args[0])?; let blk = ctx.block(); - let alg_handle = unbox_to_i64(blk, &alg_box); + let alg_handle = unbox_ffi_str_arg(blk, &alg_box); let fname = if property == "createSign" { "js_crypto_create_sign" } else { @@ -413,8 +413,8 @@ pub(crate) fn arm_crypto_sign( None }; let blk = ctx.block(); - let alg_handle = unbox_to_i64(blk, &alg_box); - let data_handle = unbox_to_i64(blk, &data_box); + let alg_handle = unbox_ffi_str_arg(blk, &alg_box); + let data_handle = unbox_ffi_str_arg(blk, &data_box); if let Some(callback_box) = callback_box { return Ok(blk.call( DOUBLE, @@ -454,9 +454,9 @@ pub(crate) fn arm_crypto_verify( None }; let blk = ctx.block(); - let alg_handle = unbox_to_i64(blk, &alg_box); - let data_handle = unbox_to_i64(blk, &data_box); - let sig_handle = unbox_to_i64(blk, &sig_box); + let alg_handle = unbox_ffi_str_arg(blk, &alg_box); + let data_handle = unbox_ffi_str_arg(blk, &data_box); + let sig_handle = unbox_ffi_str_arg(blk, &sig_box); if let Some(callback_box) = callback_box { return Ok(blk.call( DOUBLE, @@ -505,7 +505,7 @@ pub(crate) fn arm_crypto_public_private_crypt( _ => unreachable!(), }; let key_handle = blk.call(I64, key_converter, &[(DOUBLE, &key_box)]); - let data_handle = unbox_to_i64(blk, &data_box); + let data_handle = unbox_ffi_str_arg(blk, &data_box); let fname = match property { "publicEncrypt" => "js_crypto_public_encrypt", "privateDecrypt" => "js_crypto_private_decrypt", @@ -533,9 +533,9 @@ pub(crate) fn arm_crypto_create_secret_key( None }; let blk = ctx.block(); - let key_handle = unbox_to_i64(blk, &key_box); + let key_handle = unbox_ffi_str_arg(blk, &key_box); let enc_handle = if let Some(enc) = enc_box { - unbox_to_i64(blk, &enc) + unbox_ffi_str_arg(blk, &enc) } else { "0".to_string() }; @@ -559,7 +559,7 @@ pub(crate) fn arm_crypto_generate_key_sync( let alg_box = lower_expr(ctx, &args[0])?; let options_box = lower_expr(ctx, &args[1])?; let blk = ctx.block(); - let alg_handle = unbox_to_i64(blk, &alg_box); + let alg_handle = unbox_ffi_str_arg(blk, &alg_box); let buf_handle = blk.call( I64, "js_crypto_generate_key_sync", @@ -581,7 +581,7 @@ pub(crate) fn arm_crypto_generate_key_async( let options_box = lower_expr(ctx, &args[1])?; let cb_box = lower_expr(ctx, &args[2])?; let blk = ctx.block(); - let alg_handle = unbox_to_i64(blk, &alg_box); + let alg_handle = unbox_ffi_str_arg(blk, &alg_box); Ok(blk.call( DOUBLE, "js_crypto_generate_key_async", @@ -609,9 +609,9 @@ pub(crate) fn arm_crypto_generate_key_pair_sync( None }; let blk = ctx.block(); - let type_handle = unbox_to_i64(blk, &type_box); + let type_handle = unbox_ffi_str_arg(blk, &type_box); let opts_handle = match &opts_box { - Some(b) => unbox_to_i64(blk, b), + Some(b) => unbox_ffi_str_arg(blk, b), None => "0".to_string(), }; // Returns an already-NaN-boxed object (POINTER_TAG). diff --git a/crates/perry-codegen/src/expr/helpers.rs b/crates/perry-codegen/src/expr/helpers.rs index 4596d15aaa..3b8d293904 100644 --- a/crates/perry-codegen/src/expr/helpers.rs +++ b/crates/perry-codegen/src/expr/helpers.rs @@ -499,6 +499,14 @@ pub(crate) fn unbox_to_i64(blk: &mut LlBlock, boxed: &str) -> String { blk.and(I64, &bits, POINTER_MASK_I64) } +/// `unbox_to_i64` for an argument a native entry reads as a +/// `*const StringHeader` (#11430): an SSO string is copied into a scratch +/// header by `js_ffi_arg_ptr` instead of being masked into a garbage address. +/// Every other value unboxes exactly as `unbox_to_i64` does. +pub(crate) fn unbox_ffi_str_arg(blk: &mut LlBlock, boxed: &str) -> String { + blk.call(I64, "js_ffi_arg_ptr", &[(DOUBLE, boxed)]) +} + /// Built-in constructor / namespace names that the runtime pre-populates /// on the globalThis singleton (`populate_global_this_builtins` in /// crates/perry-runtime/src/object.rs). Used by codegen to decide whether diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 681c0527f7..b2f100ce0d 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -97,7 +97,7 @@ pub(crate) use helpers::{ expr_produces_fresh_heap_allocation, expr_produces_non_pointer_bits_by_construction, is_global_this_builtin_function_name, is_global_this_builtin_name, lower_expr_with_expected_type, lower_js_args_array, store_needs_string_addref, - unbox_str_handle, unbox_to_i64, + unbox_ffi_str_arg, unbox_str_handle, unbox_to_i64, }; pub(crate) use i32_fast_path::{ can_lower_expr_as_i32, can_lower_expr_as_i32_in_current_region, diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index 16aa620a3b..84a2a077a4 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -1437,6 +1437,7 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { // RegExp.escape(str) — #2899. Takes/returns NaN-boxed f64 (string). module.declare_function("js_regexp_escape", DOUBLE, &[DOUBLE]); module.declare_function("js_get_string_pointer_unified", I64, &[DOUBLE]); + module.declare_function("js_ffi_arg_ptr", I64, &[DOUBLE]); // Strict-equality (`===`) compare for switch case dispatch. module.declare_function("js_switch_strict_equals", I32, &[DOUBLE, DOUBLE]); module.declare_function("js_value_to_str_ptr_for_ffi", I64, &[DOUBLE]); diff --git a/crates/perry-runtime/src/value/mod.rs b/crates/perry-runtime/src/value/mod.rs index f43895e4b6..c2e3312742 100644 --- a/crates/perry-runtime/src/value/mod.rs +++ b/crates/perry-runtime/src/value/mod.rs @@ -110,7 +110,7 @@ pub use handle::{ // ----- Basic NaN-box pack / unpack FFI ----- pub(crate) use nanbox::nanbox_string_key; pub use nanbox::{ - js_checkpoint, js_debug_val, js_get_string_pointer_unified, js_nanbox_bigint, + js_checkpoint, js_debug_val, js_ffi_arg_ptr, js_get_string_pointer_unified, js_nanbox_bigint, js_nanbox_get_bigint, js_nanbox_get_pointer, js_nanbox_get_string_pointer, js_nanbox_is_bigint, js_nanbox_is_pointer, js_nanbox_is_string, js_nanbox_pointer, js_nanbox_string, }; diff --git a/crates/perry-runtime/src/value/nanbox.rs b/crates/perry-runtime/src/value/nanbox.rs index 3b9c0761ba..19019913af 100644 --- a/crates/perry-runtime/src/value/nanbox.rs +++ b/crates/perry-runtime/src/value/nanbox.rs @@ -389,3 +389,71 @@ pub extern "C" fn js_nanbox_is_string(value: f64) -> i32 { 0 } } + +/// Scratch copies of SSO string arguments for native entry points that take a +/// `*const StringHeader` as an `i64` (#11430). +/// +/// An SSO value keeps its characters inline in the NaN-box, so the usual +/// `bits & POINTER_MASK` unboxing turns it into a garbage address. Those +/// entries (crypto: `createHmac(alg, key)`, `pbkdf2Sync(password, salt, …)`, +/// `createCipheriv(alg, key, iv)`, …) only read the argument's bytes for the +/// duration of the call, so the characters are copied into a small per-thread +/// ring of NON-GC `StringHeader`-shaped slots rather than materialized onto the +/// GC heap: a heap copy would be an unrooted temporary that the next argument's +/// materialization could move or free before the call runs. +const FFI_SSO_SLOTS: usize = 16; + +#[repr(C)] +struct FfiSsoSlot { + header: crate::string::StringHeader, + bytes: [u8; crate::value::SHORT_STRING_MAX_LEN], +} + +crate::perry_thread_local! { + static FFI_SSO_RING: std::cell::UnsafeCell<(usize, Box<[FfiSsoSlot]>)> = std::cell::UnsafeCell::new(( + 0, + (0..FFI_SSO_SLOTS) + .map(|_| FfiSsoSlot { + header: crate::string::StringHeader { + utf16_len: 0, + byte_len: 0, + capacity: 0, + refcount: 0, + flags: 0, + }, + bytes: [0; crate::value::SHORT_STRING_MAX_LEN], + }) + .collect::>() + .into_boxed_slice(), + )); +} + +/// Unbox `value` to the raw pointer a native entry expects, copying an SSO +/// string into a scratch `StringHeader` first (see [`FFI_SSO_SLOTS`]). Every +/// other value unboxes exactly as `bits & POINTER_MASK` does. +#[no_mangle] +pub extern "C" fn js_ffi_arg_ptr(value: f64) -> i64 { + let jsval = JSValue::from_bits(value.to_bits()); + if !jsval.is_short_string() { + return (value.to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64; + } + let mut buf = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let n = jsval.short_string_to_buf(&mut buf); + FFI_SSO_RING.with(|cell| { + // SAFETY: thread-local, and no reference escapes this closure; the + // slot's address is handed out as an integer. + let (next, slots) = unsafe { &mut *cell.get() }; + let slot = &mut slots[*next]; + *next = (*next + 1) % FFI_SSO_SLOTS; + slot.bytes[..n].copy_from_slice(&buf[..n]); + // SSO holds ASCII only, so UTF-16 length equals byte length. + slot.header = crate::string::StringHeader { + utf16_len: n as u32, + byte_len: n as u32, + capacity: n as u32, + refcount: 0, + flags: 0, + }; + &slot.header as *const crate::string::StringHeader as i64 + }) +} diff --git a/crates/perry-stdlib/src/crypto/x509.rs b/crates/perry-stdlib/src/crypto/x509.rs index 4ab1b386e4..d735dd3016 100644 --- a/crates/perry-stdlib/src/crypto/x509.rs +++ b/crates/perry-stdlib/src/crypto/x509.rs @@ -1266,11 +1266,9 @@ pub(super) fn nanbox_ptr(ptr: *mut T) -> f64 { pub(super) fn arg_ptr(arg: f64) -> i64 { // #11430: an SSO short string (`String(7)`, `` `${n}` ``) carries its // characters inline in the NaN-box — masking it yields a garbage address - // that `bytes_from_ptr` then dereferences. Materialize it on the heap. - if perry_runtime::JSValue::from_bits(arg.to_bits()).is_short_string() { - return perry_runtime::js_get_string_pointer_unified(arg); - } - (arg.to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64 + // that `bytes_from_ptr` then dereferences. `js_ffi_arg_ptr` copies it into + // a non-GC scratch header and masks every other value as before. + perry_runtime::value::js_ffi_arg_ptr(arg) } pub(super) unsafe fn arg_bytes(args: &[f64], idx: usize) -> Vec { diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 8ec6f4514b..d5da890e3f 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -39,6 +39,12 @@ "verdict": "not_a_gc_pointer", "why": "Per-thread cache of five diagnostics_channel ids (i64 keys into the thread-local DIAG_CHANNELS table, minted by NEXT_DIAG_ID) for console.log/info/debug/error/warn; integers, never a NaN-boxed value. #11471 made these per-thread." }, + { + "file": "crates/perry-runtime/src/value/nanbox.rs", + "name": "FFI_SSO_RING", + "verdict": "not_a_gc_pointer", + "why": "#11430: a per-thread ring of NON-GC StringHeader-shaped slots that js_ffi_arg_ptr copies an SSO argument's inline bytes into for the duration of one native call. The slots are owned Box memory, never GC-allocated, and hold only a length header and the copied ASCII bytes \u2014 no pointer into the GC heap." + }, { "file": "crates/perry-ext-http/src/lib.rs", "name": "HTTP_PENDING_EVENTS", diff --git a/test-files/test_gap_11430_crypto_short_string_args.ts b/test-files/test_gap_11430_crypto_short_string_args.ts new file mode 100644 index 0000000000..a88dcae744 --- /dev/null +++ b/test-files/test_gap_11430_crypto_short_string_args.ts @@ -0,0 +1,23 @@ +// #11430: crypto entry points must accept a SHORT (SSO) string argument. +// Since #10762 `String(n)` / `${n}` / `n.toString()` return small values as +// SSO strings, whose characters live inline in the NaN-box. The crypto call +// sites unboxed every string argument with `bits & POINTER_MASK`, turning +// those inline characters into an address that `bytes_from_ptr` dereferenced: +// `hash.update(String(7))`, an HMAC key, a pbkdf2 password or a cipher's +// update input written this way segfaulted. +import { createHash, createHmac, pbkdf2Sync, createCipheriv, createDecipheriv, hkdfSync } from "node:crypto"; + +console.log("sha256:", createHash("sha256").update(String(7)).digest("hex")); +console.log("sha256 enc:", createHash("sha256").update(String(7), "utf8").digest("hex")); +console.log("md5 tpl:", createHash("md5").update(`${42}`).digest("hex")); +console.log("chained:", createHash("sha1").update(String(1)).update((2).toString()).digest("base64")); +console.log("hmac sso key:", createHmac("sha256", String(1)).update(String(123)).digest("hex")); +console.log("hmac heap key:", createHmac("sha256", "key").update(String(123)).digest("hex")); +console.log("pbkdf2:", pbkdf2Sync(String(9), String(8), 10, 16, "sha256").toString("hex")); +console.log("hkdf:", Buffer.from(hkdfSync("sha256", String(1), String(2), String(3), 16)).toString("hex")); +const key = Buffer.alloc(32, 1); +const iv = Buffer.alloc(16, 2); +const c = createCipheriv("aes-256-cbc", key, iv); +const enc = Buffer.concat([c.update(String(5)), c.final()]); +const d = createDecipheriv("aes-256-cbc", key, iv); +console.log("aes:", enc.toString("hex"), Buffer.concat([d.update(enc), d.final()]).toString()); From 19d22966d245c830d1366b454124ca39211a0777 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 07:47:42 +0000 Subject: [PATCH 3/3] changelog: #11486 --- changelog.d/11486-crypto-sso-args.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/11486-crypto-sso-args.md diff --git a/changelog.d/11486-crypto-sso-args.md b/changelog.d/11486-crypto-sso-args.md new file mode 100644 index 0000000000..087ed6b356 --- /dev/null +++ b/changelog.d/11486-crypto-sso-args.md @@ -0,0 +1 @@ +**fix(codegen,runtime): crypto natives accept a short (SSO) string argument, so `hash.update(String(n))` no longer segfaults (#11430 follow-up).** The crypto call sites unboxed every string argument with `bits & POINTER_MASK`: 48 codegen arms, plus `arg_ptr` and the open-coded masks in perry-stdlib. An SSO string's inline characters therefore became an address that `bytes_from_ptr` dereferenced. `hash.update(String(7))`, an HMAC key, `pbkdf2Sync` / `hkdfSync` inputs and a cipher's `update` input all crashed. The new `js_ffi_arg_ptr` copies an SSO argument into a per-thread ring of non-GC `StringHeader` slots and leaves every other value's unboxing unchanged. The new gap test is `test_gap_11430_crypto_short_string_args`.