Skip to content
Closed
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
21 changes: 21 additions & 0 deletions src/runtime/server/FileRoute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions src/runtime/server/RangeRequest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64>,
) -> 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 {
Expand Down
43 changes: 43 additions & 0 deletions src/runtime/server/RequestContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Box<[u8]>>,

pub request_body_readable_stream_ref: readable_stream::Strong,
/// Owning `+1` handle into the per-VM `Body::Value` hive pool. Shared with
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 => {}
Expand Down
100 changes: 99 additions & 1 deletion test/js/bun/http/bun-serve-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
});
});
Loading