wasm-bindgen icon indicating copy to clipboard operation
wasm-bindgen copied to clipboard

Type safe pointers in generated JS bindings

Open ColinTimBarndt opened this issue 1 year ago • 0 comments

Motivation

Javascript only has one (strictly two when counting BigInt) numeric types, which both represent data and pointers in generated bindings. This allows using regular numbers as arguments for a function accepting a pointer. Additionally, accidentally "casting" a pointer value to another type not supporting the size and alignment constraints is possible.

Example

Let's assume the following functions are all callable from JS (#[wasm_bindgen] omitted).

pub fn new_foo(x: i32) -> *mut Foo;
pub fn new_bar() -> *mut Bar;
pub fn compute(foo: *const Foo, bar: *const Bar);

The resulting binding types would be this:

function new_foo(x: number): number;
function new_bar(): number;
function compute(foo: number, bar: number): void;

And the following code is accepted by Typescript, even though it's not correct.

let bar = new_bar();
// 1. transmuting *mut Bar to number
let foo = new_foo(bar);
// 2. transmuting *mut Bar to *const Foo
// 3. transmuting number to *const Bar
compute(bar, 10);

Proposed Solution

I propose storing metadata on numbers using Typescript branding as follows:

declare const POINTER: unique symbol;
type Pointer<T> = number & {[POINTER]: {type: T}};
const NULL: Pointer<unknown> = 0 as Pointer<unknown>; // explicit cast is required
Updated Example

From the given example, the new Typescript function signatures would look like this:

function new_foo(x: number): Pointer<"Foo">;
function new_bar(): Pointer<"Bar">;
function compute(foo: Pointer<"Foo">, bar: Pointer<"Bar">): void;

Typescript is now giving errors in 2 out of 3 cases:

let bar = new_bar();
// 1. transmuting *mut Bar to number still possible
let foo = new_foo(bar);
compute(bar, 10); // 2 errors

The compiler errors are also very human-readable:

Argument of type 'Pointer<"Bar">' is not assignable to parameter of type 'Pointer<"Foo">'.
  Type 'Pointer<"Bar">' is not assignable to type '{ [POINTER]: { type: "Foo"; }; }'.
    The types of '[POINTER].type' are incompatible between these types.
      Type '"Bar"' is not assignable to type '"Foo"'.
Argument of type 'number' is not assignable to parameter of type 'Pointer<"Bar">'.
  Type 'number' is not assignable to type '{ [POINTER]: { type: "Bar"; }; }'.

Note that casting from pointer to number is still possible, though this is as safe as ptr as usize in Rust and won't result in undefined behavior.

In addition to just a type, it would also be possible to represent the distinction between *const T and *mut T at least to some degree:

type PointerMut<T> = number & {[POINTER]: {type: T, mut: true}};

With this addition, PointerMut<T> implicitly downcasts to Pointer<T>, but not the other way around. This can be useful to prevent the following scenario:

pub fn get_name() -> *const u8;
pub fn get_name_len() -> usize;
pub fn read(buf: *mut u8, len: usize);
function get_name(): Pointer<"u8">;
function get_name_len(): number;
function read(buf: PointerMut<"u8">, len: number): void;
let namePtr = get_name();
let nameLen = get_name_len();
read(namePtr, nameLen);
// namePtr could now contain non-UTF-8 characters if this was possible

Thanks to the typing of pointers, this results in a human-readable Typescript error:

Argument of type 'Pointer<"u8">' is not assignable to parameter of type 'PointerMut<"u8">'.
  Type 'Pointer<"u8">' is not assignable to type '{ [POINTER]: { type: "u8"; mut: true; }; }'.
    Types of property '[POINTER]' are incompatible.
      Property 'mut' is missing in type '{ type: "u8"; }' but required in type '{ type: "u8"; mut: true; }'.

Alternatives

Another approach to this problem could be to wrap each pointer in a class. This would prevent accidentally casting from a number, and also to a number (which my proposed solution doesn't prevent). However, this can result in a lot of object instantiations and pointer arithmetic may involve more overhead while my proposed solution only exists in the type system and is erased at runtime. Also, by using a unique symbol for branding, pointer types are not interchangeable between different WASM libraries (which have distinct memories).

class Pointer<T> {
  #address: number;

  get address() { return this.#number }
}

class PointerMut<T> extends Pointer<T> {}

Additional Context

image

ColinTimBarndt avatar Jun 12 '24 04:06 ColinTimBarndt