diff --git a/src/runtime/server/FileRoute.rs b/src/runtime/server/FileRoute.rs index 3cc3f4d9b2f3..53b01ba02f4b 100644 --- a/src/runtime/server/FileRoute.rs +++ b/src/runtime/server/FileRoute.rs @@ -450,6 +450,27 @@ impl FileRoute { RangeRequest::Result::None }; + // RFC 9110 §13.1.5: If-Range makes resumption safe. When the client's + // validator no longer matches the representation we would serve, the + // Range header MUST be ignored so the client gets the full 200 body + // instead of a 206 that splices old and new bytes. + let range = if matches!(range, RangeRequest::Result::None) { + range + } else if let Some(if_range) = req.header(b"if-range") { + let etag = this.headers.get(b"etag"); + let last_modified = match this.last_modified_date() { + Ok(v) => v, + Err(_) => return, + }; + if RangeRequest::if_range_allows_range(if_range, etag, last_modified) { + range + } else { + RangeRequest::Result::None + } + } else { + range + }; + let status_code: u16 = 'brk: { // RFC 9110 §13.2.2: conditional preconditions are evaluated before // Range. If-None-Match is evaluated first; when present it suppresses diff --git a/src/runtime/server/RangeRequest.rs b/src/runtime/server/RangeRequest.rs index ed5a271df205..d517262aee20 100644 --- a/src/runtime/server/RangeRequest.rs +++ b/src/runtime/server/RangeRequest.rs @@ -115,6 +115,54 @@ pub fn parse(header: &[u8], total: u64) -> Result { parse_raw(header).resolve(total) } +/// RFC 9110 §13.1.5: decide whether a Range request carrying an `If-Range` +/// header may be served as a partial (206) response. Returns `true` when the +/// client's validator still matches the representation we would serve (so the +/// Range is honored), and `false` when it does not (so the caller must ignore +/// the Range and send the full 200 body instead). +/// +/// `etag` / `last_modified_ms` are the response's own current validators, each +/// `None` when absent. Strong comparison is required: a weak If-Range +/// entity-tag (`W/"..."`), a mismatch, a missing validator of the requested +/// type, or an unparsable value all return `false` (fail safe to full body). +pub fn if_range_allows_range( + if_range: &[u8], + etag: Option<&[u8]>, + last_modified_ms: Option, +) -> bool { + let v = strings::trim(if_range, b" \t"); + // Only reachable via a whitespace-only header (uWS maps a zero-length + // header to "absent"). Empty is neither a valid entity-tag nor an + // HTTP-date, so fail closed like every other unparsable case. + if v.is_empty() { + return false; + } + + // Entity-tag form: an opaque-quoted tag, optionally weak-prefixed. + if v.first() == Some(&b'"') || v.starts_with(b"W/") { + // A weak validator MUST NOT be used for If-Range. + if v.starts_with(b"W/") { + return false; + } + let Some(etag) = etag else { + return false; + }; + let etag = strings::trim(etag, b" \t"); + if etag.is_empty() || etag.starts_with(b"W/") { + return false; + } + return etag == v; + } + + // HTTP-date form: exact match against Last-Modified. The header we emit is + // second-granular, so compare at second precision (matching the + // If-Modified-Since comparison in FileRoute). + let (Some(lm), Some(d)) = (last_modified_ms, crate::jsc_hooks::parse_http_date(v)) else { + return false; + }; + lm / 1000 == d / 1000 +} + // `bun_uws::AnyRequest::header` borrows `&self` and returns `&[u8]` tied to // it, so take `&AnyRequest` here. pub(crate) fn from_request(req: &AnyRequest, total: u64) -> Result { diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 43a8b24f750a..3c5d8c279f42 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -176,6 +176,11 @@ pub struct RequestContext< pub sendfile: SendfileContext, pub range: RangeRequest::Raw, + /// Raw `If-Range` request header, captured at construction because the uWS + /// request is gone by the time the response (and its validators) is known. + /// RFC 9110 §13.1.5: evaluated against the response's own validator in + /// `do_sendfile` to decide whether the Range may be honored. + pub if_range: Option>, pub request_body_readable_stream_ref: readable_stream::Strong, /// Owning `+1` handle into the per-VM `Body::Value` hive pool. Shared with @@ -889,6 +894,7 @@ where self.request_body_buf = Vec::new(); self.response_buf_owned = Vec::new(); + self.if_range = None; self.response_weakref.deref(); self.request_body_take_unref(); @@ -1347,6 +1353,9 @@ where server: NonNull::new(server.cast_mut()).map(bun_ptr::BackRef::from), defer_deinit_until_callback_completes: should_deinit_context, range: RangeRequest::raw_from_request(&Self::any_request(req)), + if_range: Self::any_request(req) + .header(b"if-range") + .map(|h| h.to_vec().into_boxed_slice()), request_weakref: request::WeakRef::EMPTY, signal: None, cookies: None, @@ -1727,6 +1736,39 @@ where true } + /// RFC 9110 §13.1.5: when the request carried an `If-Range` header, honor + /// the Range only if the client's validator still matches the response's + /// own validator (`ETag` or `Last-Modified`). A missing/mismatched/weak + /// validator returns `false`, so the caller ignores the Range and serves + /// the full 200 body. Returns `true` when there is no `If-Range` to check. + fn if_range_permits_partial(&mut self) -> bool { + let Some(if_range) = self.if_range.clone() else { + return true; + }; + let (etag, last_modified) = match self.response_weakref.get() { + Some(response) => match response.get_init_headers_mut() { + Some(headers) => ( + headers + .fast_get(jsc::HTTPHeaderName::ETag) + .map(|s| s.to_slice_clone()), + headers + .fast_get(jsc::HTTPHeaderName::LastModified) + .map(|s| s.to_slice_clone()), + ), + None => (None, None), + }, + None => (None, None), + }; + let last_modified_ms = last_modified + .as_ref() + .and_then(|s| crate::jsc_hooks::parse_http_date(s.slice())); + RangeRequest::if_range_allows_range( + &if_range, + etag.as_ref().map(|s| s.slice()), + last_modified_ms, + ) + } + pub fn do_sendfile(&mut self, blob: Blob) { if self.is_aborted_or_ended() { return; @@ -1887,6 +1929,7 @@ where && !user_handles_range && is_whole_file && self.range != RangeRequest::Raw::None + && self.if_range_permits_partial() { match self.range.resolve(stat_size) { RangeRequest::Result::None => {} diff --git a/test/js/bun/http/bun-serve-file.test.ts b/test/js/bun/http/bun-serve-file.test.ts index 6e18e4644cad..dc158d1115cd 100644 --- a/test/js/bun/http/bun-serve-file.test.ts +++ b/test/js/bun/http/bun-serve-file.test.ts @@ -2,7 +2,7 @@ import type { Server } from "bun"; import { afterAll, beforeAll, describe, expect, it, mock, test } from "bun:test"; import { bunEnv, bunExe, isASAN, isWindows, rmScope, tempDir, tempDirWithFiles } from "harness"; import { mkfifo } from "mkfifo"; -import { unlinkSync } from "node:fs"; +import { unlinkSync, utimesSync, writeFileSync } from "node:fs"; import { join } from "node:path"; const LARGE_SIZE = 1024 * 1024 * 8; @@ -1138,3 +1138,101 @@ console.log("OK"); }, 60_000, ); + +// RFC 9110 §13.1.5: a Range request carrying If-Range must only be served as +// 206 when the client's validator still matches the current representation. +// A stale/mismatched/weak validator must make the server ignore Range and +// return the full 200 body, so resuming clients never splice old+new bytes. +describe.concurrent("If-Range", () => { + it("FileRoute: stale Last-Modified makes a resumed Range return the full 200 body", async () => { + using dir = tempDir("serve-if-range-filroute", { "asset.bin": Buffer.alloc(400, "A") }); + const filePath = join(String(dir), "asset.bin"); + utimesSync(filePath, new Date("2020-01-02T03:04:05Z"), new Date("2020-01-02T03:04:05Z")); + + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + routes: { "/f": new Response(Bun.file(filePath)) }, + fetch: () => new Response("unused", { status: 404 }), + }); + + // The validator Bun itself advertised for version A. + const first = await fetch(new URL("/f", server.url)); + const lastModifiedA = first.headers.get("last-modified"); + expect(lastModifiedA).toBeTruthy(); + await first.arrayBuffer(); + + // A matching validator is resume-safe: honor the Range. + const fresh = await fetch(new URL("/f", server.url), { + headers: { Range: "bytes=200-399", "If-Range": lastModifiedA! }, + }); + expect(fresh.status).toBe(206); + expect(fresh.headers.get("content-range")).toBe("bytes 200-399/400"); + + // The file changes on disk, so A's Last-Modified is now stale. + writeFileSync(filePath, Buffer.alloc(400, "B")); + utimesSync(filePath, new Date("2021-06-07T08:09:10Z"), new Date("2021-06-07T08:09:10Z")); + + const resumed = await fetch(new URL("/f", server.url), { + headers: { Range: "bytes=200-399", "If-Range": lastModifiedA! }, + }); + // Stale validator: Range ignored, full new body returned. + expect(resumed.status).toBe(200); + expect(resumed.headers.get("content-range")).toBeNull(); + const body = Buffer.from(await resumed.arrayBuffer()); + expect(body.length).toBe(400); + expect(body.every(c => c === 0x42 /* "B" */)).toBe(true); + }); + + it.each([ + ["matching strong ETag → 206", '"etag-A"', 206], + ["mismatched ETag → 200", '"some-other-etag"', 200], + ["weak ETag never matches → 200", 'W/"etag-A"', 200], + ])("fetch handler: %s", async (_label, ifRange, expectedStatus) => { + using dir = tempDir("serve-if-range-handler", { "asset.bin": Buffer.alloc(400, "A") }); + const filePath = join(String(dir), "asset.bin"); + + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: () => new Response(Bun.file(filePath), { headers: { etag: '"etag-A"' } }), + }); + + const res = await fetch(server.url, { + headers: { Range: "bytes=200-399", "If-Range": ifRange }, + }); + expect(res.status).toBe(expectedStatus); + if (expectedStatus === 206) { + expect(res.headers.get("content-range")).toBe("bytes 200-399/400"); + } else { + expect(res.headers.get("content-range")).toBeNull(); + expect((await res.bytes()).length).toBe(400); + } + }); + + it.each([ + ["matching Last-Modified → 206", "Wed, 21 Oct 2015 07:28:00 GMT", 206], + ["stale Last-Modified → 200", "Tue, 15 Nov 1994 08:12:31 GMT", 200], + ])("fetch handler Last-Modified: %s", async (_label, ifRange, expectedStatus) => { + using dir = tempDir("serve-if-range-handler-lm", { "asset.bin": Buffer.alloc(400, "A") }); + const filePath = join(String(dir), "asset.bin"); + const lastModified = "Wed, 21 Oct 2015 07:28:00 GMT"; + + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: () => new Response(Bun.file(filePath), { headers: { "last-modified": lastModified } }), + }); + + const res = await fetch(server.url, { + headers: { Range: "bytes=200-399", "If-Range": ifRange }, + }); + expect(res.status).toBe(expectedStatus); + if (expectedStatus === 206) { + expect(res.headers.get("content-range")).toBe("bytes 200-399/400"); + } else { + expect(res.headers.get("content-range")).toBeNull(); + expect((await res.bytes()).length).toBe(400); + } + }); +});