diff --git a/Plugins/PackageToJS/Templates/runtime.mjs b/Plugins/PackageToJS/Templates/runtime.mjs index b0be54bb4..6170e58d0 100644 --- a/Plugins/PackageToJS/Templates/runtime.mjs +++ b/Plugins/PackageToJS/Templates/runtime.mjs @@ -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) => { diff --git a/Runtime/src/index.ts b/Runtime/src/index.ts index 879774a7d..e05bf712e 100644 --- a/Runtime/src/index.ts +++ b/Runtime/src/index.ts @@ -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); }, diff --git a/Runtime/test/load-typed-array.test.ts b/Runtime/test/load-typed-array.test.ts new file mode 100644 index 000000000..724aa23fa --- /dev/null +++ b/Runtime/test/load-typed-array.test.ts @@ -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]); + }); +}); diff --git a/Tests/JavaScriptKitTests/JSTypedArrayTests.swift b/Tests/JavaScriptKitTests/JSTypedArrayTests.swift index a4649879e..85a4e3ba1 100644 --- a/Tests/JavaScriptKitTests/JSTypedArrayTests.swift +++ b/Tests/JavaScriptKitTests/JSTypedArrayTests.swift @@ -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( + unsafelyWrapping: JSObject.global.Uint8Array.function!.new(arrayBuffer) + ) + for i in 0..( + unsafelyWrapping: JSObject.global.Uint8Array.function!.new( + arrayBuffer, + viewOffset, + viewLength + ) + ) + XCTAssertEqual(view.length, viewLength) + XCTAssertEqual(view.lengthInBytes, viewLength) + + let expected = (0...allocate(capacity: backingLength) + defer { storage.deallocate() } + storage.initialize(repeating: sentinel) + let destination = UnsafeMutableBufferPointer(rebasing: storage[0..(elements).jsObject.buffer.object! + let view = JSTypedArray( + unsafelyWrapping: JSObject.global.Int32Array.function!.new( + arrayBuffer, + viewOffsetInBytes, + viewLength + ) + ) + XCTAssertEqual(view.length, viewLength) + XCTAssertEqual(view.lengthInBytes, viewLength * MemoryLayout.size) + + let expected: [Int32] = [3, 4, 5, 6] + XCTAssertEqual(view.withUnsafeBytes { Array($0) }, expected) + + let sentinel: Int32 = -559_038_737 // 0xDEADBEEF + let storage = UnsafeMutableBufferPointer.allocate(capacity: elements.count) + defer { storage.deallocate() } + storage.initialize(repeating: sentinel) + let destination = UnsafeMutableBufferPointer(rebasing: storage[0..(length: 100) for i in 0..<100 {