From 0a5a2dcf9b59d4b394bd34d6d73ff74e722d5ceb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 16 Jul 2026 10:04:03 +0200 Subject: [PATCH] fix(runtime): unknown method on a Buffer receiver must throw, not return undefined MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dispatch_buffer_method's catch-all returned undefined for any method neither the Buffer API nor the delegated %TypedArray%.prototype tower implements. Node throws: `buf.charCodeAt(0)` is `TypeError: buf.charCodeAt is not a function`. The silent fallback has real teeth since #1420 made readFileSync return a Buffer when called without an encoding (Node parity): a caller still treating the result as a string gets undefined from every string-method call and keeps running on garbage instead of failing at the call site. Real-world case: an editor's NUL-byte binary-file scan (content.charCodeAt(i) === 0) misclassified every text file it opened — no crash, no error, just an app that quietly stopped displaying files. Fix: after Buffer-API arms and typed-array delegation both miss, route through js_throw_type_error_not_a_function — the same thrower the string and number primitive catch-alls already use, so the message shape ((Buffer).charCodeAt is not a function) and catchability match those paths. Internal __perry_* duck-type probes (using-disposal) keep the non-throwing undefined. Callers are unaffected: both dynamic-dispatch entry points (handle_methods, collection_methods) return Some(...) unconditionally, so nothing relied on the undefined to fall through to another dispatcher; the named-method callers (set/export/slice) never reach the catch-all. Test: test_gap_buffer_unknown_method_throws.ts — byte-identical to the Node oracle locally; buffer sweep (prototype_methods, numeric_read_intrinsic, small_alloc, edge_from_encoding) unchanged. compat_buffers_typed's toSorted/toReversed diff is pre-existing and identical before/after. --- .../src/object/buffer_dispatch.rs | 23 +++++++++- .../test_gap_buffer_unknown_method_throws.ts | 45 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 test-files/test_gap_buffer_unknown_method_throws.ts diff --git a/crates/perry-runtime/src/object/buffer_dispatch.rs b/crates/perry-runtime/src/object/buffer_dispatch.rs index fba54122af..cda5b577ab 100644 --- a/crates/perry-runtime/src/object/buffer_dispatch.rs +++ b/crates/perry-runtime/src/object/buffer_dispatch.rs @@ -1100,7 +1100,7 @@ pub unsafe fn dispatch_buffer_method( // non-callable callback) nor iterating. Delegate any method the Buffer // API doesn't claim to the shared uint8 typed-array dispatcher (it // validates callbacks and reads the typed store); it returns `None` for - // names it doesn't implement, preserving the `undefined` fallback. + // names it doesn't implement. _ => { if super::typed_array_proto_thunks::is_typed_array_buffer(addr) { if let Some(r) = super::typed_array_proto_thunks::dispatch_uint8_buffer_method( @@ -1111,7 +1111,26 @@ pub unsafe fn dispatch_buffer_method( return r; } } - f64::from_bits(crate::value::TAG_UNDEFINED) + // Internal perry hooks (`using` disposal probes and friends) may + // reach any receiver; they are duck-typed and must stay non-throwing. + if method_name.starts_with("__perry_") { + return f64::from_bits(crate::value::TAG_UNDEFINED); + } + // A method neither the Buffer API nor %TypedArray%.prototype + // implements must throw like Node (`buf.charCodeAt is not a + // function`), not silently return undefined. The silent fallback + // turned a readFileSync-now-returns-Buffer migration into invisible + // data corruption downstream: `content.charCodeAt(i)` yielded + // undefined in every comparison, so e.g. a NUL-byte binary-file + // scan misclassified every text file while the program kept + // running as if the calls had worked. Same shape as the string / + // number primitive catch-alls, which already route here. + crate::error::js_throw_type_error_not_a_function( + b"Buffer".as_ptr(), + 6, + method_name.as_ptr(), + method_name.len(), + ) } } } diff --git a/test-files/test_gap_buffer_unknown_method_throws.ts b/test-files/test_gap_buffer_unknown_method_throws.ts new file mode 100644 index 0000000000..1b2cd9dc9b --- /dev/null +++ b/test-files/test_gap_buffer_unknown_method_throws.ts @@ -0,0 +1,45 @@ +// Calling a method that neither the Buffer API nor %TypedArray%.prototype +// implements must throw a TypeError like Node — not silently return undefined. +// +// Pre-fix, `dispatch_buffer_method`'s catch-all returned undefined for any +// unknown name. That silence turned the readFileSync Node-parity migration +// (no encoding → Buffer, not string) into invisible data corruption for +// callers still treating the result as a string: `content.charCodeAt(i)` +// yielded undefined in every comparison and the program kept running as if +// the calls had worked (real case: an editor's NUL-byte binary-file scan +// misclassified every text file it opened). + +const buf = Buffer.from('hello', 'utf8'); + +// The implemented Buffer API surface keeps working. +console.log('toString:', buf.toString('utf8')); +console.log('indexOf:', buf.indexOf('l')); + +// Inherited %TypedArray%.prototype methods keep working via delegation. +console.log('every:', buf.every((b) => b > 0)); + +// A String.prototype method reached through a Buffer receiver throws. +try { + (buf as any).charCodeAt(0); + console.log('charCodeAt: no throw'); +} catch (e) { + console.log('charCodeAt throws TypeError:', e instanceof TypeError); + const msg = (e as Error).message; + console.log('mentions method:', msg.includes('charCodeAt')); + console.log('mentions not-a-function:', msg.includes('is not a function')); +} + +// A method that exists nowhere throws too. +try { + (buf as any).definitelyNotAMethod(1, 2); + console.log('bogus: no throw'); +} catch (e) { + console.log('bogus throws TypeError:', e instanceof TypeError); + console.log( + 'mentions not-a-function:', + (e as Error).message.includes('is not a function') + ); +} + +// The throw is catchable and execution continues normally afterwards. +console.log('still running:', buf.readUInt8(0));