Skip to content

Commit d8519f7

Browse files
Runtime: Copy only the view's window in swjs_load_typed_array
`new Uint8Array(typedArray.buffer)` views the whole backing `ArrayBuffer`, ignoring the `byteOffset` and `byteLength` of the view it was handed. A view with a non-zero offset (anything from `subarray`, or `new Uint8Array(buffer, offset, length)`) therefore arrives in the guest shifted by that offset, silently wrong rather than failing. It is also a memory-safety bug: `JSTypedArray.copyMemory(to:)` sizes the destination from the view's own `length`, so whenever the view is smaller than its backing buffer the runtime writes past the end of the destination and corrupts whatever follows it in linear memory. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B97nGtc85G19MMHbw7Ps8h
1 parent cadafdc commit d8519f7

4 files changed

Lines changed: 201 additions & 2 deletions

File tree

Plugins/PackageToJS/Templates/runtime.mjs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -759,7 +759,12 @@ class SwiftRuntime {
759759
swjs_load_typed_array: (ref, buffer) => {
760760
const memory = this.memory;
761761
const typedArray = memory.getObject(ref);
762-
const bytes = new Uint8Array(typedArray.buffer);
762+
// Copy only the window the view describes. `typedArray.buffer`
763+
// is the whole backing `ArrayBuffer`, which may be larger than
764+
// the view and may start before it; the guest sizes the
765+
// destination from the view's own length, so viewing the entire
766+
// buffer would both shift the bytes and overrun the destination.
767+
const bytes = new Uint8Array(typedArray.buffer, typedArray.byteOffset, typedArray.byteLength);
763768
this.getUint8Array().set(bytes, buffer >>> 0);
764769
},
765770
swjs_release: (ref) => {

Runtime/src/index.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -763,7 +763,16 @@ export class SwiftRuntime {
763763
swjs_load_typed_array: (ref: ref, buffer: pointer) => {
764764
const memory = this.memory;
765765
const typedArray = memory.getObject(ref);
766-
const bytes = new Uint8Array(typedArray.buffer);
766+
// Copy only the window the view describes. `typedArray.buffer`
767+
// is the whole backing `ArrayBuffer`, which may be larger than
768+
// the view and may start before it; the guest sizes the
769+
// destination from the view's own length, so viewing the entire
770+
// buffer would both shift the bytes and overrun the destination.
771+
const bytes = new Uint8Array(
772+
typedArray.buffer,
773+
typedArray.byteOffset,
774+
typedArray.byteLength,
775+
);
767776
this.getUint8Array().set(bytes, buffer >>> 0);
768777
},
769778

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { describe, expect, test } from "vitest";
2+
import { SwiftRuntime } from "../src/index.js";
3+
4+
// `swjs_load_typed_array` must copy only the window a TypedArray view describes,
5+
// not its whole backing `ArrayBuffer`. The guest sizes the destination from the
6+
// view's own `length`/`byteLength`, so copying the entire buffer both shifts the
7+
// bytes (a view with a non-zero `byteOffset` lands offset in the guest) and
8+
// writes past the end of the destination.
9+
const DESTINATION = 1024;
10+
11+
function makeRuntime(): { runtime: SwiftRuntime; memory: WebAssembly.Memory } {
12+
const memory = new WebAssembly.Memory({ initial: 1 });
13+
const runtime = new SwiftRuntime();
14+
runtime.setInstance({
15+
exports: {
16+
memory,
17+
swjs_library_version: () => 708,
18+
},
19+
} as unknown as WebAssembly.Instance);
20+
return { runtime, memory };
21+
}
22+
23+
describe("swjs_load_typed_array respects the view's window", () => {
24+
test("copies a Uint8Array view from its byteOffset", () => {
25+
const { runtime, memory } = makeRuntime();
26+
const backing = new ArrayBuffer(32);
27+
new Uint8Array(backing).set(
28+
Array.from({ length: 32 }, (_, i) => 0xa0 + i),
29+
);
30+
const view = new Uint8Array(backing, 8, 8);
31+
32+
const space = (runtime as any).memory;
33+
const imports = runtime.wasmImports as any;
34+
imports.swjs_load_typed_array(space.retain(view), DESTINATION);
35+
36+
const guest = new Uint8Array(memory.buffer);
37+
expect(
38+
Array.from(guest.subarray(DESTINATION, DESTINATION + 8)),
39+
).toEqual(Array.from(view));
40+
});
41+
42+
test("does not write past the end of the view", () => {
43+
const { runtime, memory } = makeRuntime();
44+
const backing = new ArrayBuffer(32);
45+
new Uint8Array(backing).fill(0xff);
46+
const view = new Uint8Array(backing, 8, 8);
47+
48+
// Fill the guest memory around the destination with a sentinel so any
49+
// byte written beyond the view's `byteLength` is visible.
50+
const guest = new Uint8Array(memory.buffer);
51+
guest.fill(0x5a, DESTINATION, DESTINATION + 64);
52+
53+
const space = (runtime as any).memory;
54+
const imports = runtime.wasmImports as any;
55+
imports.swjs_load_typed_array(space.retain(view), DESTINATION);
56+
57+
expect(
58+
Array.from(guest.subarray(DESTINATION + 8, DESTINATION + 64)),
59+
).toEqual(new Array(56).fill(0x5a));
60+
});
61+
62+
test("copies a multi-byte element view from its byteOffset", () => {
63+
const { runtime, memory } = makeRuntime();
64+
const backing = new ArrayBuffer(32);
65+
new Int32Array(backing).set([1, 2, 3, 4, 5, 6, 7, 8]);
66+
const view = new Int32Array(backing, 8, 4);
67+
68+
const space = (runtime as any).memory;
69+
const imports = runtime.wasmImports as any;
70+
imports.swjs_load_typed_array(space.retain(view), DESTINATION);
71+
72+
const guest = new Int32Array(memory.buffer, DESTINATION, 4);
73+
expect(Array.from(guest)).toEqual([3, 4, 5, 6]);
74+
});
75+
76+
test("copies a DataView from its byteOffset", () => {
77+
const { runtime, memory } = makeRuntime();
78+
const backing = new ArrayBuffer(32);
79+
new Uint8Array(backing).set(
80+
Array.from({ length: 32 }, (_, i) => 0xa0 + i),
81+
);
82+
const view = new DataView(backing, 8, 8);
83+
84+
const space = (runtime as any).memory;
85+
const imports = runtime.wasmImports as any;
86+
imports.swjs_load_typed_array(space.retain(view), DESTINATION);
87+
88+
const guest = new Uint8Array(memory.buffer);
89+
expect(
90+
Array.from(guest.subarray(DESTINATION, DESTINATION + 8)),
91+
).toEqual(Array.from(new Uint8Array(backing, 8, 8)));
92+
});
93+
94+
test("still copies a whole-buffer view unchanged", () => {
95+
const { runtime, memory } = makeRuntime();
96+
const view = new Uint8Array([1, 2, 3, 4, 5]);
97+
98+
const space = (runtime as any).memory;
99+
const imports = runtime.wasmImports as any;
100+
imports.swjs_load_typed_array(space.retain(view), DESTINATION);
101+
102+
const guest = new Uint8Array(memory.buffer);
103+
expect(
104+
Array.from(guest.subarray(DESTINATION, DESTINATION + 5)),
105+
).toEqual([1, 2, 3, 4, 5]);
106+
});
107+
});

Tests/JavaScriptKitTests/JSTypedArrayTests.swift

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,84 @@ final class JSTypedArrayTests: XCTestCase {
110110
}
111111
}
112112

113+
func testTypedArrayWithByteOffset() {
114+
// A view over part of a larger `ArrayBuffer`: `byteOffset` is non-zero and
115+
// `byteLength` is smaller than the backing buffer. Copying the whole
116+
// buffer instead of the view's window would both shift the bytes and
117+
// write past the end of the destination, which is sized from `length`.
118+
let backingLength = 32
119+
let viewOffset = 8
120+
let viewLength = 8
121+
122+
let arrayBuffer = JSObject.global.ArrayBuffer.function!.new(backingLength)
123+
let wholeBuffer = JSTypedArray<UInt8>(
124+
unsafelyWrapping: JSObject.global.Uint8Array.function!.new(arrayBuffer)
125+
)
126+
for i in 0..<backingLength {
127+
wholeBuffer[i] = UInt8(0xA0 + i)
128+
}
129+
130+
let view = JSTypedArray<UInt8>(
131+
unsafelyWrapping: JSObject.global.Uint8Array.function!.new(
132+
arrayBuffer,
133+
viewOffset,
134+
viewLength
135+
)
136+
)
137+
XCTAssertEqual(view.length, viewLength)
138+
XCTAssertEqual(view.lengthInBytes, viewLength)
139+
140+
let expected = (0..<viewLength).map { UInt8(0xA0 + viewOffset + $0) }
141+
XCTAssertEqual(view.withUnsafeBytes { Array($0) }, expected)
142+
143+
// `copyMemory(to:)` must not write beyond the destination it is given.
144+
let sentinel: UInt8 = 0x5A
145+
let storage = UnsafeMutableBufferPointer<UInt8>.allocate(capacity: backingLength)
146+
defer { storage.deallocate() }
147+
storage.initialize(repeating: sentinel)
148+
let destination = UnsafeMutableBufferPointer(rebasing: storage[0..<viewLength])
149+
view.copyMemory(to: destination)
150+
151+
XCTAssertEqual(Array(destination), expected)
152+
for i in viewLength..<backingLength {
153+
XCTAssertEqual(storage[i], sentinel, "copyMemory(to:) wrote past the destination at \(i)")
154+
}
155+
}
156+
157+
func testMultiByteTypedArrayWithByteOffset() {
158+
// Same, with a multi-byte element type, so the destination is sized in
159+
// elements while the overrun would be measured in bytes.
160+
let elements: [Int32] = [1, 2, 3, 4, 5, 6, 7, 8]
161+
let viewOffsetInBytes = 8
162+
let viewLength = 4
163+
164+
let arrayBuffer = JSTypedArray<Int32>(elements).jsObject.buffer.object!
165+
let view = JSTypedArray<Int32>(
166+
unsafelyWrapping: JSObject.global.Int32Array.function!.new(
167+
arrayBuffer,
168+
viewOffsetInBytes,
169+
viewLength
170+
)
171+
)
172+
XCTAssertEqual(view.length, viewLength)
173+
XCTAssertEqual(view.lengthInBytes, viewLength * MemoryLayout<Int32>.size)
174+
175+
let expected: [Int32] = [3, 4, 5, 6]
176+
XCTAssertEqual(view.withUnsafeBytes { Array($0) }, expected)
177+
178+
let sentinel: Int32 = -559_038_737 // 0xDEADBEEF
179+
let storage = UnsafeMutableBufferPointer<Int32>.allocate(capacity: elements.count)
180+
defer { storage.deallocate() }
181+
storage.initialize(repeating: sentinel)
182+
let destination = UnsafeMutableBufferPointer(rebasing: storage[0..<viewLength])
183+
view.copyMemory(to: destination)
184+
185+
XCTAssertEqual(Array(destination), expected)
186+
for i in viewLength..<elements.count {
187+
XCTAssertEqual(storage[i], sentinel, "copyMemory(to:) wrote past the destination at \(i)")
188+
}
189+
}
190+
113191
func testCopyMemory() {
114192
let array = JSTypedArray<Int>(length: 100)
115193
for i in 0..<100 {

0 commit comments

Comments
 (0)