From addb1eba442c9af30f55b430d3a4220a5516f280 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 26 Jun 2026 22:37:38 +0000 Subject: [PATCH 1/4] Resolve a file slice's size to the slice's length, not the file's Bun.file(p).slice(a, b) served from a Bun.serve fetch handler reported the wrong framing: GET invented a 206 + Content-Range the client never asked for, and HEAD reported Content-Length = fileSize - a. - Blob::resolve_size / resolved_size: the File arm widened a concrete slice size to max_size - offset; the Bytes arm already guarded against this. Extract the shared clamp_view_to_store helper and use it in both arms of both functions. This fixes HEAD's Content-Length, Response(slice).body reading past the end of the slice, and structuredClone mutating the source blob's size. - RequestContext::do_sendfile: resolve the blob's size to the clamped sendfile length instead of the stat size, so the slice is the HTTP entity and render_metadata frames it as a plain 200. - render_metadata: needs_content_range now only comes from a resolved incoming Range header, so the remain < blob.size() and has_content_range compensations are dead; remove them. - Blob serialization: add the blob's size (format version 4) so a file slice's length survives structuredClone / bun:jsc serialize. --- src/runtime/server/RequestContext.rs | 97 +++++++++---------- src/runtime/webcore/Blob.rs | 89 ++++++++++------- test/js/bun/http/bun-serve-file.test.ts | 65 ++++++++++++- test/js/web/fetch/blob.test.ts | 15 +++ .../js/web/structured-clone-blob-file.test.ts | 27 +++++- 5 files changed, 203 insertions(+), 90 deletions(-) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 762f9b8aec5e..9ca8cda44565 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -1764,30 +1764,18 @@ where AnyBlob::Blob(b) => b.size.get(), _ => unreachable!(), }; - let stat_size: BlobSizeType = BlobSizeType::try_from(stat.st_size.max(0)).unwrap(); - if let AnyBlob::Blob(b) = &mut self.blob { - b.size.set(if is_regular { - stat_size - } else { - original_size.min(stat_size) - }); - } - - self.flags.set_needs_content_length(true); let blob_offset = match &self.blob { AnyBlob::Blob(b) => b.offset.get(), _ => unreachable!(), }; + let stat_size: BlobSizeType = BlobSizeType::try_from(stat.st_size.max(0)).unwrap(); + + self.flags.set_needs_content_length(true); self.sendfile = SendfileContext { remain: blob_offset + original_size, offset: blob_offset, total: 0, }; - if is_regular && auto_close { - self.flags.set_needs_content_range( - self.sendfile.remain.saturating_sub(self.sendfile.offset) != stat_size, - ); - } if is_regular { self.sendfile.offset = self.sendfile.offset.min(stat_size); self.sendfile.remain = self @@ -1797,17 +1785,31 @@ where .min(stat_size) .saturating_sub(self.sendfile.offset); } + // Resolve the blob's size now that the file is stat'd. For a regular + // file the clamped `sendfile.remain` is exactly the byte count we will + // send, which is the HTTP entity length. In particular a `.slice()` + // keeps its own length rather than inheriting the whole file's, so + // `render_metadata` frames it as a plain 200 instead of inventing a + // 206 + Content-Range the client never asked for. + if let AnyBlob::Blob(b) = &mut self.blob { + b.size.set(if is_regular { + self.sendfile.remain + } else { + original_size.min(stat_size) + }); + } // Honor an incoming Range: header for whole-file responses. We // don't compose Range with a user-supplied .slice() because the - // Content-Range arithmetic gets ambiguous; the slice path keeps - // its existing slice-as-range behavior. `offset == 0` alone is - // insufficient — `Bun.file(p).slice(0, n)` has offset 0 — so we - // also check the size: an unsliced blob has either the unset-size - // sentinel or, if JS already read `.size`, the stat'd size; a - // `.slice(0, n)` blob has `n < stat_size`. Skip if the user - // already set Content-Range or a non-200 status — they're - // managing partial responses themselves. + // Content-Range arithmetic gets ambiguous; a sliced body ignores + // the Range header and is served as a plain 200 whose entity is + // the slice. `offset == 0` alone is insufficient — + // `Bun.file(p).slice(0, n)` has offset 0 — so we also check the + // size: an unsliced blob has either the unset-size sentinel or, + // if JS already read `.size`, the stat'd size; a `.slice(0, n)` + // blob has `n < stat_size`. Skip if the user already set + // Content-Range or a non-200 status — they're managing partial + // responses themselves. let user_handles_range = if let Some(r) = self.response_weakref.get() { r.status_code() != 200 || r.get_init_headers_mut() @@ -3486,8 +3488,11 @@ where // an async hop keep the Response rooted via response_protected. let response: &mut Response = self.response_weakref.get().unwrap(); let mut status = response.status_code(); - let mut needs_content_range = self.flags.needs_content_range() - && (self.sendfile.total > 0 || self.sendfile.remain < self.blob.size()); + // Set only when `do_sendfile` resolved an incoming `Range:` header to + // a satisfiable range. A `.slice()` body is never a partial response: + // the slice is the whole entity, and `do_sendfile` resolves the blob + // size to the slice's length so Content-Length describes it directly. + let needs_content_range = self.flags.needs_content_range(); let size = if needs_content_range { self.sendfile.remain @@ -3495,6 +3500,10 @@ where self.blob.size() }; + if needs_content_range { + status = 206; + } + let (content_type, needs_content_type, content_type_needs_free) = get_content_type(response.get_init_headers_mut(), &self.blob); // NOTE: `MimeType` owns a `Cow<'static, [u8]>`; Drop handles the owned case. @@ -3503,32 +3512,20 @@ where // Drop of `content_type` (moved into closure capture below would // change borrow lifetimes); rely on natural end-of-scope drop. }); + // Take the headers out before `do_write_status` borrows `self` so the + // `response` reference (which also borrows `self`) is no longer live. + // The status line must still hit the wire before any header. + let headers = response.swap_init_headers(); + self.do_write_status(status); let mut has_content_disposition = false; - let mut has_content_range = false; - if let Some(mut headers_) = response.swap_init_headers() { + if let Some(mut headers_) = headers { has_content_disposition = headers_.fast_has(jsc::HTTPHeaderName::ContentDisposition); - has_content_range = headers_.fast_has(jsc::HTTPHeaderName::ContentRange); - // For .slice()-driven ranges, only promote to 206 if the user - // also set Content-Range (preserves the old contract). For an - // incoming Range: header (sendfile.total > 0) we always 206. - needs_content_range = - needs_content_range && (self.sendfile.total > 0 || has_content_range); - if needs_content_range { - status = 206; - } - - self.do_write_status(status); self.do_write_headers(&mut headers_); // `HeadersRef` is RAII — its Drop // already calls `WebCore__FetchHeaders__deref`, so an explicit // `.deref()` here would resolve (via DerefMut) to the inherent // `FetchHeaders::deref` and double-free the C++ object. drop(headers_); - } else if needs_content_range { - status = 206; - self.do_write_status(status); - } else { - self.do_write_status(status); } if let Some(mut cookies) = self.cookies.take() { @@ -3604,25 +3601,21 @@ where self.flags.set_needs_content_length(false); } - if needs_content_range && !has_content_range { + if needs_content_range { let mut crbuf = [0u8; RangeRequest::CONTENT_RANGE_BUF]; let end = self.sendfile.offset + self.sendfile.remain.saturating_sub(1); - // `total > 0` ⇒ we resolved an incoming Range header against the - // stat'd size, so the full size is meaningful. Otherwise this is a - // `.slice()`-driven range — omit the full size (it can change - // between requests and may leak PII). + // `sendfile.total` is the stat'd size the incoming Range header + // was resolved against, so the full size is always meaningful. let header_value = RangeRequest::format_content_range( &mut crbuf, RangeRequest::Result::Satisfiable { start: self.sendfile.offset, end, }, - (self.sendfile.total > 0).then_some(self.sendfile.total), + Some(self.sendfile.total), ); resp.write_header(b"content-range", header_value); - if self.sendfile.total > 0 { - resp.write_header(b"accept-ranges", b"bytes"); - } + resp.write_header(b"accept-ranges", b"bytes"); self.flags.set_needs_content_range(false); } } diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 4f645182ddcc..8df8f0c33146 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -112,7 +112,10 @@ pub type Ref = bun_ptr::ExternalShared; /// 2: Added byte for whether it's a dom file, length and bytes for `stored_name`, /// and f64 for `last_modified`. /// 3: Added File name serialization for File objects (when is_jsdom_file is true) -const SERIALIZATION_VERSION: u8 = 3; +/// 4: Added the blob's `size` (u64). `offset` alone cannot reconstruct a +/// file-backed `.slice(start, end)`: without the length the clone widens +/// to the rest of the file. +const SERIALIZATION_VERSION: u8 = 4; pub use bun_jsc::generated::JSBlob as js; @@ -780,6 +783,13 @@ impl BlobExt for Blob { writer.write_int_le::(0)?; } } + + // Version 4: the blob's `size`. For a memory-backed blob the payload + // above already is the `(offset, size)` view; for a file-backed one + // only the path and `offset` are serialized, and `resolve_size()` + // (called before `store.serialize` above) just clamped `size` to the + // file, so this is the slice's concrete length. + writer.write_int_le::(self.size.get())?; Ok(()) } @@ -2358,20 +2368,12 @@ impl BlobExt for Blob { // the raw-ptr deref so each read here is a fresh, safe borrow. match store.data_mut().tag() { store::DataTag::Bytes => { - let offset = self.offset.get(); let store_size = store.size(); if store_size != MAX_SIZE { - self.offset.set(store_size.min(offset)); - let available = store_size - self.offset.get(); - // Only resolve an unknown size. A slice already has a concrete - // `size`; overwriting it with `store_size - offset` would widen - // the view to the end of the backing store. Clamp a known size - // to `available` so a bogus size can't report past the store end. - if self.size.get() == MAX_SIZE { - self.size.set(available); - } else { - self.size.set(self.size.get().min(available)); - } + let (offset, size) = + clamp_view_to_store(self.offset.get(), self.size.get(), store_size); + self.offset.set(offset); + self.size.set(size); } } store::DataTag::File => { @@ -2382,10 +2384,10 @@ impl BlobExt for Blob { let file = store.data_mut().as_file(); if file.seekable.is_some() && file.max_size != MAX_SIZE { - let store_size = file.max_size; - let offset = self.offset.get(); - self.offset.set(store_size.min(offset)); - self.size.set(store_size.saturating_sub(offset)); + let (offset, size) = + clamp_view_to_store(self.offset.get(), self.size.get(), file.max_size); + self.offset.set(offset); + self.size.set(size); return; } @@ -2414,21 +2416,9 @@ impl BlobExt for Blob { // `Deref`-produced `&Data`/`&File` is live across the mutating call. match store.data_mut().tag() { store::DataTag::Bytes => { - let offset = self.offset.get(); let store_size = store.size(); if store_size != MAX_SIZE { - let offset = store_size.min(offset); - let available = store_size - offset; - // Matches `resolve_size`: a known size (e.g. a slice) is - // authoritative; only an unknown size falls back to the - // remainder of the backing store. Clamp to `available` so a - // bogus size can't report past the store end. - let size = if self.size.get() == MAX_SIZE { - available - } else { - self.size.get().min(available) - }; - return (offset, size); + return clamp_view_to_store(self.offset.get(), self.size.get(), store_size); } (self.offset.get(), self.size.get()) } @@ -2439,9 +2429,7 @@ impl BlobExt for Blob { // Fresh borrow after possible mutation by `resolve_file_stat`. let file = store.data_mut().as_file(); if file.seekable.is_some() && file.max_size != MAX_SIZE { - let store_size = file.max_size; - let offset = self.offset.get(); - return (store_size.min(offset), store_size.saturating_sub(offset)); + return clamp_view_to_store(self.offset.get(), self.size.get(), file.max_size); } if file.seekable == Some(false) { return (self.offset.get(), self.size.get()); @@ -4343,6 +4331,15 @@ fn _on_structured_clone_deserialize>( if version == 3 { break 'versions; } + + // Version 4: the blob's `size`. Required to reconstruct a file-backed + // `.slice(start, end)`: `offset` alone widens the view to the rest of + // the file. Version 3 payloads fall back to that older behavior. + blob.size.set(reader.read_int_le::()? as SizeType); + + if version == 4 { + break 'versions; + } } debug_assert!( @@ -4350,8 +4347,9 @@ fn _on_structured_clone_deserialize>( "expected blob to be heap-allocated" ); - // `offset` comes from untrusted bytes. Clamp it so a crafted payload cannot - // make shared_view() slice past the end of the backing store (OOB heap read). + // `offset` and `size` come from untrusted bytes. Clamp them so a crafted + // payload cannot make shared_view() slice past the end of the backing + // store (OOB heap read). blob.offset.set(offset as SizeType); // intentional truncate if let Some(store) = blob.store.get() { let store_size = store.size(); @@ -4362,6 +4360,7 @@ fn _on_structured_clone_deserialize>( } } else { blob.offset.set(0); + blob.size.set(0); } if !content_type.is_empty() { @@ -6285,6 +6284,26 @@ fn stat_to_js_mtime(stat: &bun_sys::Stat) -> jsc::JSTimeType { } /// resolve file stat like size, last_modified +/// Clamp a blob's `(offset, size)` view to a backing store whose size is now +/// known. Only the `MAX_SIZE` unknown-size sentinel resolves to the remainder +/// of the store: a slice already has a concrete `size`, and overwriting it +/// with `store_size - offset` would widen the view to the end of the backing +/// store. Both results are capped to the bytes the store actually has. +fn clamp_view_to_store( + offset: SizeType, + size: SizeType, + store_size: SizeType, +) -> (SizeType, SizeType) { + let offset = store_size.min(offset); + let available = store_size - offset; + let size = if size == MAX_SIZE { + available + } else { + size.min(available) + }; + (offset, size) +} + fn resolve_file_stat(store: &StoreRef) { // `StoreRef::data_mut` encapsulates the raw-pointer deref under the // `StoreRef` liveness invariant; the caller holds the only ref across diff --git a/test/js/bun/http/bun-serve-file.test.ts b/test/js/bun/http/bun-serve-file.test.ts index 2165d6774252..02de89005496 100644 --- a/test/js/bun/http/bun-serve-file.test.ts +++ b/test/js/bun/http/bun-serve-file.test.ts @@ -88,6 +88,15 @@ describe("Bun.file in serve routes", () => { return new Response(f); }, "/slice-escape": () => new Response(Bun.file(join(tempDir, "bytes256.bin")).slice(0, 100)), + // A Bun.file().slice() returned from a function route is the whole HTTP + // entity: GET must be a 200 whose Content-Length is the slice's length + // (no server-invented 206 / Content-Range), and HEAD must report the + // same framing as GET. + "/slice-handler": () => new Response(Bun.file(join(tempDir, "partial.txt")).slice(5, 10)), + "/slice-zero-offset-handler": () => new Response(Bun.file(join(tempDir, "partial.txt")).slice(0, 4)), + "/slice-open-handler": () => new Response(Bun.file(join(tempDir, "partial.txt")).slice(5)), + "/slice-past-eof-handler": () => new Response(Bun.file(join(tempDir, "partial.txt")).slice(12, 99)), + "/slice-empty-handler": () => new Response(Bun.file(join(tempDir, "partial.txt")).slice(5, 5)), "/range-custom-headers": () => new Response(Bun.file(join(tempDir, "partial.txt")), { headers: { "Cache-Control": "max-age=3600", "X-Custom": "abc" }, @@ -755,11 +764,63 @@ describe("Bun.file in serve routes", () => { it("Range header cannot escape a Bun.file().slice(0, n) window via fetch handler", async () => { const res = await fetch(new URL("/slice-escape", server.url), { headers: { Range: "bytes=200-220" } }); const bytes = new Uint8Array(await res.arrayBuffer()); - // Range must be ignored for sliced blobs: serve the 100-byte slice, never bytes 200-220. + // Range is ignored for sliced blobs: the slice is the whole entity, so + // serve it as a plain 200, never bytes 200-220 and never a + // server-invented 206 + Content-Range. expect(bytes.length).toBe(100); expect(bytes[0]).toBe(0); expect(bytes[99]).toBe(99); - expect(res.headers.get("content-range")).not.toContain("/256"); + expect(res.headers.get("content-range")).toBeNull(); + expect(res.status).toBe(200); + }); + }); + + // RFC 9110: without a client Range header the response is a 200 whose + // entity is the body the handler returned. A Bun.file().slice() body must + // not leak its internal offset as a 206 + Content-Range, and HEAD (§9.3.2) + // must report the same status and Content-Length that GET would. + describe.concurrent("Bun.file().slice() via fetch handler is the entity", () => { + it.each([ + ["/slice-handler", "56789"], + ["/slice-zero-offset-handler", "0123"], + ["/slice-open-handler", "56789ABCDEF"], + ["/slice-past-eof-handler", "CDEF"], + ])("GET and HEAD agree on %s", async (path, body) => { + const expected = { + status: 200, + contentLength: String(body.length), + contentRange: null, + }; + + const get = await fetch(new URL(path, server.url)); + const text = await get.text(); + expect({ + status: get.status, + contentLength: get.headers.get("content-length"), + contentRange: get.headers.get("content-range"), + body: text, + }).toEqual({ ...expected, body }); + + const head = await fetch(new URL(path, server.url), { method: "HEAD" }); + expect(await head.text()).toBe(""); + expect({ + status: head.status, + contentLength: head.headers.get("content-length"), + contentRange: head.headers.get("content-range"), + }).toEqual(expected); + }); + + it("empty slice: GET is a 204 with no Content-Range", async () => { + const res = await fetch(new URL("/slice-empty-handler", server.url)); + expect(await res.text()).toBe(""); + expect(res.headers.get("content-range")).toBeNull(); + expect(res.status).toBe(204); + }); + + it("empty slice: HEAD reports Content-Length 0", async () => { + const res = await fetch(new URL("/slice-empty-handler", server.url), { method: "HEAD" }); + expect(res.headers.get("content-length")).toBe("0"); + expect(res.headers.get("content-range")).toBeNull(); }); }); }); diff --git a/test/js/web/fetch/blob.test.ts b/test/js/web/fetch/blob.test.ts index dc5ccbd1adf9..a8a599c004b8 100644 --- a/test/js/web/fetch/blob.test.ts +++ b/test/js/web/fetch/blob.test.ts @@ -93,6 +93,21 @@ for (const info of [ }); } +// `Blob::resolve_size()` must not widen a file-backed slice's concrete `size` +// to `fileSize - offset`. `Response(slice).body` resolves the blob size to +// build the stream, and the widened size made it read past the end of the +// slice (156 of the 256 bytes below instead of the 100-byte window). +test("Response(Bun.file().slice()).body streams exactly the slice", async () => { + // byte[i] === i, so an out-of-window read is distinguishable from a length bug. + using dir = tempDir("blob-file-slice-body", { + "f.bin": Buffer.from(Array.from({ length: 256 }, (_, i) => i)), + }); + const slice = Bun.file(path.join(String(dir), "f.bin")).slice(100, 200); + expect(await new Response(slice).body!.bytes()).toEqual( + new Uint8Array(Array.from({ length: 100 }, (_, i) => 100 + i)), + ); +}); + test("new Blob", () => { var blob = new Blob(["Bun", "Foo"], { type: "text/foo" }); expect(blob.size).toBe(6); diff --git a/test/js/web/structured-clone-blob-file.test.ts b/test/js/web/structured-clone-blob-file.test.ts index 7d887d882c17..e3af33976557 100644 --- a/test/js/web/structured-clone-blob-file.test.ts +++ b/test/js/web/structured-clone-blob-file.test.ts @@ -1,6 +1,7 @@ import { deserialize, serialize } from "bun:jsc"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN } from "harness"; +import { bunEnv, bunExe, isASAN, tempDir } from "harness"; +import path from "node:path"; import v8 from "node:v8"; describe("structuredClone with Blob and File", () => { @@ -146,6 +147,30 @@ describe("structuredClone with Blob and File", () => { expect(roundTripped.size).toBe(7); expect(await roundTripped.text()).toBe("PAYLOAD"); }); + + // File-backed sibling of the test above. The wire format carries the + // file path and the slice's offset; without the slice's length the clone + // widens to the rest of the file (156 of the 256 bytes below). + test("sliced Bun.file() round-trips its offset and length", async () => { + // byte[i] === i, so an out-of-window read is distinguishable from a length bug. + using dir = tempDir("structured-clone-file-slice", { + "f.bin": Buffer.from(Array.from({ length: 256 }, (_, i) => i)), + }); + const expected = new Uint8Array(Array.from({ length: 100 }, (_, i) => 100 + i)); + const slice = Bun.file(path.join(String(dir), "f.bin")).slice(100, 200); + + const cloned = structuredClone(slice); + expect(cloned.size).toBe(100); + expect(new Uint8Array(await cloned.arrayBuffer())).toEqual(expected); + + const roundTripped = deserialize(serialize(slice)); + expect(roundTripped.size).toBe(100); + expect(new Uint8Array(await roundTripped.arrayBuffer())).toEqual(expected); + + // Serializing must not mutate the live source slice. + expect(slice.size).toBe(100); + expect(new Uint8Array(await slice.arrayBuffer())).toEqual(expected); + }); }); describe("File structured clone", () => { From 5b90c3f5903ad557432e5f61e29f62cb52ce6ab6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 26 Jun 2026 22:54:55 +0000 Subject: [PATCH 2/4] Update the slice-as-206 tests and docs for the new framing new Response(Bun.file(f).slice(start, end)) is now a plain 200 whose entity is the slice, so rewrite the serve.test.ts suite that pinned the old 206 + Content-Range behavior to assert the new framing (and add the Content-Length / Content-Range checks it was missing). An empty slice is a 204, never a 0-byte 206. Update docs/runtime/http/routing.mdx: slicing sets Content-Length to the slice's length, and incoming Range headers are handled natively by returning the whole file, so drop the parse-Range-yourself example. --- docs/runtime/http/routing.mdx | 26 ++++----- test/js/bun/http/serve.test.ts | 53 +++++++++++-------- .../js/web/structured-clone-blob-file.test.ts | 2 +- 3 files changed, 45 insertions(+), 36 deletions(-) diff --git a/docs/runtime/http/routing.mdx b/docs/runtime/http/routing.mdx index 187e00049bd0..5beee551272c 100644 --- a/docs/runtime/http/routing.mdx +++ b/docs/runtime/http/routing.mdx @@ -213,22 +213,24 @@ Bun.serve({ system call when possible, enabling zero-copy file transfers in the kernel—the fastest way to send files. -To send part of a file, use the [`slice(start, end)`](https://developer.mozilla.org/en-US/docs/Web/API/Blob/slice) method on the `Bun.file` object. Bun sets the `Content-Range` and `Content-Length` headers on the `Response` object automatically. +To send part of a file, use the [`slice(start, end)`](https://developer.mozilla.org/en-US/docs/Web/API/Blob/slice) method on the `Bun.file` object. The slice is the response body, so Bun sets `Content-Length` to the slice's length. ```ts Bun.serve({ fetch(req) { - // parse `Range` header - const [start = 0, end = Infinity] = req.headers - .get("Range") // Range: bytes=0-100 - .split("=") // ["Range: bytes", "0-100"] - .at(-1) // "0-100" - .split("-") // ["0", "100"] - .map(Number); // [0, 100] - - // return a slice of the file - const bigFile = Bun.file("./big-video.mp4"); - return new Response(bigFile.slice(start, end)); + // send the first megabyte of a file + return new Response(Bun.file("./big-video.mp4").slice(0, 1024 * 1024)); + }, +}); +``` + +To support HTTP [`Range` requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests), return the whole file. Bun resolves the client's `Range` header against it and responds with `206 Partial Content` and the matching `Content-Range` header automatically. + +```ts +Bun.serve({ + fetch(req) { + // `Range: bytes=0-100` → `206` with `Content-Range: bytes 0-100/` + return new Response(Bun.file("./big-video.mp4")); }, }); ``` diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index b030c46a80af..d6f32b85c802 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -1409,7 +1409,13 @@ it("should support multiple Set-Cookie headers", async () => { ); }); -describe("should support Content-Range with Bun.file()", () => { +// `new Response(Bun.file(f).slice(start, end))` is a plain 200 whose entity +// is the slice: Content-Length is the slice's length and there is no +// server-invented 206 / Content-Range for a request that sent no Range +// header (RFC 9110). Incoming Range headers are resolved natively against +// whole-file bodies instead (see bun-serve-file.test.ts). A user-supplied +// Content-Range header passes through untouched. +describe("should serve Bun.file().slice() as the entity", () => { // this must be a big file so we can test potentially multiple chunks // more than 65 KB const full = (function () { @@ -1466,8 +1472,11 @@ describe("should support Content-Range with Bun.file()", () => { const response = await fetch(`${server.url.origin}/?start=${start}&end=${end}`, { verbose: true, }); - expect(await response.arrayBuffer()).toEqual(full.buffer.slice(start, end)); - expect(response.status).toBe(start > 0 || end < full.byteLength ? 206 : 200); + const body = await response.arrayBuffer(); + expect(body).toEqual(full.buffer.slice(start, end)); + expect(response.headers.get("Content-Length")).toBe(String(body.byteLength)); + expect(response.headers.get("Content-Range")).toBeNull(); + expect(response.status).toBe(200); }); }); } @@ -1480,7 +1489,8 @@ describe("should support Content-Range with Bun.file()", () => { }); expect(parseInt(response.headers.get("Content-Range")?.split("/")[1])).toEqual(full.byteLength); expect(await response.arrayBuffer()).toEqual(full.buffer.slice(start, end)); - expect(response.status).toBe(start > 0 || end < full.byteLength ? 206 : 200); + // The user-set Content-Range does not change the user-set status. + expect(response.status).toBe(200); }); }); } @@ -1497,17 +1507,6 @@ describe("should support Content-Range with Bun.file()", () => { [full.byteLength - 1, full.byteLength - 1], ]; - for (const [start, end] of emptyRanges) { - it(`empty range: ${start} - ${end}`, async () => { - await getServer(async server => { - const response = await fetch(`${server.url.origin}/?start=${start}&end=${end}`); - const out = await response.arrayBuffer(); - expect(out).toEqual(new ArrayBuffer(0)); - expect(response.status).toBe(206); - }); - }); - } - const badRanges = [ [10, NaN], [10, -Infinity], @@ -1519,15 +1518,23 @@ describe("should support Content-Range with Bun.file()", () => { [full.byteLength + 100, -full.byteLength], ]; - for (const [start, end] of badRanges) { - it(`bad range: ${start} - ${end}`, async () => { - await getServer(async server => { - const response = await fetch(`${server.url.origin}/?start=${start}&end=${end}`); - const out = await response.arrayBuffer(); - expect(out).toEqual(new ArrayBuffer(0)); - expect(response.status).toBe(206); + // Every slice here normalizes to zero bytes; a 0-byte body with the + // default 200 status is promoted to 204, never a 0-byte 206. + for (const [kind, ranges] of [ + ["empty", emptyRanges], + ["bad", badRanges], + ] as const) { + for (const [start, end] of ranges) { + it(`${kind} range: ${start} - ${end}`, async () => { + await getServer(async server => { + const response = await fetch(`${server.url.origin}/?start=${start}&end=${end}`); + const out = await response.arrayBuffer(); + expect(out).toEqual(new ArrayBuffer(0)); + expect(response.headers.get("Content-Range")).toBeNull(); + expect(response.status).toBe(204); + }); }); - }); + } } }); diff --git a/test/js/web/structured-clone-blob-file.test.ts b/test/js/web/structured-clone-blob-file.test.ts index e3af33976557..5cf3a9ee5ef1 100644 --- a/test/js/web/structured-clone-blob-file.test.ts +++ b/test/js/web/structured-clone-blob-file.test.ts @@ -567,7 +567,7 @@ describe("structuredClone with Blob and File", () => { afterContentType + 2, // store_tag + bytes_len partially read afterBytes, // bytes + Store allocated, stored_name len read fails afterStoredName, // heap *Blob allocated, is_jsdom_file read fails - full.length - 1, // v3 File name read fails (last byte missing) + full.length - 1, // v4 size (u64) read fails (last byte missing) ]; const payloads = cuts.map(n => full.slice(0, n)); // All of these must hit the error path; if one accidentally succeeds From 31fe3f738c95bcbb5d22c62645a13f9bdc4aaf99 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 26 Jun 2026 23:37:41 +0000 Subject: [PATCH 3/4] Don't pin an unresolved file blob's size during serialization The version-4 blob wire format wrote self.size.get() after the serializer's resolve_size() call. For an fd-backed blob that pins the resolve result of the sending process into the clone, and an fd number is meaningless outside it: the cross-process structuredClone test got size 0 back because the intermediate process could not stat the fd. Capture the size before resolve_size() runs. A slice's concrete length still round-trips; an unresolved Bun.file(p) / Bun.file(fd) keeps the MAX_SIZE unknown-size sentinel on the wire so the receiver resolves it lazily against its own path or fd, exactly as before version 4. --- src/runtime/webcore/Blob.rs | 24 ++++++++++++------- .../js/web/structured-clone-blob-file.test.ts | 16 +++++++++++++ 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 8df8f0c33146..fdbbed3c60c8 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -114,7 +114,8 @@ pub type Ref = bun_ptr::ExternalShared; /// 3: Added File name serialization for File objects (when is_jsdom_file is true) /// 4: Added the blob's `size` (u64). `offset` alone cannot reconstruct a /// file-backed `.slice(start, end)`: without the length the clone widens -/// to the rest of the file. +/// to the rest of the file. `MAX_SIZE` on the wire means "unresolved": the +/// receiver resolves it lazily against its own path / fd. const SERIALIZATION_VERSION: u8 = 4; pub use bun_jsc::generated::JSBlob as js; @@ -729,6 +730,15 @@ impl BlobExt for Blob { } else { false }; + // Version 4: the blob's `size`, written at the very end. Capture it + // before the `resolve_size()` below mutates it: a slice's concrete + // length must survive the round-trip (`offset` alone widens the clone + // to the rest of the file), but an unresolved file blob must keep the + // `MAX_SIZE` unknown-size sentinel so the receiving side resolves it + // lazily against its own path / fd. Resolving here and pinning the + // result would serialize e.g. the 0 that `resolve_size()` reports for + // an fd that does not stat in *this* process. + let size = self.size.get(); writer.write_int_le::(SERIALIZATION_VERSION)?; writer.write_int_le::(if is_memory_backed { @@ -784,12 +794,7 @@ impl BlobExt for Blob { } } - // Version 4: the blob's `size`. For a memory-backed blob the payload - // above already is the `(offset, size)` view; for a file-backed one - // only the path and `offset` are serialized, and `resolve_size()` - // (called before `store.serialize` above) just clamped `size` to the - // file, so this is the slice's concrete length. - writer.write_int_le::(self.size.get())?; + writer.write_int_le::(size)?; Ok(()) } @@ -4334,7 +4339,10 @@ fn _on_structured_clone_deserialize>( // Version 4: the blob's `size`. Required to reconstruct a file-backed // `.slice(start, end)`: `offset` alone widens the view to the rest of - // the file. Version 3 payloads fall back to that older behavior. + // the file. `MAX_SIZE` means the sender never resolved it (an unsliced + // `Bun.file(p)` / `Bun.file(fd)`), so it stays lazy and resolves here + // against this process's own path / fd. Version 3 payloads fall back + // to the older (size-less, always lazy) behavior. blob.size.set(reader.read_int_le::()? as SizeType); if version == 4 { diff --git a/test/js/web/structured-clone-blob-file.test.ts b/test/js/web/structured-clone-blob-file.test.ts index 5cf3a9ee5ef1..6e50d9426c9d 100644 --- a/test/js/web/structured-clone-blob-file.test.ts +++ b/test/js/web/structured-clone-blob-file.test.ts @@ -171,6 +171,22 @@ describe("structuredClone with Blob and File", () => { expect(slice.size).toBe(100); expect(new Uint8Array(await slice.arrayBuffer())).toEqual(expected); }); + + // Only a slice's *concrete* length is carried on the wire. An unresolved + // `Bun.file(p)` keeps its unknown-size sentinel: the receiving side must + // resolve it lazily against its own path (or fd, which can refer to a + // different file or nothing at all in another process). Pinning the + // serialize-time stat would freeze a stale size into the clone. + test("serializing an unresolved Bun.file() does not pin its size", async () => { + using dir = tempDir("structured-clone-lazy-size", { "f.bin": Buffer.alloc(4, 1) }); + const p = path.join(String(dir), "f.bin"); + // The blob's size is never read before serializing, so it is unresolved. + const wire = serialize(Bun.file(p)); + await Bun.write(p, Buffer.alloc(10, 2)); + // The clone reflects the file as it is now, not as it was at serialize time. + expect(deserialize(wire).size).toBe(10); + expect(structuredClone(Bun.file(p)).size).toBe(10); + }); }); describe("File structured clone", () => { From 9be8e38280af028e6a4ef422fdabc987538150b5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 28 Jun 2026 02:51:03 +0000 Subject: [PATCH 4/4] Align the empty-slice assertions with the 200-on-empty framing from #32800 8f6a7c6866 (#32800) removed render_metadata's 200-to-204 rewrite for an empty body, so an empty Bun.file().slice() is now a plain 200 with Content-Length: 0 on both GET and HEAD. That also makes GET and HEAD fully agree for an empty slice, so the two separate empty-slice tests collapse into the GET/HEAD parity matrix. --- test/js/bun/http/bun-serve-file.test.ts | 17 +++-------------- test/js/bun/http/serve.test.ts | 7 ++++--- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/test/js/bun/http/bun-serve-file.test.ts b/test/js/bun/http/bun-serve-file.test.ts index 02de89005496..f5387334da01 100644 --- a/test/js/bun/http/bun-serve-file.test.ts +++ b/test/js/bun/http/bun-serve-file.test.ts @@ -778,13 +778,15 @@ describe("Bun.file in serve routes", () => { // RFC 9110: without a client Range header the response is a 200 whose // entity is the body the handler returned. A Bun.file().slice() body must // not leak its internal offset as a 206 + Content-Range, and HEAD (§9.3.2) - // must report the same status and Content-Length that GET would. + // must report the same status and Content-Length that GET would. An empty + // slice is a valid 0-byte entity: 200 with Content-Length: 0. describe.concurrent("Bun.file().slice() via fetch handler is the entity", () => { it.each([ ["/slice-handler", "56789"], ["/slice-zero-offset-handler", "0123"], ["/slice-open-handler", "56789ABCDEF"], ["/slice-past-eof-handler", "CDEF"], + ["/slice-empty-handler", ""], ])("GET and HEAD agree on %s", async (path, body) => { const expected = { status: 200, @@ -809,19 +811,6 @@ describe("Bun.file in serve routes", () => { contentRange: head.headers.get("content-range"), }).toEqual(expected); }); - - it("empty slice: GET is a 204 with no Content-Range", async () => { - const res = await fetch(new URL("/slice-empty-handler", server.url)); - expect(await res.text()).toBe(""); - expect(res.headers.get("content-range")).toBeNull(); - expect(res.status).toBe(204); - }); - - it("empty slice: HEAD reports Content-Length 0", async () => { - const res = await fetch(new URL("/slice-empty-handler", server.url), { method: "HEAD" }); - expect(res.headers.get("content-length")).toBe("0"); - expect(res.headers.get("content-range")).toBeNull(); - }); }); }); diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index d6f32b85c802..08511eddaf09 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -1518,8 +1518,8 @@ describe("should serve Bun.file().slice() as the entity", () => { [full.byteLength + 100, -full.byteLength], ]; - // Every slice here normalizes to zero bytes; a 0-byte body with the - // default 200 status is promoted to 204, never a 0-byte 206. + // Every slice here normalizes to zero bytes. A 0-byte slice is a valid + // 0-byte entity: a plain 200 with Content-Length: 0, never a 0-byte 206. for (const [kind, ranges] of [ ["empty", emptyRanges], ["bad", badRanges], @@ -1530,8 +1530,9 @@ describe("should serve Bun.file().slice() as the entity", () => { const response = await fetch(`${server.url.origin}/?start=${start}&end=${end}`); const out = await response.arrayBuffer(); expect(out).toEqual(new ArrayBuffer(0)); + expect(response.headers.get("Content-Length")).toBe("0"); expect(response.headers.get("Content-Range")).toBeNull(); - expect(response.status).toBe(204); + expect(response.status).toBe(200); }); }); }