Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Plugins/PackageToJS/Templates/runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -759,7 +759,12 @@ class SwiftRuntime {
swjs_load_typed_array: (ref, buffer) => {
const memory = this.memory;
const typedArray = memory.getObject(ref);
const bytes = new Uint8Array(typedArray.buffer);
// Copy only the window the view describes. `typedArray.buffer`
// is the whole backing `ArrayBuffer`, which may be larger than
// the view and may start before it; the guest sizes the
// destination from the view's own length, so viewing the entire
// buffer would both shift the bytes and overrun the destination.
const bytes = new Uint8Array(typedArray.buffer, typedArray.byteOffset, typedArray.byteLength);
this.getUint8Array().set(bytes, buffer >>> 0);
},
swjs_release: (ref) => {
Expand Down
11 changes: 10 additions & 1 deletion Runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -763,7 +763,16 @@ export class SwiftRuntime {
swjs_load_typed_array: (ref: ref, buffer: pointer) => {
const memory = this.memory;
const typedArray = memory.getObject(ref);
const bytes = new Uint8Array(typedArray.buffer);
// Copy only the window the view describes. `typedArray.buffer`
// is the whole backing `ArrayBuffer`, which may be larger than
// the view and may start before it; the guest sizes the
// destination from the view's own length, so viewing the entire
// buffer would both shift the bytes and overrun the destination.
const bytes = new Uint8Array(
typedArray.buffer,
typedArray.byteOffset,
typedArray.byteLength,
);
this.getUint8Array().set(bytes, buffer >>> 0);
},

Expand Down
107 changes: 107 additions & 0 deletions Runtime/test/load-typed-array.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, expect, test } from "vitest";
import { SwiftRuntime } from "../src/index.js";

// `swjs_load_typed_array` must copy only the window a TypedArray view describes,
// not its whole backing `ArrayBuffer`. The guest sizes the destination from the
// view's own `length`/`byteLength`, so copying the entire buffer both shifts the
// bytes (a view with a non-zero `byteOffset` lands offset in the guest) and
// writes past the end of the destination.
const DESTINATION = 1024;

function makeRuntime(): { runtime: SwiftRuntime; memory: WebAssembly.Memory } {
const memory = new WebAssembly.Memory({ initial: 1 });
const runtime = new SwiftRuntime();
runtime.setInstance({
exports: {
memory,
swjs_library_version: () => 708,
},
} as unknown as WebAssembly.Instance);
return { runtime, memory };
}

describe("swjs_load_typed_array respects the view's window", () => {
test("copies a Uint8Array view from its byteOffset", () => {
const { runtime, memory } = makeRuntime();
const backing = new ArrayBuffer(32);
new Uint8Array(backing).set(
Array.from({ length: 32 }, (_, i) => 0xa0 + i),
);
const view = new Uint8Array(backing, 8, 8);

const space = (runtime as any).memory;
const imports = runtime.wasmImports as any;
imports.swjs_load_typed_array(space.retain(view), DESTINATION);

const guest = new Uint8Array(memory.buffer);
expect(
Array.from(guest.subarray(DESTINATION, DESTINATION + 8)),
).toEqual(Array.from(view));
});

test("does not write past the end of the view", () => {
const { runtime, memory } = makeRuntime();
const backing = new ArrayBuffer(32);
new Uint8Array(backing).fill(0xff);
const view = new Uint8Array(backing, 8, 8);

// Fill the guest memory around the destination with a sentinel so any
// byte written beyond the view's `byteLength` is visible.
const guest = new Uint8Array(memory.buffer);
guest.fill(0x5a, DESTINATION, DESTINATION + 64);

const space = (runtime as any).memory;
const imports = runtime.wasmImports as any;
imports.swjs_load_typed_array(space.retain(view), DESTINATION);

expect(
Array.from(guest.subarray(DESTINATION + 8, DESTINATION + 64)),
).toEqual(new Array(56).fill(0x5a));
});

test("copies a multi-byte element view from its byteOffset", () => {
const { runtime, memory } = makeRuntime();
const backing = new ArrayBuffer(32);
new Int32Array(backing).set([1, 2, 3, 4, 5, 6, 7, 8]);
const view = new Int32Array(backing, 8, 4);

const space = (runtime as any).memory;
const imports = runtime.wasmImports as any;
imports.swjs_load_typed_array(space.retain(view), DESTINATION);

const guest = new Int32Array(memory.buffer, DESTINATION, 4);
expect(Array.from(guest)).toEqual([3, 4, 5, 6]);
});

test("copies a DataView from its byteOffset", () => {
const { runtime, memory } = makeRuntime();
const backing = new ArrayBuffer(32);
new Uint8Array(backing).set(
Array.from({ length: 32 }, (_, i) => 0xa0 + i),
);
const view = new DataView(backing, 8, 8);

const space = (runtime as any).memory;
const imports = runtime.wasmImports as any;
imports.swjs_load_typed_array(space.retain(view), DESTINATION);

const guest = new Uint8Array(memory.buffer);
expect(
Array.from(guest.subarray(DESTINATION, DESTINATION + 8)),
).toEqual(Array.from(new Uint8Array(backing, 8, 8)));
});

test("still copies a whole-buffer view unchanged", () => {
const { runtime, memory } = makeRuntime();
const view = new Uint8Array([1, 2, 3, 4, 5]);

const space = (runtime as any).memory;
const imports = runtime.wasmImports as any;
imports.swjs_load_typed_array(space.retain(view), DESTINATION);

const guest = new Uint8Array(memory.buffer);
expect(
Array.from(guest.subarray(DESTINATION, DESTINATION + 5)),
).toEqual([1, 2, 3, 4, 5]);
});
});
78 changes: 78 additions & 0 deletions Tests/JavaScriptKitTests/JSTypedArrayTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,84 @@ final class JSTypedArrayTests: XCTestCase {
}
}

func testTypedArrayWithByteOffset() {
// A view over part of a larger `ArrayBuffer`: `byteOffset` is non-zero and
// `byteLength` is smaller than the backing buffer. Copying the whole
// buffer instead of the view's window would both shift the bytes and
// write past the end of the destination, which is sized from `length`.
let backingLength = 32
let viewOffset = 8
let viewLength = 8

let arrayBuffer = JSObject.global.ArrayBuffer.function!.new(backingLength)
let wholeBuffer = JSTypedArray<UInt8>(
unsafelyWrapping: JSObject.global.Uint8Array.function!.new(arrayBuffer)
)
for i in 0..<backingLength {
wholeBuffer[i] = UInt8(0xA0 + i)
}

let view = JSTypedArray<UInt8>(
unsafelyWrapping: JSObject.global.Uint8Array.function!.new(
arrayBuffer,
viewOffset,
viewLength
)
)
XCTAssertEqual(view.length, viewLength)
XCTAssertEqual(view.lengthInBytes, viewLength)

let expected = (0..<viewLength).map { UInt8(0xA0 + viewOffset + $0) }
XCTAssertEqual(view.withUnsafeBytes { Array($0) }, expected)

// `copyMemory(to:)` must not write beyond the destination it is given.
let sentinel: UInt8 = 0x5A
let storage = UnsafeMutableBufferPointer<UInt8>.allocate(capacity: backingLength)
defer { storage.deallocate() }
storage.initialize(repeating: sentinel)
let destination = UnsafeMutableBufferPointer(rebasing: storage[0..<viewLength])
view.copyMemory(to: destination)

XCTAssertEqual(Array(destination), expected)
for i in viewLength..<backingLength {
XCTAssertEqual(storage[i], sentinel, "copyMemory(to:) wrote past the destination at \(i)")
}
}

func testMultiByteTypedArrayWithByteOffset() {
// Same, with a multi-byte element type, so the destination is sized in
// elements while the overrun would be measured in bytes.
let elements: [Int32] = [1, 2, 3, 4, 5, 6, 7, 8]
let viewOffsetInBytes = 8
let viewLength = 4

let arrayBuffer = JSTypedArray<Int32>(elements).jsObject.buffer.object!
let view = JSTypedArray<Int32>(
unsafelyWrapping: JSObject.global.Int32Array.function!.new(
arrayBuffer,
viewOffsetInBytes,
viewLength
)
)
XCTAssertEqual(view.length, viewLength)
XCTAssertEqual(view.lengthInBytes, viewLength * MemoryLayout<Int32>.size)

let expected: [Int32] = [3, 4, 5, 6]
XCTAssertEqual(view.withUnsafeBytes { Array($0) }, expected)

let sentinel: Int32 = -559_038_737 // 0xDEADBEEF
let storage = UnsafeMutableBufferPointer<Int32>.allocate(capacity: elements.count)
defer { storage.deallocate() }
storage.initialize(repeating: sentinel)
let destination = UnsafeMutableBufferPointer(rebasing: storage[0..<viewLength])
view.copyMemory(to: destination)

XCTAssertEqual(Array(destination), expected)
for i in viewLength..<elements.count {
XCTAssertEqual(storage[i], sentinel, "copyMemory(to:) wrote past the destination at \(i)")
}
}

func testCopyMemory() {
let array = JSTypedArray<Int>(length: 100)
for i in 0..<100 {
Expand Down
Loading