Skip to content
Open
45 changes: 38 additions & 7 deletions src/runtime/server/RequestContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1662,6 +1662,19 @@ where
true
}

/// Default HTTP status when a `Bun.file()` response body can't be opened.
fn sendfile_errno_status(&self, errno: bun_sys::E) -> u16 {
// After error() has run we're serving its file; that failing is a handler fault.
if self.flags.has_called_error_handler() {
return 500;
}
match errno {
bun_sys::E::ENOENT | bun_sys::E::ENOTDIR | bun_sys::E::EISDIR => 404,
bun_sys::E::EACCES | bun_sys::E::EPERM => 403,
_ => 500,
}
}

pub(crate) fn do_sendfile(&mut self, blob: Blob) {
if self.is_aborted_or_ended() {
return;
Expand Down Expand Up @@ -1693,10 +1706,11 @@ where
) {
bun_sys::Result::Ok(fd_) => fd_,
bun_sys::Result::Err(err) => {
let status = self.sendfile_errno_status(err.get_errno());
let js_err = err
.with_path(file.pathlike.path().slice())
.to_js(global_this);
return self.run_error_handler(js_err);
return self.run_error_handler_with_status_code(js_err, status);
}
}
};
Expand Down Expand Up @@ -1750,7 +1764,11 @@ where
let mut sys: jsc::SystemError = err.to_system_error().into();
sys.message =
BunString::static_("Cannot stream a directory as a response body").into();
return self.run_error_handler(sys.to_error_instance(global_this));
let status = self.sendfile_errno_status(bun_sys::E::EISDIR);
return self.run_error_handler_with_status_code(
sys.to_error_instance(global_this),
status,
);
}
(bun_io::FileType::File, false)
};
Expand Down Expand Up @@ -3474,6 +3492,13 @@ where
}
self.end_without_body(self.should_close_connection());
}
403 => {
if !self.flags.has_written_status() {
resp.write_status(b"403 Forbidden");
self.flags.set_has_written_status(true);
}
self.end_without_body(self.should_close_connection());
}
_ => {
const BODY: &[u8] = b"Something went wrong!";
if !self.flags.has_written_status() {
Expand Down Expand Up @@ -3542,6 +3567,12 @@ where
// `ServerLike::vm()` is the process-static VM `BackRef`; `as_mut()` is
// the single audited `&mut VirtualMachine` accessor.
let vm = server.vm().as_mut();
// 404/403 are expected outcomes (missing/unreadable Bun.file): no report, no dev page.
if matches!(status, 403 | 404) {
self.render_production_error(status);
vm.log_mut().unwrap().reset();
return;
}
Comment thread
robobun marked this conversation as resolved.
if DEBUG_MODE {
let mut exception_list_upstream: jsc::ExceptionList = Vec::new();
let prev_exception_list = vm.on_unhandled_rejection_exception_list;
Expand Down Expand Up @@ -3569,9 +3600,7 @@ where
log.reset();
return;
}
if status != 404 {
(vm.on_unhandled_rejection)(vm, global_this, value);
}
(vm.on_unhandled_rejection)(vm, global_this, value);
self.render_production_error(status);
vm.log_mut().unwrap().reset();
}
Expand All @@ -3598,7 +3627,8 @@ where
let _keep = jsc::EnsureStillAlive(result);
if !result.is_empty_or_undefined_or_null() {
if let Some(err) = result.to_error() {
self.finish_running_error_handler(err, status);
// error() itself threw: a handler fault, not `status`.
self.finish_running_error_handler(err, 500);
return;
} else if let Some(promise) = result.as_any_promise() {
Self::process_on_error_promise(self, result, promise, value, status);
Expand Down Expand Up @@ -3694,7 +3724,8 @@ where
return;
}
jsc::PromiseResult::Rejected(err) => {
ctx.finish_running_error_handler(err, status);
// error() rejected: a handler fault, not `status`.
ctx.finish_running_error_handler(err, 500);
return;
}
}
Expand Down
168 changes: 167 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, rss, tempDir, tempDirWithFiles } from "harness";
import { mkfifo } from "mkfifo";
import { unlinkSync } from "node:fs";
import { chmodSync, unlinkSync } from "node:fs";
import { join } from "node:path";

const LARGE_SIZE = 1024 * 1024 * 8;
Expand Down Expand Up @@ -1279,3 +1279,169 @@ test("file route serves a burst of concurrent requests after reloads", async ()
const a = await fetch(`${server.url}a`).then(r => r.text());
expect(a).toBe("a-new");
});

