Skip to content
Open
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
61 changes: 61 additions & 0 deletions src/runtime/server/FileRoute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,11 @@ impl FileRoute {
return;
};

if route.not_modified_from_stat(path, &mut req, resp, method) {
route.on_response_complete(resp);
return;
}

let open_flags = bun_sys::O::RDONLY | bun_sys::O::CLOEXEC | bun_sys::O::NONBLOCK;

let fd_result: bun_sys::Result<Fd> = {
Expand Down Expand Up @@ -317,6 +322,62 @@ impl FileRoute {
}
}

/// A 304 needs the file's metadata, never its contents. When the route was
/// given an ETag and the request's `If-None-Match` matches it, one `stat`
/// answers what would otherwise cost open + fstat + close. Anything less
/// clear-cut (no match, `If-Match`, `If-Unmodified-Since`, a missing file,
/// a directory) returns `false` and takes the regular path.
fn not_modified_from_stat(
&self,
path: &[u8],
req: &mut AnyRequest,
resp: AnyResponse,
method: Method,
) -> bool {
if !(method == Method::GET || method == Method::HEAD) || self.status_code != 200 {
return false;
}
let Some(etag) = self.headers.get(b"etag").filter(|v| !v.is_empty()) else {
return false;
};
let Some(inm) = req.header(b"if-none-match").filter(|v| !v.is_empty()) else {
return false;
};
if req.header(b"if-match").is_some()
|| req.header(b"if-unmodified-since").is_some()
|| !ETag::if_none_match(etag, inm)
{
return false;
}

let mut path_buffer = bun_paths::path_buffer_pool::get();
if path.len() >= path_buffer.len() {
return false;
}
path_buffer[..path.len()].copy_from_slice(path);
path_buffer[path.len()] = 0;
let Ok(stat) = bun_sys::stat(bun_core::ZStr::from_buf(&path_buffer[..], path.len())) else {
return false;
};
if bun_sys::S::ISDIR(stat.st_mode as bun_sys::Mode) {
return false;
}

let mut sh = self.stat_hash.take();
sh.hash(&stat, path);
self.stat_hash.set(sh);

req.set_yield(false);
write_any_status(resp, 304);
if self.has_date_header {
resp.mark_wrote_date_header();
}
resp.write_mark();
self.write_headers(resp);
resp.end_without_body(resp.should_close_connection());
true
}

fn serve(
&self,
fd: Fd,
Expand Down
47 changes: 46 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, isLinux, isWindows, rmScope, rss, tempDir, tempDirWithFiles } from "harness";
import { mkfifo } from "mkfifo";
import { closeSync, openSync, unlinkSync, writeSync } from "node:fs";
import { chmodSync, closeSync, openSync, unlinkSync, writeSync } from "node:fs";
import { join } from "node:path";

const LARGE_SIZE = 1024 * 1024 * 8;
Expand Down Expand Up @@ -1755,3 +1755,48 @@ test.skipIf(!isLinux)("sendfile serves an intact >=1MB file over a unix socket l
expect(body.length).toBe(data.length);
expect(body.compare(data)).toBe(0);
});

// A 304 needs the file's metadata, never its contents: a matching If-None-Match
// against a user-set ETag is answered from one stat(), without opening the file.
test.skipIf(isWindows || process.getuid?.() === 0)(
"file route answers a matching If-None-Match without opening the file",
async () => {
const dir = tempDirWithFiles("file-route-304-stat", { "unreadable.txt": "secret", "gone.txt": "gone" });
const headers = { ETag: '"v1"' };
await using server = Bun.serve({
port: 0,
routes: {
"/unreadable.txt": new Response(Bun.file(join(dir, "unreadable.txt")), { headers }),
"/gone.txt": new Response(Bun.file(join(dir, "gone.txt")), { headers }),
},
fetch: () => new Response("fallback", { status: 404 }),
});
chmodSync(join(dir, "unreadable.txt"), 0o000);
unlinkSync(join(dir, "gone.txt"));

const match = { headers: { "If-None-Match": '"v1"' } };

// open() would fail with EACCES and fall through; stat() does not need read access
const not_modified = await fetch(new URL("/unreadable.txt", server.url), match);
expect(not_modified.status).toBe(304);
expect(not_modified.headers.get("ETag")).toBe('"v1"');
expect(not_modified.headers.get("Last-Modified")).not.toBeNull();
expect(await not_modified.text()).toBe("");

const head = await fetch(new URL("/unreadable.txt", server.url), { method: "HEAD", ...match });
expect(head.status).toBe(304);

// anything the ETag alone cannot decide still takes the regular path
const stale = await fetch(new URL("/unreadable.txt", server.url), { headers: { "If-None-Match": '"v0"' } });
expect(await stale.text()).toBe("fallback");
const if_match = await fetch(new URL("/unreadable.txt", server.url), {
headers: { "If-None-Match": '"v1"', "If-Match": '"v0"' },
});
expect(await if_match.text()).toBe("fallback");

// a missing file never validates a cached copy
const gone = await fetch(new URL("/gone.txt", server.url), match);
expect(gone.status).toBe(404);
expect(await gone.text()).toBe("fallback");
},
);