Fix Bun.file().slice() being treated as the rest of the file
Found by a fuzzer that byte-checks Bun.serve file responses. A Bun.file(p).slice(a, b) body is repeatedly treated as a..end-of-file instead of a..b. There are four manifestations with one shared root: a file slice's concrete size gets overwritten with a value derived from the file size and the offset.
Repro
const p = "/tmp/f.bin";
require("node:fs").writeFileSync(p, Buffer.alloc(1000, 7)); // 1000-byte file
using server = Bun.serve({ port: 0, fetch: () => new Response(Bun.file(p).slice(100, 200)) });
const get = await fetch(`http://127.0.0.1:${server.port}/`); await get.arrayBuffer();
const head = await fetch(`http://127.0.0.1:${server.port}/`, { method: "HEAD" });
console.log("GET ", get.status, get.headers.get("content-length"), get.headers.get("content-range"));
console.log("HEAD", head.status, head.headers.get("content-length"), head.headers.get("content-range"));
On 1.4.0 and current main, the request sent no Range header, yet:
GET 206 content-length=100 content-range=bytes 100-199/*
HEAD 200 content-length=900 content-range=null
Causes
Blob::resolve_size()/resolved_size()(Blob.rs): theFilearm doessize = max_size - offset, overwriting a slice's concrete length. TheBytesarm directly above it already has the guard, with a comment explaining why. Every unconditionalresolve_size()caller is affected:do_render_head_response: HEAD'sContent-Lengthis 900 (fileSize - offset).Body::to_readable_stream:new Response(Bun.file(p).slice(100, 200)).bodystreams 900 bytes, reading past the end of the slice.- the structured-clone serializer: serializing a file slice mutates the source blob's
.sizeto 900.
- Blob structured-clone wire format: only the file path and
offsetare serialized, never thesize, so a deserializedBun.file(p).slice(100, 200)widens to 900 bytes. RequestContext::do_sendfile:b.size.set(stat_size)overwrites the slice blob's size with the whole file's. That is what makesrender_metadatabelieve the body is a partial file and invent the 206.
The static route path (routes: { "/x": new Response(Bun.file(p).slice(a, b)) }) already treated the slice as the entity; only the fetch handler path was wrong.
Fix
Blob.rs: extractclamp_view_to_store()(the logic theBytesarm already had) and use it in both arms ofresolve_sizeandresolved_size. A concrete slice size is authoritative; only the unknown-size sentinel resolves to the remainder of the store.- Blob structured-clone serialization: append the blob's
sizeand bump the format version from 3 to 4. Only a slice's concrete length is pinned; an unresolvedBun.file(p)/Bun.file(fd)keeps the unknown-size sentinel on the wire so the receiver resolves it lazily against its own path or fd (an fd number is meaningless in another process). Version 3 payloads still deserialize. RequestContext::do_sendfile: resolve the blob's size to the clampedsendfile.remain, the byte count actually sent, so the slice is the HTTP entity.Content-Lengthis the slice's length andrender_metadataframes it as a plain 200. An empty slice is a plain200withContent-Length: 0, matching 8f6a7c6866 (#32800).render_metadata: with the blob size resolved correctly,needs_content_rangeis only ever set by a resolved incomingRangeheader, so theremain < blob.size()andhas_content_rangecompensations are dead. Removed, along with the now-redundant flag setter indo_sendfile.
Rebased onto #32800
8f6a7c6866 (#32800) landed on main and fixes the companion framing class from the same fuzzer run (the HEAD / 204 / empty-body paths each derived framing independently of GET). This PR fixes the fourth divergence it mentions (Bun.file(p).slice(a, b) treated as a..EOF); the two are independent root causes but both edit render_metadata, so this branch is rebased onto it. The one conflict was the 200 -> 204 rewrite for an empty body: #32800 deliberately removes it (an empty body is a valid zero-byte 200 entity), so this branch keeps that removal, and the empty-slice tests here assert 200 with Content-Length: 0 (which #32800 also makes HEAD agree on).
Behavior change, needs a maintainer call
new Response(Bun.file(f).slice(a, b)) with no client Range header used to be a 206 Partial Content with Content-Range: bytes a-(b-1)/*. That was documented (docs/runtime/http/routing.mdx, introduced in 0617896d70) and covered by serve.test.ts's should support Content-Range with Bun.file() suite; both are updated here. Reasons to drop it:
- RFC 9110: a server must not return 206 to a request that sent no
Rangeheader. Unsolicited 206es poison caches, and any HEAD-then-GET client sees two contradictory descriptions of the same resource. - It composes incorrectly with the native
Rangeheader support. Onmain,new Response(Bun.file(p).slice(100, 200))plusRange: bytes=0-9returns206 Content-Range: bytes 100-199/*with 100 bytes: aContent-Rangefor a different range than the client asked for. - The documented use (parse the
Rangeheader yourself and return a slice) is superseded:new Response(Bun.file(p))already resolves the client'sRangeheader natively and responds with a correct 206.
This is the only part of the PR that changes documented behavior; the other fixes are unambiguous. Happy to split it out if preferred.
Tests
test/js/bun/http/bun-serve-file.test.ts: a GET/HEAD parity matrix for slices at offset 0, a nonzero offset, open-ended, past EOF, and empty. Also strengthens the existing/slice-escapeassertion.test/js/bun/http/serve.test.ts: re-asserts the existing slice suite for the new framing and adds theContent-Lengthand absent-Content-Rangechecks it never had.test/js/web/fetch/blob.test.ts:Response(slice).bodystreams exactly the slice's bytes.test/js/web/structured-clone-blob-file.test.ts: a file-backed slice round-trips its offset and length (the file sibling of the existing memory-blob test).
All of the new and re-asserted tests fail without the src/ changes and pass with them.