Fix Bun.file().slice() being treated as the rest of the file

Draft
RO
2mo ago
+262-126

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

  1. Blob::resolve_size() / resolved_size() (Blob.rs): the File arm does size = max_size - offset, overwriting a slice's concrete length. The Bytes arm directly above it already has the guard, with a comment explaining why. Every unconditional resolve_size() caller is affected:
    • do_render_head_response: HEAD's Content-Length is 900 (fileSize - offset).
    • Body::to_readable_stream: new Response(Bun.file(p).slice(100, 200)).body streams 900 bytes, reading past the end of the slice.
    • the structured-clone serializer: serializing a file slice mutates the source blob's .size to 900.
  2. Blob structured-clone wire format: only the file path and offset are serialized, never the size, so a deserialized Bun.file(p).slice(100, 200) widens to 900 bytes.
  3. RequestContext::do_sendfile: b.size.set(stat_size) overwrites the slice blob's size with the whole file's. That is what makes render_metadata believe 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: extract clamp_view_to_store() (the logic the Bytes arm already had) and use it in both arms of resolve_size and resolved_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 size and bump the format version from 3 to 4. Only a slice's concrete length is pinned; an unresolved Bun.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 clamped sendfile.remain, the byte count actually sent, so the slice is the HTTP entity. Content-Length is the slice's length and render_metadata frames it as a plain 200. An empty slice is a plain 200 with Content-Length: 0, matching 8f6a7c6866 (#32800).
  • render_metadata: with the blob size resolved correctly, needs_content_range is only ever set by a resolved incoming Range header, so the remain < blob.size() and has_content_range compensations are dead. Removed, along with the now-redundant flag setter in do_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 Range header. Unsolicited 206es poison caches, and any HEAD-then-GET client sees two contradictory descriptions of the same resource.
  • It composes incorrectly with the native Range header support. On main, new Response(Bun.file(p).slice(100, 200)) plus Range: bytes=0-9 returns 206 Content-Range: bytes 100-199/* with 100 bytes: a Content-Range for a different range than the client asked for.
  • The documented use (parse the Range header yourself and return a slice) is superseded: new Response(Bun.file(p)) already resolves the client's Range header 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-escape assertion.
  • test/js/bun/http/serve.test.ts: re-asserts the existing slice suite for the new framing and adds the Content-Length and absent-Content-Range checks it never had.
  • test/js/web/fetch/blob.test.ts: Response(slice).body streams 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.