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
23 changes: 23 additions & 0 deletions src/jsc/array_buffer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,29 @@ impl ArrayBuffer {
}
}

/// Copy this buffer's bytes into a fresh, non-shared `ArrayBuffer` via a C++
/// memcpy, without ever forming a Rust `&[u8]` over the (possibly shared or
/// resizable) source. Use to snapshot `SharedArrayBuffer` / resizable input
/// before borrowing it as `&[u8]`, where another agent could resize or mutate
/// the backing store under the borrow. The returned `ArrayBuffer` owns the copy.
pub fn copy_to_unshared(&self, global: &JSGlobalObject) -> JsResult<ArrayBuffer> {
crate::mark_binding!();
// SAFETY: FFI — same `Bun__createArrayBufferForCopy` entry point and ABI
// already used by `ArrayBuffer::create`/`create_empty`. `global` is a live
// opaque ZST handle (coerces to *const). `ptr`/`byte_len` describe `self`'s
// current live ArrayBuffer backing supplied by the caller; the C++ side
// copies those bytes immediately into a fresh, non-shared `ArrayBuffer` and
// does not retain `ptr`. A null `ptr` is only ever passed for `byte_len == 0`
// (the same empty-buffer convention as `create_empty`). No borrowed view into
// the source backing is formed or returned on this path.
let copy = crate::host_fn::from_js_host_call(global, || unsafe {
Bun__createArrayBufferForCopy(global, self.ptr.cast(), self.byte_len)
})?;
copy.as_array_buffer(global).ok_or_else(|| {
global.throw_invalid_arguments(format_args!("Failed to copy ArrayBuffer"))
})
}

