From b230718e481d946df82cffcb3df4a163f34934de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 30 Aug 2026 20:20:00 +0000 Subject: [PATCH 1/2] fix(buffer): distinguish Uint8Array in isBuffer --- .../perry-codegen/src/expr/array_methods.rs | 7 +- .../runtime_decls/stdlib_ffi/third_party.rs | 1 + crates/perry-runtime/src/buffer/mod.rs | 4 +- crates/perry-runtime/src/buffer/query.rs | 38 +++++++++- .../native_module_dispatch/dispatch_a_c.rs | 6 +- .../issue_9179_buffer_isbuffer_uint8array.rs | 69 +++++++++++++++++++ 6 files changed, 116 insertions(+), 9 deletions(-) create mode 100644 crates/perry/tests/issue_9179_buffer_isbuffer_uint8array.rs diff --git a/crates/perry-codegen/src/expr/array_methods.rs b/crates/perry-codegen/src/expr/array_methods.rs index 9f13ebcdf9..ffe0925356 100644 --- a/crates/perry-codegen/src/expr/array_methods.rs +++ b/crates/perry-codegen/src/expr/array_methods.rs @@ -269,13 +269,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // -------- BufferIsBuffer -------- // `Buffer.isBuffer(x)`. Runtime returns i32 (0/1); wrap as NaN-boxed - // boolean. `js_buffer_is_buffer` already strips NaN-box tags and - // checks the BUFFER_REGISTRY, so any value type is safe to pass. + // boolean. `js_buffer_is_node_buffer` strips NaN-box tags, checks the + // shared BUFFER_REGISTRY, and excludes constructor-created Uint8Arrays, + // so any value type is safe to pass. Expr::BufferIsBuffer(operand) => { let v_box = lower_expr(ctx, operand)?; let blk = ctx.block(); let v_handle = unbox_to_i64(blk, &v_box); - let i32_result = blk.call(I32, "js_buffer_is_buffer", &[(I64, &v_handle)]); + let i32_result = blk.call(I32, "js_buffer_is_node_buffer", &[(I64, &v_handle)]); Ok(i32_bool_to_nanbox(blk, &i32_result)) } diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs index 4609ade92a..471f8c5726 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs @@ -254,6 +254,7 @@ pub(crate) fn declare_third_party(module: &mut LlModule) { module.declare_function("js_buffer_from_value", I64, &[I64, I32]); module.declare_function("js_buffer_is_ascii", DOUBLE, &[DOUBLE]); module.declare_function("js_buffer_is_buffer", I32, &[I64]); + module.declare_function("js_buffer_is_node_buffer", I32, &[I64]); module.declare_function("js_buffer_is_encoding", I32, &[DOUBLE]); module.declare_function("js_buffer_is_utf8", DOUBLE, &[DOUBLE]); module.declare_function("js_buffer_print", VOID, &[I64]); diff --git a/crates/perry-runtime/src/buffer/mod.rs b/crates/perry-runtime/src/buffer/mod.rs index b548871aac..9fc7b29727 100644 --- a/crates/perry-runtime/src/buffer/mod.rs +++ b/crates/perry-runtime/src/buffer/mod.rs @@ -99,8 +99,8 @@ pub use from::{ // ---- Re-exports: predicates / byteLength (FFI) ---- pub use query::{ js_buffer_byte_length, js_buffer_byte_length_value, js_buffer_is_ascii, js_buffer_is_buffer, - js_buffer_is_encoding, js_buffer_is_utf8, js_native_buffer_byte_len, js_native_buffer_data_ptr, - js_value_buffer_or_typedarray_data, + js_buffer_is_encoding, js_buffer_is_node_buffer, js_buffer_is_utf8, js_native_buffer_byte_len, + js_native_buffer_data_ptr, js_value_buffer_or_typedarray_data, }; // ---- Re-exports: toString / print / length / to-array ---- diff --git a/crates/perry-runtime/src/buffer/query.rs b/crates/perry-runtime/src/buffer/query.rs index 23aa567358..eef4cbd4ed 100644 --- a/crates/perry-runtime/src/buffer/query.rs +++ b/crates/perry-runtime/src/buffer/query.rs @@ -67,7 +67,7 @@ pub unsafe extern "C" fn js_value_buffer_or_typedarray_data( static KEEP_JS_VALUE_BUFFER_OR_TYPEDARRAY_DATA: unsafe extern "C" fn(f64, *mut u32) -> *const u8 = js_value_buffer_or_typedarray_data; -/// Check if an object is a Buffer (using the buffer registry) +/// Check if an object uses the shared Buffer/Uint8Array representation. #[no_mangle] pub extern "C" fn js_buffer_is_buffer(ptr: i64) -> i32 { if ptr == 0 || (ptr as u64) < 0x1000 { @@ -86,6 +86,42 @@ pub extern "C" fn js_buffer_is_buffer(ptr: i64) -> i32 { } } +/// Check if an object has the Node `Buffer` brand. +/// +/// Perry intentionally backs both `Buffer` and constructor-created +/// `Uint8Array` values with `BufferHeader` and registers both addresses in +/// `BUFFER_REGISTRY`. Keep [`js_buffer_is_buffer`] as the broader storage +/// predicate used by native APIs that accept either representation, and use +/// this discriminator for the public `Buffer.isBuffer()` identity check. +#[no_mangle] +pub extern "C" fn js_buffer_is_node_buffer(ptr: i64) -> i32 { + if js_buffer_is_buffer(ptr) == 0 { + return 0; + } + let addr = if ((ptr as u64) >> 48) != 0 { + (ptr as u64) & 0x0000_FFFF_FFFF_FFFF + } else { + ptr as u64 + } as usize; + (!is_uint8array_buffer(addr)) as i32 +} + +#[cfg(test)] +mod buffer_brand_tests { + use super::*; + + #[test] + fn node_buffer_brand_excludes_constructor_uint8arrays() { + let buffer = buffer_alloc(4); + let uint8array = js_uint8array_alloc(4); + + assert_eq!(js_buffer_is_buffer(buffer as i64), 1); + assert_eq!(js_buffer_is_node_buffer(buffer as i64), 1); + assert_eq!(js_buffer_is_buffer(uint8array as i64), 1); + assert_eq!(js_buffer_is_node_buffer(uint8array as i64), 0); + } +} + /// Check if a value is a Node Buffer encoding name. #[no_mangle] pub extern "C" fn js_buffer_is_encoding(value: f64) -> i32 { diff --git a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs index 7eaee3cd6a..3eaeed3cb3 100644 --- a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs +++ b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs @@ -378,9 +378,9 @@ pub(crate) unsafe fn nm_dispatch_buffer(ctx: &NmCtx, module_name: &str, method_n let arr = pack_args(); ptr_to_f64(crate::buffer::js_buffer_from_array(arr) as *const u8) } - ("buffer.Buffer", "isBuffer") => { - bool_to_f64(crate::buffer::js_buffer_is_buffer(arg(0).to_bits() as i64)) - } + ("buffer.Buffer", "isBuffer") => bool_to_f64(crate::buffer::js_buffer_is_node_buffer( + arg(0).to_bits() as i64, + )), ("buffer.Buffer", "isEncoding") => { bool_to_f64(crate::buffer::js_buffer_is_encoding(arg(0))) } diff --git a/crates/perry/tests/issue_9179_buffer_isbuffer_uint8array.rs b/crates/perry/tests/issue_9179_buffer_isbuffer_uint8array.rs new file mode 100644 index 0000000000..0c9b3d4e3f --- /dev/null +++ b/crates/perry/tests/issue_9179_buffer_isbuffer_uint8array.rs @@ -0,0 +1,69 @@ +//! Regression coverage for #9179: `Buffer.isBuffer()` is a brand check, not a +//! test for Perry's shared Buffer/Uint8Array storage representation. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +#[test] +fn buffer_isbuffer_rejects_plain_uint8arrays_in_every_call_path() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let output = dir.path().join("main_bin"); + std::fs::write( + &entry, + r#" +const buffer = Buffer.alloc(4); +const uint8array = new Uint8Array(4); +const plain = {}; + +console.log( + Buffer.isBuffer(buffer), + Buffer.isBuffer(uint8array), + Buffer.isBuffer(plain), +); + +const predicate = Buffer.isBuffer; +console.log( + predicate(buffer), + predicate(uint8array), + predicate(plain), +); +"#, + ) + .expect("write fixture"); + + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output) + .output() + .expect("run compiled fixture"); + assert!( + run.status.success(), + "compiled fixture failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&run.stdout), + "true false false\ntrue false false\n" + ); +} From ce754a2e57a4cd66dfda42505bdd0d6007b8663b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 30 Aug 2026 20:20:56 +0000 Subject: [PATCH 2/2] docs(changelog): note Buffer brand fix --- changelog.d/9205-buffer-isbuffer-uint8array-brand.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 changelog.d/9205-buffer-isbuffer-uint8array-brand.md diff --git a/changelog.d/9205-buffer-isbuffer-uint8array-brand.md b/changelog.d/9205-buffer-isbuffer-uint8array-brand.md new file mode 100644 index 0000000000..6433589859 --- /dev/null +++ b/changelog.d/9205-buffer-isbuffer-uint8array-brand.md @@ -0,0 +1,9 @@ +`Buffer.isBuffer()` now returns `false` for a plain `Uint8Array`, matching +Node. Perry stores both values in the same `BufferHeader` layout and buffer +registry, so registry membership alone could not distinguish their brands. + +The public predicate now also consults the existing constructor-created +`Uint8Array` discriminator. Native APIs keep using the broader storage +predicate where Node accepts both `Buffer` and `Uint8Array` inputs. Regression +coverage exercises direct calls and `Buffer.isBuffer` used as a first-class +function.