// https://github.com/oven-sh/bun/issues/4292
// A Response(Bun.file(path)) returned from the fetch handler for a path that
// can't be opened should map the open() errno to an HTTP status (404/403)
// rather than treating it as a handler fault (500 + error page).
describe.each([true, false])("Response(Bun.file()) open-error status mapping (development: %p)", development => {
const isRoot = !isWindows && process.getuid?.() === 0;

let server: Server;
let dir: string;
let errorsSeen: unknown[];

beforeAll(() => {
dir = tempDirWithFiles("serve-file-open-errno", {
"ok.txt": "ok",
"noperm.txt": "secret",
"sub/.keep": "",
});
if (!isWindows) chmodSync(join(dir, "noperm.txt"), 0o000);
errorsSeen = [];
server = Bun.serve({
port: 0,
development,
fetch: req => new Response(Bun.file(join(dir, new URL(req.url).pathname.slice(1)))),
error: err => {
errorsSeen.push(err);
return undefined as any;
},
});
server.unref();
});

afterAll(() => {
if (!isWindows) chmodSync(join(dir, "noperm.txt"), 0o644);
server?.stop(true);
using _ = rmScope(dir);
});

it("existing file -> 200", async () => {
const res = await fetch(new URL("/ok.txt", server.url));
expect({ status: res.status, body: await res.text() }).toEqual({ status: 200, body: "ok" });
});

it("ENOENT -> 404", async () => {
const before = errorsSeen.length;
const res = await fetch(new URL("/missing.txt", server.url));
expect({ status: res.status, statusText: res.statusText }).toEqual({ status: 404, statusText: "Not Found" });
// The error handler must still be invoked with the ENOENT error so
// users can customize the response.
expect(errorsSeen.length).toBe(before + 1);
expect((errorsSeen.at(-1) as any)?.code).toBe("ENOENT");
});

it("ENOTDIR (path component is a file) -> 404", async () => {
const res = await fetch(new URL("/ok.txt/whatever", server.url));
expect(res.status).toBe(404);
});

// On POSIX open() on a directory succeeds, so EISDIR is reported from the
// post-open fstat branch. Windows fails the open itself (EACCES -> 403).
it.skipIf(isWindows)("EISDIR (path is a directory) -> 404", async () => {
const before = errorsSeen.length;
const res = await fetch(new URL("/sub", server.url));
expect(res.status).toBe(404);
expect(errorsSeen.length).toBe(before + 1);
expect((errorsSeen.at(-1) as any)?.code).toBe("EISDIR");
});

it.skipIf(isWindows || isRoot)("EACCES -> 403", async () => {
const before = errorsSeen.length;
const res = await fetch(new URL("/noperm.txt", server.url));
expect({ status: res.status, statusText: res.statusText }).toEqual({ status: 403, statusText: "Forbidden" });
expect(errorsSeen.length).toBe(before + 1);
expect((errorsSeen.at(-1) as any)?.code).toBe("EACCES");
});

it("error handler can override the 404 with its own Response", async () => {
await using s = Bun.serve({
port: 0,
development,
fetch: () => new Response(Bun.file(join(dir, "missing.txt"))),
error: err => new Response(`custom ${(err as any).code}`, { status: 410 }),
});
const res = await fetch(s.url);
expect({ status: res.status, body: await res.text() }).toEqual({ status: 410, body: "custom ENOENT" });
});

it("error handler returning an already-fulfilled Promise<undefined> falls through to 404", async () => {
await using s = Bun.serve({
port: 0,
development,
fetch: () => new Response(Bun.file(join(dir, "missing.txt"))),
error: () => Promise.resolve(undefined as any),
});
const res = await fetch(s.url);
expect(res.status).toBe(404);
});
});

// An `error()` handler that itself fails while handling a Bun.file ENOENT is
// a handler fault, not an "expected 404": it must still be reported as an
// unhandled error and surface as 500. Covers sync throw, async reject, and an
// error handler whose own Bun.file body can't be opened (custom-404-page case).
test.concurrent.each([
["sync throw", `error: () => { throw new Error("boom in error handler") },`, "boom in error handler"],
["Promise.reject", `error: () => Promise.reject(new Error("boom in error handler")),`, "boom in error handler"],
[
"Bun.file body missing",
`error: () => new Response(Bun.file(dir + "/404-also-missing.html")),`,
"404-also-missing.html",
],
])("Response(Bun.file(missing)): faulting error handler is reported (%s)", async (_label, errorClause, stderrHas) => {
using dir = tempDir("serve-file-enoent-handler-fault", { "ok.txt": "ok" });
const fixture = `
const dir = ${JSON.stringify(String(dir))};
const server = Bun.serve({
port: 0,
development: false,
fetch: () => new Response(Bun.file(dir + "/missing.txt")),
${errorClause}
});
const res = await fetch(server.url);
console.log(res.status);
server.stop(true);
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", fixture],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout.trim()).toBe("500");
expect(stderr).toContain(stderrHas);
expect(exitCode).toBe(1);
});

// Same mapping without a user-provided `error` handler: the 404 must land
// without being reported as an unhandled error on stderr, in dev and prod.
test.concurrent.each([true, false])(
"Response(Bun.file(missing)) -> 404, no unhandled error (development: %p)",
async development => {
using dir = tempDir("serve-file-enoent-quiet", { "ok.txt": "ok" });
const fixture = `
const dir = ${JSON.stringify(String(dir))};
const server = Bun.serve({
port: 0,
development: ${development},
fetch: () => new Response(Bun.file(dir + "/missing.txt")),
});
const res = await fetch(server.url);
console.log(res.status, JSON.stringify(res.statusText));
server.stop(true);
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", fixture],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout.trim()).toBe('404 "Not Found"');
expect(stderr).toBe("");
expect(exitCode).toBe(0);
},
);
Loading