pub fn create_empty<const KIND: JSType>(global: &JSGlobalObject) -> JsResult<JSValue> {
crate::mark_binding!();
match KIND {
Expand Down
25 changes: 18 additions & 7 deletions src/runtime/webcore/Blob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3413,12 +3413,17 @@ impl BlobExt for Blob {
| jsc::JSType::BigInt64Array
| jsc::JSType::BigUint64Array
| jsc::JSType::DataView => {
return Blob::try_create(
top_value.as_array_buffer(global).unwrap().byte_slice(),
global,
false,
)
.map_err(Into::into);
let array_buffer = top_value.as_array_buffer(global).unwrap();
// Shared/resizable JS backing stores are not stable enough for a
// Rust `&[u8]`. Snapshot before Blob materialization; fixed
// unshared inputs keep the borrowed path.
let stable = if array_buffer.shared || array_buffer.resizable {
array_buffer.copy_to_unshared(global)?
} else {
array_buffer
};
return Blob::try_create(stable.byte_slice(), global, false)
.map_err(Into::into);
Comment thread
EffortlessSteven marked this conversation as resolved.
}

jsc::JSType::DOMWrapper => {
Expand Down Expand Up @@ -3611,7 +3616,13 @@ impl BlobExt for Blob {
| jsc::JSType::DataView => {
could_have_non_ascii = true;
let buf = item.as_array_buffer(global).unwrap();
if parts_can_run_js {
if buf.shared || buf.resizable {
// Shared/resizable backing stores are not stable
// enough for a Rust `&[u8]`; copy before
// reading the bytes into the joiner.
let snapshot = buf.copy_to_unshared(global)?;
joiner.push_cloned(snapshot.byte_slice());
} else if parts_can_run_js {
// A later part may run user JS that detaches
// or resizes this buffer before `done()`.
joiner.push_cloned(buf.byte_slice());
Expand Down
94 changes: 94 additions & 0 deletions test/js/bun/s3/s3.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1801,3 +1801,97 @@ describe("s3 multipart upload id validation", () => {
expect(exitCode).toBe(0);
}, 60_000);
});

describe.concurrent("SharedArrayBuffer upload (stable bytes)", () => {
// S3 write accepts SharedArrayBuffer-backed upload data (the public type
// surface allows it). The byte source is snapshotted before Rust borrows it,
// so these uploads must still send exactly the requested view bytes against a
// local fake S3 endpoint — no real credentials required.
function fakeS3() {
const received: { method: string; path: string; body: Uint8Array }[] = [];
const server = Bun.serve({
port: 0,
async fetch(req) {
const url = new URL(req.url);
received.push({ method: req.method, path: url.pathname, body: new Uint8Array(await req.arrayBuffer()) });
return new Response("", { status: 200, headers: { etag: '"stable"', "content-length": "0" } });
},
});
const options = {
endpoint: server.url.href,
accessKeyId: "test",
secretAccessKey: "test",
bucket: "bucket",
region: "us-east-1",
};
return { server, options, received };
}

// A SAB-backed view surrounded by 0xff bytes that must never be uploaded, so a
// stale or whole-buffer read would change the asserted body.
function sabView(offset: number, bytes: number[]) {
const sab = new SharedArrayBuffer(offset + bytes.length + 4);
const full = new Uint8Array(sab);
full.fill(0xff);
full.set(bytes, offset);
return new Uint8Array(sab, offset, bytes.length);
}

const putBody = (received: { method: string; body: Uint8Array }[]) => received.find(r => r.method === "PUT")?.body;

it("S3File.write uploads a nonzero-offset Uint8Array(SAB) view", async () => {
const { server, options, received } = fakeS3();
try {
await s3("file-view.bin", options).write(sabView(8, [1, 2, 3, 4, 5, 6, 7, 8]));
expect(received.some(r => r.method === "PUT" && r.path === "/bucket/file-view.bin")).toBe(true);
expect(Array.from(putBody(received)!)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
} finally {
server.stop(true);
}
});

it("S3Client.write uploads a nonzero-offset Uint8Array(SAB) view", async () => {
const { server, options, received } = fakeS3();
try {
await new S3Client(options).write("client-view.bin", sabView(4, [10, 11, 12, 13]));
expect(received.some(r => r.method === "PUT" && r.path === "/bucket/client-view.bin")).toBe(true);
expect(Array.from(putBody(received)!)).toEqual([10, 11, 12, 13]);
} finally {
server.stop(true);
}
});

it("Bun.write(S3File, view) uploads a nonzero-offset Uint8Array(SAB) view", async () => {
const { server, options, received } = fakeS3();
try {
await Bun.write(s3("bun-write-view.bin", options), sabView(2, [20, 21, 22]));
expect(received.some(r => r.method === "PUT" && r.path === "/bucket/bun-write-view.bin")).toBe(true);
expect(Array.from(putBody(received)!)).toEqual([20, 21, 22]);
} finally {
server.stop(true);
}
});

it("S3File.write accepts a raw SharedArrayBuffer", async () => {
const { server, options, received } = fakeS3();
try {
const sab = new SharedArrayBuffer(5);
new Uint8Array(sab).set([1, 2, 3, 4, 5]);
await s3("raw-sab.bin", options).write(sab);
expect(Array.from(putBody(received)!)).toEqual([1, 2, 3, 4, 5]);
} finally {
server.stop(true);
}
});

it("S3File.write accepts a zero-length Uint8Array(SAB) view", async () => {
const { server, options, received } = fakeS3();
try {
await s3("zero-view.bin", options).write(new Uint8Array(new SharedArrayBuffer(8), 4, 0));
expect(received.some(r => r.method === "PUT" && r.path === "/bucket/zero-view.bin")).toBe(true);
expect(putBody(received)!.byteLength).toBe(0);
} finally {
server.stop(true);
}
});
});
58 changes: 58 additions & 0 deletions test/js/web/fetch/blob.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -556,3 +556,61 @@ describe("slice bounds are respected when streaming and serving", () => {
expect(await get.text()).toBe("3456");
});
});

// Acceptance/behavior coverage for SharedArrayBuffer- and resizable-backed Blob
// input. The fix copies such backing in C++ before Rust borrows it as a &[u8] (a
// shared-aliasing UB that Miri catches but JS cannot observe, since Blob always
// copies its bytes synchronously); these assert the observable contract: the
// input is accepted and only the view's range is materialized.
test("Blob copies a nonzero-offset SharedArrayBuffer view", async () => {
// The 0xff guard bytes around the view must not appear in the Blob.
const sab = new SharedArrayBuffer(16);
const bytes = new Uint8Array(sab);
bytes.fill(0xff);
bytes.set([1, 2, 3, 4], 6);

const blob = new Blob([new Uint8Array(sab, 6, 4)]);
expect(Array.from(new Uint8Array(await blob.arrayBuffer()))).toEqual([1, 2, 3, 4]);
});

test("Blob copies SharedArrayBuffer parts in multi-part blobs", async () => {
const sab = new SharedArrayBuffer(16);
const bytes = new Uint8Array(sab);
bytes.fill(0xff);
bytes.set([1, 2, 3, 4], 6);

const blob = new Blob([new Uint8Array(sab, 6, 4), "x"]);
expect(Array.from(new Uint8Array(await blob.arrayBuffer()))).toEqual([1, 2, 3, 4, 120]);
});

test("Blob copies multiple SharedArrayBuffer parts", async () => {
const sab = new SharedArrayBuffer(16);
const bytes = new Uint8Array(sab);
bytes.fill(0xff);
bytes.set([1, 2], 4);
bytes.set([3, 4], 10);

const blob = new Blob([new Uint8Array(sab, 4, 2), new Uint8Array(sab, 10, 2)]);
expect(Array.from(new Uint8Array(await blob.arrayBuffer()))).toEqual([1, 2, 3, 4]);
});

test("Blob copies DataView parts over SharedArrayBuffer", async () => {
const sab = new SharedArrayBuffer(16);
const bytes = new Uint8Array(sab);
bytes.fill(0xff);
bytes.set([5, 6, 7], 8);

const blob = new Blob([new DataView(sab, 8, 3)]);
expect(Array.from(new Uint8Array(await blob.arrayBuffer()))).toEqual([5, 6, 7]);
});

test("Blob copies a resizable ArrayBuffer view", async () => {
// Resizable (non-shared) ArrayBuffer backing takes the same copy path as a SAB.
const rab = new ArrayBuffer(16, { maxByteLength: 32 });
const bytes = new Uint8Array(rab);
bytes.fill(0xff);
bytes.set([1, 2, 3, 4], 6);

const blob = new Blob([new Uint8Array(rab, 6, 4)]);
expect(Array.from(new Uint8Array(await blob.arrayBuffer()))).toEqual([1, 2, 3, 4]);
});