From 1ee738b4b8d7371dc759d78c78bfe83f00c97b98 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:30:54 +0000 Subject: [PATCH 1/5] worker: copy argv/execArgv into worker-local StringImpls When a Worker is created with an explicit argv or execArgv option, the strings are stored in the parent thread's WorkerOptions vector and a raw pointer to that storage is handed to the Rust WebWorker. When the worker thread later builds process.argv/process.execArgv, it wrapped each parent-owned StringImpl* directly in a BunString and handed it to JSC. That lets JSC on the worker thread take further refs on a StringImpl that is not thread-safe, which on Windows release reliably crashed with STATUS_STACK_BUFFER_OVERRUN once a second worker loading node:worker_threads was created before the first was GC'd. Copy each entry's bytes into a fresh worker-local StringImpl instead, mirroring the isolatedCopy() the C++ side already does for options.name in createNodeWorkerThreadsBinding. This showed up in CI as the parallel test batch reporting an unrelated test file (most often test/regression/issue/20875.test.ts on Windows aarch64) as 'worker crashed: exit code 9', because the crashing worker process happened to have that file inflight when the panic fired. --- src/runtime/node/node_process.rs | 47 +++++++++++++++++-- .../worker-argv-cross-thread-fixture.test.ts | 29 ++++++++++++ test/js/web/workers/worker.test.ts | 18 +++++++ 3 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 test/js/web/workers/worker-argv-cross-thread-fixture.test.ts diff --git a/src/runtime/node/node_process.rs b/src/runtime/node/node_process.rs index 809260ad8c9f..e71ff4e548d1 100644 --- a/src/runtime/node/node_process.rs +++ b/src/runtime/node/node_process.rs @@ -198,8 +198,26 @@ mod _impl { if let Some(worker) = vm.worker_ref() { // was explicitly overridden for the worker? if let Some(exec_argv) = worker.exec_argv() { + // The exec_argv slice borrows `StringImpl*` owned by the + // parent-thread `WorkerOptions::execArgv` vector. Handing one to + // `BunString::init` and then `to_js` would `String(impl)`-ref it + // from this worker thread and let JSC take further refs on it + // (atomization, rope resolution), which is a cross-thread hazard + // on a StringImpl that is not thread-safe. Copy the bytes into a + // worker-local impl instead, same as the C++ side does for + // `options.name.isolatedCopy()` in createNodeWorkerThreadsBinding. return JSValue::create_array_from_iter(global_object, exec_argv.iter(), |&wtf| { - BunString::init(wtf).to_js(global_object) + // SAFETY: non-null entries borrow live storage in the + // parent `WorkerOptions` (see `WebWorker::exec_argv`). + let impl_ = unsafe { &*wtf }; + let s = if impl_.is_8bit() { + BunString::clone_latin1(impl_.latin1_slice()) + } else { + BunString::clone_utf16(impl_.utf16_slice()) + }; + let r = s.to_js(global_object); + s.deref(); + r }); } } @@ -332,7 +350,16 @@ mod _impl { // argv omits "bun" because it could be "bun run" or "bun" and it's kind of ambiguous // argv also omits the script name - let mut args_list: Vec = Vec::with_capacity(args_count + 2); + // `deref` on every element on scope exit: a no-op for ZigString/Static + // tags, and releases the +1 held by `clone_*` in the worker branch. + let mut args_list = scopeguard::guard( + Vec::::with_capacity(args_count + 2), + |v| { + for a in &v { + a.deref(); + } + }, + ); if vm.standalone_module_graph.is_some() { // Don't break user's code because they did process.argv.slice(2) @@ -369,8 +396,22 @@ mod _impl { } if let Some(worker) = worker { + // The argv slice borrows `StringImpl*` owned by the parent-thread + // `WorkerOptions::argv` vector. Wrapping one in `BunString::init` + // lets `to_js_array` `String(impl)`-ref it from this worker thread + // and hand JSC a shared impl it may further ref (atomize, resolve a + // rope into), which is a cross-thread hazard on a StringImpl that is + // not thread-safe. Copy the bytes into a worker-local impl instead, + // same as the C++ side does for `options.name.isolatedCopy()`. for &arg in worker.argv() { - args_list.push(BunString::init(arg)); + // SAFETY: non-null entries borrow live storage in the parent + // `WorkerOptions` (see `WebWorker::argv`). + let impl_ = unsafe { &*arg }; + args_list.push(if impl_.is_8bit() { + BunString::clone_latin1(impl_.latin1_slice()) + } else { + BunString::clone_utf16(impl_.utf16_slice()) + }); } } else { for arg in &vm.argv { diff --git a/test/js/web/workers/worker-argv-cross-thread-fixture.test.ts b/test/js/web/workers/worker-argv-cross-thread-fixture.test.ts new file mode 100644 index 000000000000..8b6e99d9f72f --- /dev/null +++ b/test/js/web/workers/worker-argv-cross-thread-fixture.test.ts @@ -0,0 +1,29 @@ +// Fixture for "web Worker argv followed by worker_threads Worker does not +// crash" in worker.test.ts. The crash only reproduced under `bun test` (not +// `bun -e`), so this must be driven as a test file in a subprocess. +import { expect, test } from "bun:test"; +import wt from "worker_threads"; + +const url = new URL("worker-fixture-argv.js", import.meta.url); + +test("web Worker with argv reads process.argv/execArgv", async () => { + const w = new Worker(url.href, { argv: ["--some-arg=1"], execArgv: ["--no-warnings"] }); + const result: any = await new Promise((resolve, reject) => { + w.onerror = reject; + w.onmessage = e => resolve(e.data); + w.postMessage(1); + }); + w.terminate(); + expect(result.argv[result.argv.length - 1]).toBe("--some-arg=1"); + expect(result.execArgv).toEqual(["--no-warnings"]); +}); + +test("worker_threads Worker after the above", async () => { + const w = new wt.Worker(url, {}); + const result: any = await new Promise(resolve => { + w.on("message", resolve); + w.postMessage(1); + }); + await w.terminate(); + expect(result.argv.length).toBeGreaterThan(0); +}); diff --git a/test/js/web/workers/worker.test.ts b/test/js/web/workers/worker.test.ts index 9e20bd0d8b4d..0414da33aab2 100644 --- a/test/js/web/workers/worker.test.ts +++ b/test/js/web/workers/worker.test.ts @@ -431,6 +431,24 @@ describe("worker_threads", () => { expect(process.execArgv).toEqual(original_execArgv); }); + // A web Worker with an explicit argv/execArgv whose worker thread reads + // process.argv, followed by a node:worker_threads Worker in the same + // process, used to crash on Windows (STATUS_STACK_BUFFER_OVERRUN) because + // building process.argv wrapped the parent-thread StringImpl instead of + // copying it. The crash only reproduced under `bun test`, so spawn the + // fixture as a test subprocess; a crash surfaces as a non-zero exit instead + // of taking out this test runner. + test("web Worker argv followed by worker_threads Worker does not crash", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", path.join(import.meta.dir, "worker-argv-cross-thread-fixture.test.ts")], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain("2 pass"); + expect(exitCode).toBe(0); + }); + test("worker with eval = false validates the filename", () => { // eval:false is equivalent to omitting eval, so a bare string that isn't a // path is rejected synchronously like Node (ERR_WORKER_PATH), rather than From 23480cc905a28dd13943de48278ebc928bc56c13 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:33:36 +0000 Subject: [PATCH 2/5] [autofix.ci] apply automated fixes --- src/runtime/node/node_process.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/runtime/node/node_process.rs b/src/runtime/node/node_process.rs index e71ff4e548d1..3ebc1d8a8338 100644 --- a/src/runtime/node/node_process.rs +++ b/src/runtime/node/node_process.rs @@ -352,14 +352,12 @@ mod _impl { // argv also omits the script name // `deref` on every element on scope exit: a no-op for ZigString/Static // tags, and releases the +1 held by `clone_*` in the worker branch. - let mut args_list = scopeguard::guard( - Vec::::with_capacity(args_count + 2), - |v| { + let mut args_list = + scopeguard::guard(Vec::::with_capacity(args_count + 2), |v| { for a in &v { a.deref(); } - }, - ); + }); if vm.standalone_module_graph.is_some() { // Don't break user's code because they did process.argv.slice(2) From 88bf79ae5841971f0de7a1b84b54f88d1d0f20f9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:39:37 +0000 Subject: [PATCH 3/5] review: factor clone into a helper; harden fixture error handling --- src/runtime/node/node_process.rs | 51 +++++++------------ .../worker-argv-cross-thread-fixture.test.ts | 39 +++++++++----- 2 files changed, 43 insertions(+), 47 deletions(-) diff --git a/src/runtime/node/node_process.rs b/src/runtime/node/node_process.rs index 3ebc1d8a8338..9f26756a7276 100644 --- a/src/runtime/node/node_process.rs +++ b/src/runtime/node/node_process.rs @@ -181,6 +181,20 @@ mod _impl { // ───────────────────────────── execArgv ───────────────────────────── + /// `WebWorker::argv`/`exec_argv` borrow `StringImpl*` from the parent + /// thread's `WorkerOptions` vector; those impls are not thread-safe, so + /// copy the bytes into a worker-local impl before handing them to JSC. + fn clone_parent_worker_option_string(wtf: bun_core::WTFStringImpl) -> BunString { + // SAFETY: each entry borrows live storage in the parent `WorkerOptions` + // for this worker's lifetime (see `WebWorker::argv`/`exec_argv`). + let impl_ = unsafe { &*wtf }; + if impl_.is_8bit() { + BunString::clone_latin1(impl_.latin1_slice()) + } else { + BunString::clone_utf16(impl_.utf16_slice()) + } + } + // The C++ caller // (headers.h) declares `EncodedJSValue Bun__Process__createExecArgv(JSGlobalObject*)`, // not a `JSHostFunctionType`. Hand-roll the shim instead of `#[bun_jsc::host_fn]`. @@ -198,23 +212,8 @@ mod _impl { if let Some(worker) = vm.worker_ref() { // was explicitly overridden for the worker? if let Some(exec_argv) = worker.exec_argv() { - // The exec_argv slice borrows `StringImpl*` owned by the - // parent-thread `WorkerOptions::execArgv` vector. Handing one to - // `BunString::init` and then `to_js` would `String(impl)`-ref it - // from this worker thread and let JSC take further refs on it - // (atomization, rope resolution), which is a cross-thread hazard - // on a StringImpl that is not thread-safe. Copy the bytes into a - // worker-local impl instead, same as the C++ side does for - // `options.name.isolatedCopy()` in createNodeWorkerThreadsBinding. return JSValue::create_array_from_iter(global_object, exec_argv.iter(), |&wtf| { - // SAFETY: non-null entries borrow live storage in the - // parent `WorkerOptions` (see `WebWorker::exec_argv`). - let impl_ = unsafe { &*wtf }; - let s = if impl_.is_8bit() { - BunString::clone_latin1(impl_.latin1_slice()) - } else { - BunString::clone_utf16(impl_.utf16_slice()) - }; + let s = clone_parent_worker_option_string(wtf); let r = s.to_js(global_object); s.deref(); r @@ -350,8 +349,8 @@ mod _impl { // argv omits "bun" because it could be "bun run" or "bun" and it's kind of ambiguous // argv also omits the script name - // `deref` on every element on scope exit: a no-op for ZigString/Static - // tags, and releases the +1 held by `clone_*` in the worker branch. + // Scope-exit `deref` releases the +1 from `clone_*` in the worker + // branch; it is a no-op for the ZigString/Static entries. let mut args_list = scopeguard::guard(Vec::::with_capacity(args_count + 2), |v| { for a in &v { @@ -394,22 +393,8 @@ mod _impl { } if let Some(worker) = worker { - // The argv slice borrows `StringImpl*` owned by the parent-thread - // `WorkerOptions::argv` vector. Wrapping one in `BunString::init` - // lets `to_js_array` `String(impl)`-ref it from this worker thread - // and hand JSC a shared impl it may further ref (atomize, resolve a - // rope into), which is a cross-thread hazard on a StringImpl that is - // not thread-safe. Copy the bytes into a worker-local impl instead, - // same as the C++ side does for `options.name.isolatedCopy()`. for &arg in worker.argv() { - // SAFETY: non-null entries borrow live storage in the parent - // `WorkerOptions` (see `WebWorker::argv`). - let impl_ = unsafe { &*arg }; - args_list.push(if impl_.is_8bit() { - BunString::clone_latin1(impl_.latin1_slice()) - } else { - BunString::clone_utf16(impl_.utf16_slice()) - }); + args_list.push(clone_parent_worker_option_string(arg)); } } else { for arg in &vm.argv { diff --git a/test/js/web/workers/worker-argv-cross-thread-fixture.test.ts b/test/js/web/workers/worker-argv-cross-thread-fixture.test.ts index 8b6e99d9f72f..610a2e4f80ba 100644 --- a/test/js/web/workers/worker-argv-cross-thread-fixture.test.ts +++ b/test/js/web/workers/worker-argv-cross-thread-fixture.test.ts @@ -8,22 +8,33 @@ const url = new URL("worker-fixture-argv.js", import.meta.url); test("web Worker with argv reads process.argv/execArgv", async () => { const w = new Worker(url.href, { argv: ["--some-arg=1"], execArgv: ["--no-warnings"] }); - const result: any = await new Promise((resolve, reject) => { - w.onerror = reject; - w.onmessage = e => resolve(e.data); - w.postMessage(1); - }); - w.terminate(); - expect(result.argv[result.argv.length - 1]).toBe("--some-arg=1"); - expect(result.execArgv).toEqual(["--no-warnings"]); + try { + const result: any = await new Promise((resolve, reject) => { + w.onerror = reject; + w.onmessage = e => resolve(e.data); + w.postMessage(1); + }); + expect(result.argv[result.argv.length - 1]).toBe("--some-arg=1"); + expect(result.execArgv).toEqual(["--no-warnings"]); + } finally { + // Web Worker.terminate() is synchronous (returns void); the crash this + // fixture reproduces requires the next test to start before this worker + // is GC'd, so don't insert any additional awaits here. + w.terminate(); + } }); test("worker_threads Worker after the above", async () => { const w = new wt.Worker(url, {}); - const result: any = await new Promise(resolve => { - w.on("message", resolve); - w.postMessage(1); - }); - await w.terminate(); - expect(result.argv.length).toBeGreaterThan(0); + try { + const result: any = await new Promise((resolve, reject) => { + w.once("message", resolve); + w.once("error", reject); + w.once("exit", code => reject(new Error(`worker exited before replying (code ${code})`))); + w.postMessage(1); + }); + expect(result.argv.length).toBeGreaterThan(0); + } finally { + await w.terminate(); + } }); From c63aedfa84c136c2028123046e0f50b9e0c31638 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:48:24 +0000 Subject: [PATCH 4/5] test: rename fixture to -fixture.ts so CI discovery skips it --- src/runtime/server/DirectoryRoute.rs | 712 ++++++++++++++++++ ...ts => worker-argv-cross-thread-fixture.ts} | 0 test/js/web/workers/worker.test.ts | 2 +- 3 files changed, 713 insertions(+), 1 deletion(-) create mode 100644 src/runtime/server/DirectoryRoute.rs rename test/js/web/workers/{worker-argv-cross-thread-fixture.test.ts => worker-argv-cross-thread-fixture.ts} (100%) diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs new file mode 100644 index 000000000000..79977e1f11ca --- /dev/null +++ b/src/runtime/server/DirectoryRoute.rs @@ -0,0 +1,712 @@ +//! Serve a directory tree at a URL prefix: `"/static/*": { dir: "./public" }`. + +use core::cell::Cell; +use core::ffi::c_void; +use core::mem::size_of; +use core::ptr::NonNull; + +use bun_core::strings; +use bun_http::Method; +use bun_io::FileType; +use bun_paths::resolve_path; +use bun_resolver::fs::StatHash; +use bun_sys::{self, Fd, File}; +use bun_uws::{AnyRequest, AnyResponse}; + +use crate::server::file_response_stream::StartOptions as FileResponseStreamOptions; +use crate::server::file_route::{status_for_preconditions, write_any_status, write_content_range}; +use crate::server::jsc::{JSGlobalObject, JsResult}; +use crate::server::{AnyServer, FileResponseStream, HTTPStatusText, RangeRequest}; + +bun_output::declare_scope!(DirectoryRoute, hidden); + +/// `wyhash(subpath) % N` direct-mapped StatHash cache; collisions overwrite. +const STAT_CACHE_SLOTS: usize = 256; + +#[derive(Default)] +struct StatCacheEntry { + path: Vec, + stat_hash: StatHash, +} + +#[derive(bun_ptr::CellRefCounted)] +#[ref_count(destroy = DirectoryRoute::deinit)] +pub struct DirectoryRoute { + ref_count: Cell, + server: Cell>, + root_fd: Cell, + /// Mount prefix with trailing `/` (`"/static/"`, or `"/"` for `"/*"`). + url_prefix: Box<[u8]>, + stat_cache: Box<[Cell]>, + /// Sum of `StatCacheEntry.path` capacities, for `memory_cost()`. + stat_cache_path_bytes: Cell, +} + +impl DirectoryRoute { + #[inline] + pub fn set_server(&self, server: Option) { + self.server.set(server); + } + + pub fn memory_cost(&self) -> usize { + size_of::() + + self.url_prefix.len() + + self.stat_cache.len() * size_of::>() + + self.stat_cache_path_bytes.get() + } + + /// Open `root` and construct the route. `url_prefix` must end in `/`. + pub fn create( + global: &JSGlobalObject, + root: &[u8], + url_prefix: &[u8], + enable_stat_cache: bool, + ) -> JsResult<*mut DirectoryRoute> { + debug_assert!(url_prefix.last() == Some(&b'/')); + debug_assert!(!strings::contains(url_prefix, b"//")); + + let root_fd = match bun_sys::open_a( + root, + bun_sys::O::DIRECTORY | bun_sys::O::CLOEXEC | bun_sys::O::RDONLY, + 0, + ) { + Ok(fd) => fd, + Err(err) => { + use bun_sys_jsc::ErrorJsc; + return Err(global.throw_value(err.to_js(global)?)); + } + }; + + let slots = if enable_stat_cache { + STAT_CACHE_SLOTS + } else { + 0 + }; + let mut stat_cache = Vec::with_capacity(slots); + for _ in 0..slots { + stat_cache.push(Cell::new(StatCacheEntry::default())); + } + + Ok(bun_core::heap::into_raw(Box::new(DirectoryRoute { + ref_count: Cell::new(1), + server: Cell::new(None), + root_fd: Cell::new(root_fd), + url_prefix: url_prefix.to_vec().into_boxed_slice(), + stat_cache: stat_cache.into_boxed_slice(), + stat_cache_path_bytes: Cell::new(0), + }))) + } + + fn deinit(this: *mut DirectoryRoute) { + // SAFETY: heap-allocated in `create`; refcount has reached 0. + let this = unsafe { bun_core::heap::take(this) }; + drop(File::from_fd(this.root_fd.get())); + } + + #[allow(clippy::not_unsafe_ptr_arg_deref)] + pub fn on_head_request(this: *mut DirectoryRoute, req: AnyRequest, resp: AnyResponse) { + Self::on(NonNull::new(this).unwrap(), req, resp, Method::HEAD); + } + + #[allow(clippy::not_unsafe_ptr_arg_deref)] + pub fn on_request(this: *mut DirectoryRoute, req: AnyRequest, resp: AnyResponse) { + let method = Method::find(req.method()).unwrap_or(Method::GET); + Self::on(NonNull::new(this).unwrap(), req, resp, method); + } + + // `this_ptr` (not `&self`) because it is stashed as `FileResponseStream`'s + // ctx userdata; `on_stream_complete` may drop the last ref after a reload, + // and `Box::from_raw` on a `&self`-derived pointer is UB under Stacked + // Borrows. See src/CLAUDE.md §Pointer provenance at FFI boundaries. + fn on( + this_ptr: NonNull, + mut req: AnyRequest, + resp: AnyResponse, + method: Method, + ) { + let this = bun_ptr::BackRef::from(this_ptr); + debug_assert!(this.server.get().is_some()); + this.ref_(); + let guard = ResponseGuard { + route: this_ptr, + resp, + }; + if let Some(mut server) = this.server.get() { + server.on_pending_request(); + resp.timeout(server.config().idle_timeout); + } + + let mut path_buf = bun_paths::path_buffer_pool::get(); + let Some((rel_len, had_trailing_slash)) = + resolve_subpath(req.url(), &this.url_prefix, &mut path_buf.0[..]) + else { + bun_output::scoped_log!(DirectoryRoute, "reject {}", bstr::BStr::new(req.url())); + write_miss(&mut req, resp); + return; + }; + let rel: &[u8] = &path_buf.0[..rel_len]; + + let (file, stat, is_index) = match this.open_subpath(rel, had_trailing_slash) { + Some(Subpath::File(f, s, idx)) => (f, s, idx), + Some(Subpath::RedirectSlash) => { + let mut loc = bun_paths::path_buffer_pool::get(); + let n = build_slash_redirect(req.url(), &mut loc.0[..]); + if n == 0 { + write_miss(&mut req, resp); + return; + } + req.set_yield(false); + write_any_status(resp, 301); + resp.write_mark(); + resp.write_header(b"location", &loc.0[..n]); + resp.end(b"", resp.should_close_connection()); + return; + } + None => { + bun_output::scoped_log!(DirectoryRoute, "miss {}", bstr::BStr::new(rel)); + write_miss(&mut req, resp); + return; + } + }; + + let size: u64 = u64::try_from(stat.st_size.max(0)).expect("int cast"); + + let (last_modified_ms, lm_buf, lm_len) = this.stat_cache_lookup(rel, &stat); + let last_modified = (lm_len > 0).then(|| &lm_buf[..lm_len]); + + let mut etag_buf = [0u8; 40]; + let etag = format_weak_etag(&mut etag_buf, size, last_modified_ms); + + let range = if method == Method::GET || method == Method::HEAD { + RangeRequest::from_request(&req, size) + } else { + RangeRequest::Result::None + }; + + let status_code = status_for_preconditions( + &req, + method, + 200, + Some(etag), + (last_modified_ms > 0).then_some(last_modified_ms), + range, + ); + + req.set_yield(false); + write_any_status(resp, status_code); + resp.write_mark(); + + let ext: &[u8] = if is_index { + b"html" + } else { + extension_for_mime(rel) + }; + resp.write_header( + b"content-type", + &bun_http_types::MimeType::by_extension(ext).value, + ); + if let Some(lm) = last_modified { + resp.write_header(b"last-modified", lm); + } + resp.write_header(b"etag", etag); + if !matches!(resp, AnyResponse::H3(_)) { + if let Some(srv) = this.server.get() { + if let Some(alt) = srv.h3_alt_svc() { + resp.write_header(b"alt-svc", alt); + } + } + } + + if HTTPStatusText::is_null_body(status_code) { + resp.end_without_body(resp.should_close_connection()); + return; + } + if status_code == 412 { + resp.end(b"", resp.should_close_connection()); + return; + } + + let (body_offset, body_len): (u64, u64) = match range { + RangeRequest::Result::Satisfiable { .. } => { + write_content_range(resp, range, size).unwrap() + } + RangeRequest::Result::Unsatisfiable => { + write_content_range(resp, range, size); + resp.end(b"", resp.should_close_connection()); + return; + } + RangeRequest::Result::None => { + resp.write_header(b"accept-ranges", b"bytes"); + (0, size) + } + }; + + if !resp.state().has_written_content_length_header() { + resp.write_header_int(b"content-length", body_len); + resp.mark_wrote_content_length_header(); + } + + if method == Method::HEAD { + resp.end_without_body(resp.should_close_connection()); + return; + } + + bun_output::scoped_log!( + DirectoryRoute, + "serve {} ({} bytes)", + bstr::BStr::new(rel), + size + ); + + let server = this.server.get().unwrap(); + FileResponseStream::start(&FileResponseStreamOptions { + fd: file.into_raw(), + auto_close: true, + resp, + vm: bun_ptr::BackRef::new(server.vm()), + file_type: FileType::File, + pollable: false, + offset: body_offset, + length: Some(body_len), + idle_timeout: server.config().idle_timeout, + ctx: guard.into_ctx(), + on_complete: on_stream_complete, + on_abort: None, + on_error: on_stream_error, + }); + } + + /// Open `rel` under the root. For directories: serve `index.html` when the + /// URL had a trailing slash, otherwise ask the caller to 301-redirect to + /// the slash form so the new request re-enters routing (the served + /// resource's canonical URL may be owned by a more-specific route). + fn open_subpath(&self, rel: &[u8], had_trailing_slash: bool) -> Option { + let open_and_stat = |p: &[u8]| -> Option<(File, bun_sys::Stat)> { + let f = self.open_beneath(p)?; + let s = f.stat().ok()?; + Some((f, s)) + }; + if rel.is_empty() { + let (f, s) = open_and_stat(b"index.html")?; + return bun_sys::S::ISREG(s.st_mode as bun_sys::Mode) + .then_some(Subpath::File(f, s, true)); + } + let (file, stat) = open_and_stat(rel)?; + let mode = stat.st_mode as bun_sys::Mode; + if bun_sys::S::ISDIR(mode) { + drop(file); + if !had_trailing_slash { + return Some(Subpath::RedirectSlash); + } + let mut buf = bun_paths::path_buffer_pool::get(); + let joined = resolve_path::join_string_buf::( + &mut buf.0[..], + &[rel, b"index.html"], + ); + let (f, s) = open_and_stat(joined)?; + return bun_sys::S::ISREG(s.st_mode as bun_sys::Mode) + .then_some(Subpath::File(f, s, true)); + } + // Trailing slash on a regular file is a miss (nginx, npm `send`): + // `/file/` would route past an exact `/file` handler in uWS. + (bun_sys::S::ISREG(mode) && !had_trailing_slash).then_some(Subpath::File(file, stat, false)) + } + + /// `openat2(RESOLVE_IN_ROOT|NO_MAGICLINKS)` on Linux, `openat` elsewhere. + fn open_beneath(&self, rel: &[u8]) -> Option { + let mut buf = bun_paths::path_buffer_pool::get(); + let zrel = resolve_path::z(rel, &mut *buf); + // NONBLOCK so opening a FIFO without a writer cannot block the event + // loop on POSIX. Not on Windows: there `openat` maps it to omitting + // FILE_SYNCHRONOUS_IO_NONALERT, which breaks the synchronous reads + // FileResponseStream issues. + #[cfg(not(windows))] + let flags = bun_sys::O::RDONLY | bun_sys::O::CLOEXEC | bun_sys::O::NONBLOCK; + #[cfg(windows)] + let flags = bun_sys::O::RDONLY | bun_sys::O::CLOEXEC; + #[cfg(any(target_os = "linux", target_os = "android"))] + let fd = bun_sys::openat2_in_root(self.root_fd.get(), zrel, flags, 0).ok()?; + #[cfg(not(any(target_os = "linux", target_os = "android")))] + let fd = bun_sys::openat(self.root_fd.get(), zrel, flags, 0).ok()?; + // Windows `openat` returns a HANDLE; `FileResponseStream` needs a + // libuv fd. `make_lib_uv_owned` is a no-op on POSIX. + use bun_sys::FdExt; + fd.make_lib_uv_owned_for_syscall(bun_sys::Tag::open, bun_sys::ErrorCase::CloseOnFail) + .ok() + .map(File::from_fd) + } + + fn stat_cache_lookup(&self, rel: &[u8], stat: &bun_sys::Stat) -> (u64, [u8; 32], usize) { + let mut buf = [0u8; 32]; + if self.stat_cache.is_empty() { + let mut sh = StatHash::default(); + sh.hash(stat, rel); + let len = sh.last_modified().map(|s| { + buf[..s.len()].copy_from_slice(s); + s.len() + }); + return (sh.last_modified_u64, buf, len.unwrap_or(0)); + } + let slot = &self.stat_cache[(bun_wyhash::hash(rel) as usize) % self.stat_cache.len()]; + let mut entry = slot.replace(StatCacheEntry::default()); + if entry.path.as_slice() != rel { + let old_cap = entry.path.capacity(); + entry.path.clear(); + entry.path.extend_from_slice(rel); + entry.stat_hash = StatHash::default(); + self.stat_cache_path_bytes + .set(self.stat_cache_path_bytes.get() + entry.path.capacity() - old_cap); + } + entry.stat_hash.hash(stat, rel); + let ms = entry.stat_hash.last_modified_u64; + let len = entry + .stat_hash + .last_modified() + .map(|s| { + buf[..s.len()].copy_from_slice(s); + s.len() + }) + .unwrap_or(0); + slot.set(entry); + (ms, buf, len) + } + + fn on_response_complete(this: NonNull, resp: AnyResponse) { + resp.clear_aborted(); + resp.clear_on_writable(); + resp.clear_timeout(); + if let Some(mut server) = bun_ptr::BackRef::from(this).server.get() { + server.on_static_request_complete(); + } + // SAFETY: intrusive refcount; `ref_()` in `on()` pairs with this. + unsafe { Self::deref(this.as_ptr()) }; + } +} + +/// Releases the route ref (and file, if any) on every non-streaming return. +struct ResponseGuard { + route: NonNull, + resp: AnyResponse, +} + +impl ResponseGuard { + fn into_ctx(self) -> *mut c_void { + core::mem::ManuallyDrop::new(self).route.as_ptr().cast() + } +} + +impl Drop for ResponseGuard { + fn drop(&mut self) { + DirectoryRoute::on_response_complete(self.route, self.resp); + } +} + +fn on_stream_complete(ctx: *mut c_void, resp: AnyResponse) { + DirectoryRoute::on_response_complete(NonNull::new(ctx.cast()).unwrap(), resp); +} + +fn on_stream_error(ctx: *mut c_void, resp: AnyResponse, _err: bun_sys::Error) { + DirectoryRoute::on_response_complete(NonNull::new(ctx.cast()).unwrap(), resp); +} + +// `Stat` is ~144 bytes; boxing it would add a heap alloc on the hot path. +#[allow(clippy::large_enum_variant)] +enum Subpath { + File(File, bun_sys::Stat, bool), + RedirectSlash, +} + +fn write_miss(req: &mut AnyRequest, resp: AnyResponse) { + req.set_yield(false); + write_any_status(resp, 404); + resp.write_mark(); + resp.end(b"", resp.should_close_connection()); +} + +/// `Location: {path}/{?query}` into `out`. `resolve_subpath` has already +/// validated `path`: it starts with `url_prefix` (which starts with `/`) and +/// its first segment is non-empty, so the result cannot be a `//...` +/// protocol-relative URL (CVE-2024-43799). +fn build_slash_redirect(url: &[u8], out: &mut [u8]) -> usize { + let (path, query) = path_and_query(url); + debug_assert!(path.first() == Some(&b'/') && path.get(1) != Some(&b'/')); + if path.len() >= out.len() { + return 0; + } + out[..path.len()].copy_from_slice(path); + out[path.len()] = b'/'; + let q = query.len().min(out.len() - path.len() - 1); + out[path.len() + 1..path.len() + 1 + q].copy_from_slice(&query[..q]); + path.len() + 1 + q +} + +/// Split a raw request-target (uWS `getFullUrl()`) into `(path, query)`. +/// Strips `?query` first, then any absolute-form scheme+authority (RFC 9112 +/// §3.2.2), mirroring uWS `getUrlForRouting()` exactly. `query` includes the +/// leading `?` when present. +fn path_and_query(url: &[u8]) -> (&[u8], &[u8]) { + let (path, query) = match strings::index_of_char(url, b'?') { + Some(i) => (&url[..i as usize], &url[i as usize..]), + None => (url, &b""[..]), + }; + let path = if !path.is_empty() && path[0] != b'/' { + let skip = if strings::has_prefix_case_insensitive(path, b"http://") { + 7 + } else if strings::has_prefix_case_insensitive(path, b"https://") { + 8 + } else { + 0 + }; + if skip > 0 { + match strings::index_of_char(&path[skip..], b'/') { + Some(i) => &path[skip + i as usize..], + None => b"/", + } + } else { + path + } + } else { + path + }; + (path, query) +} + +/// RFC 3986 `pchar` (the bytes that may appear literally in a path segment): +/// unreserved / sub-delims / ":" / "@". `%XX` encoding one of these never +/// changes the URL's meaning, so there is no legitimate reason to send it. +#[inline] +fn is_url_path_literal(b: u8) -> bool { + b.is_ascii_alphanumeric() + || matches!( + b, + b'-' | b'.' + | b'_' + | b'~' + | b'!' + | b'$' + | b'&' + | b'\'' + | b'(' + | b')' + | b'*' + | b'+' + | b',' + | b';' + | b'=' + | b':' + | b'@' + ) +} + +/// Strip `url_prefix`, percent-decode once, and validate the result is a +/// canonical relative path. `None` for any input that would make the served +/// path differ from the routed path (see comment on the segment scan below). +/// Writes into `out`; returns `(len, had_trailing_slash)`. +fn resolve_subpath(url: &[u8], url_prefix: &[u8], out: &mut [u8]) -> Option<(usize, bool)> { + let (path, _query) = path_and_query(url); + let after_prefix = if strings::starts_with(path, url_prefix) { + &path[url_prefix.len()..] + } else if path.len() + 1 == url_prefix.len() && path == &url_prefix[..url_prefix.len() - 1] { + b"" + } else { + return None; + }; + + // Leave room for the NUL `z()` appends and for `"/index.html"` when the + // resolved path turns out to be a directory. + if after_prefix.len() >= out.len().saturating_sub(b"/index.html\0".len()) { + return None; + } + + // uWS routed on the raw URL split on literal `/` with no decode and no + // normalization. Any transformation we apply that uWS did not creates a + // path uWS never matched, which can bypass a more-specific overlapping + // route. So reject every such transformation: `%XX` whose decoded byte is + // a `pchar` (would let `%61dmin` reach `admin/`); encoded `%2F`; and any + // non-canonical segment (empty / `.` / `..`). Route segments can only + // consist of `pchar`s on the wire, so rejecting encoded `pchar`s leaves + // percent-decoding as the identity on every byte that could influence + // routing, while still decoding `%20`, high-bit bytes, etc. + let mut raw_slashes = 0usize; + let mut i = 0usize; + while i < after_prefix.len() { + match after_prefix[i] { + b'/' => { + raw_slashes += 1; + i += 1; + } + b'%' if i + 2 < after_prefix.len() + && after_prefix[i + 1].is_ascii_hexdigit() + && after_prefix[i + 2].is_ascii_hexdigit() => + { + let b = (strings::to_ascii_hex_value(after_prefix[i + 1]) << 4) + | strings::to_ascii_hex_value(after_prefix[i + 2]); + if is_url_path_literal(b) { + return None; + } + i += 3; + } + _ => i += 1, + } + } + + let decoded_len = + bun_url::PercentEncoding::decode_into(&mut out[..after_prefix.len()], after_prefix).ok()? + as usize; + let decoded = &out[..decoded_len]; + + if decoded.iter().filter(|&&b| b == b'/').count() != raw_slashes { + return None; + } + if decoded_len == 0 { + return Some((0, false)); + } + let had_trailing_slash = decoded[decoded_len - 1] == b'/'; + let end = decoded_len - usize::from(had_trailing_slash); + let mut seg_start = 0; + let mut i = 0; + while i <= end { + if i == end || decoded[i] == b'/' { + let seg = &decoded[seg_start..i]; + if seg.is_empty() || seg == b"." || seg == b".." { + return None; + } + seg_start = i + 1; + } else if decoded[i] == 0 || decoded[i] == b'\\' || decoded[i] == b':' { + return None; + } + i += 1; + } + Some((end, had_trailing_slash)) +} + +/// `W/"-"` (nginx/send scheme). +fn format_weak_etag(buf: &mut [u8; 40], size: u64, mtime_ms: u64) -> &[u8] { + use core::fmt::Write as _; + let mut c = bun_core::fmt::SliceCursor::new(&mut buf[..]); + let _ = write!(c, "W/\"{:x}-{:x}\"", size, mtime_ms / 1000); + let n = c.at; + &buf[..n] +} + +fn extension_for_mime(path: &[u8]) -> &[u8] { + let ext = bun_paths::extension(path); + ext.strip_prefix(b".").unwrap_or(ext) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn resolve(url: &[u8], prefix: &[u8]) -> Option<(Vec, bool)> { + let mut out = [0u8; 4096]; + resolve_subpath(url, prefix, &mut out).map(|(n, s)| (out[..n].to_vec(), s)) + } + fn ok(bytes: &[u8], slash: bool) -> Option<(Vec, bool)> { + Some((bytes.to_vec(), slash)) + } + + #[test] + fn resolve_basic() { + assert_eq!(resolve(b"/static/a.txt", b"/static/"), ok(b"a.txt", false)); + assert_eq!( + resolve(b"/static/a/b.txt", b"/static/"), + ok(b"a/b.txt", false) + ); + assert_eq!(resolve(b"/a.txt", b"/"), ok(b"a.txt", false)); + assert_eq!(resolve(b"/", b"/"), ok(b"", false)); + assert_eq!(resolve(b"/static", b"/static/"), ok(b"", false)); + assert_eq!(resolve(b"/static/", b"/static/"), ok(b"", false)); + assert_eq!( + resolve(b"/static/a.txt?v=1", b"/static/"), + ok(b"a.txt", false) + ); + assert_eq!(resolve(b"/static?x", b"/static/"), ok(b"", false)); + assert_eq!( + resolve(b"http://x/static/a.txt", b"/static/"), + ok(b"a.txt", false) + ); + assert_eq!( + resolve(b"HTTP://x/static/a.txt", b"/static/"), + ok(b"a.txt", false) + ); + assert_eq!(resolve(b"http://x?q/admin/secret", b"/"), ok(b"", false)); + assert_eq!(resolve(b"http://x", b"/"), ok(b"", false)); + assert_eq!( + resolve(b"https://x:8080/static/a.txt?v=1", b"/static/"), + ok(b"a.txt", false) + ); + } + + #[test] + fn resolve_trailing_slash() { + assert_eq!(resolve(b"/static/a/", b"/static/"), ok(b"a", true)); + assert_eq!(resolve(b"/static/a/b/", b"/static/"), ok(b"a/b", true)); + assert_eq!(resolve(b"/static/a", b"/static/"), ok(b"a", false)); + } + + #[test] + fn resolve_traversal() { + assert_eq!(resolve(b"/static/../etc/passwd", b"/static/"), None); + assert_eq!(resolve(b"/static/..%2Fetc", b"/static/"), None); + assert_eq!(resolve(b"/static/%2e%2e/etc", b"/static/"), None); + assert_eq!(resolve(b"/static/a/../../etc", b"/static/"), None); + assert_eq!(resolve(b"/static/c:/windows", b"/static/"), None); + assert_eq!(resolve(b"/static/file::$DATA", b"/static/"), None); + assert_eq!(resolve(b"/static/a%00.txt", b"/static/"), None); + assert_eq!(resolve(b"/static/a%5Cb.txt", b"/static/"), None); + } + + #[test] + fn resolve_route_precedence_parity() { + // These all route to the outer wildcard in uWS (which matches on raw + // segments) but would reach a file under an inner prefix if we + // normalized, decoded `/`, or decoded a pchar. Reject so the served + // path equals the routed path. + assert_eq!(resolve(b"/static/a%2Fb.txt", b"/static/"), None); + assert_eq!(resolve(b"/static/a%2fb.txt", b"/static/"), None); + assert_eq!(resolve(b"/static//a/b.txt", b"/static/"), None); + assert_eq!(resolve(b"/static/a//b.txt", b"/static/"), None); + assert_eq!(resolve(b"/static//", b"/static/"), None); + assert_eq!(resolve(b"//", b"/"), None); + assert_eq!(resolve(b"/static/./a.txt", b"/static/"), None); + assert_eq!(resolve(b"/static/a/./b.txt", b"/static/"), None); + assert_eq!(resolve(b"/static/a/../b.txt", b"/static/"), None); + assert_eq!(resolve(b"/static/a/..", b"/static/"), None); + // `%XX` encoding a pchar (RFC 3986) is rejected: uWS would not have + // matched the literal segment, so decoding it creates a new path. + assert_eq!(resolve(b"/static/%61dmin/x", b"/static/"), None); + assert_eq!(resolve(b"/static/admi%6E/x", b"/static/"), None); + assert_eq!(resolve(b"/static/ad%4Din/x", b"/static/"), None); + assert_eq!(resolve(b"/static/%40user/x", b"/static/"), None); + assert_eq!(resolve(b"/static/%2Ewell-known/x", b"/static/"), None); + // Legitimate percent-encoding (bytes that cannot appear literally in + // a path segment) still works. + assert_eq!( + resolve(b"/static/hello%20world.txt", b"/static/"), + ok(b"hello world.txt", false) + ); + assert_eq!( + resolve(b"/static/%C3%A9.txt", b"/static/"), + ok(b"\xC3\xA9.txt", false) + ); + } + + #[test] + fn slash_redirect_location() { + let mut out = [0u8; 256]; + let n = build_slash_redirect(b"/static/sub", &mut out); + assert_eq!(&out[..n], b"/static/sub/"); + let n = build_slash_redirect(b"/static/sub?v=1&x=2", &mut out); + assert_eq!(&out[..n], b"/static/sub/?v=1&x=2"); + let n = build_slash_redirect(b"http://h/static/sub?v=1", &mut out); + assert_eq!(&out[..n], b"/static/sub/?v=1"); + // Path alone does not fit: bail rather than panic. + let mut small = [0u8; 8]; + assert_eq!(build_slash_redirect(b"/static/sub", &mut small), 0); + // Query truncated to fit. + let mut small = [0u8; 14]; + let n = build_slash_redirect(b"/static/sub?verylongquery", &mut small); + assert_eq!(&small[..n], b"/static/sub/?v"); + } +} diff --git a/test/js/web/workers/worker-argv-cross-thread-fixture.test.ts b/test/js/web/workers/worker-argv-cross-thread-fixture.ts similarity index 100% rename from test/js/web/workers/worker-argv-cross-thread-fixture.test.ts rename to test/js/web/workers/worker-argv-cross-thread-fixture.ts diff --git a/test/js/web/workers/worker.test.ts b/test/js/web/workers/worker.test.ts index 0414da33aab2..e3f95a8a1355 100644 --- a/test/js/web/workers/worker.test.ts +++ b/test/js/web/workers/worker.test.ts @@ -440,7 +440,7 @@ describe("worker_threads", () => { // of taking out this test runner. test("web Worker argv followed by worker_threads Worker does not crash", async () => { await using proc = Bun.spawn({ - cmd: [bunExe(), "test", path.join(import.meta.dir, "worker-argv-cross-thread-fixture.test.ts")], + cmd: [bunExe(), "test", path.join(import.meta.dir, "worker-argv-cross-thread-fixture.ts")], env: bunEnv, stderr: "pipe", }); From ca970753a8486a4337abca8c0f2014ee209e6d0c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:48:33 +0000 Subject: [PATCH 5/5] revert: remove unrelated DirectoryRoute.rs accidentally staged --- src/runtime/server/DirectoryRoute.rs | 712 --------------------------- 1 file changed, 712 deletions(-) delete mode 100644 src/runtime/server/DirectoryRoute.rs diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs deleted file mode 100644 index 79977e1f11ca..000000000000 --- a/src/runtime/server/DirectoryRoute.rs +++ /dev/null @@ -1,712 +0,0 @@ -//! Serve a directory tree at a URL prefix: `"/static/*": { dir: "./public" }`. - -use core::cell::Cell; -use core::ffi::c_void; -use core::mem::size_of; -use core::ptr::NonNull; - -use bun_core::strings; -use bun_http::Method; -use bun_io::FileType; -use bun_paths::resolve_path; -use bun_resolver::fs::StatHash; -use bun_sys::{self, Fd, File}; -use bun_uws::{AnyRequest, AnyResponse}; - -use crate::server::file_response_stream::StartOptions as FileResponseStreamOptions; -use crate::server::file_route::{status_for_preconditions, write_any_status, write_content_range}; -use crate::server::jsc::{JSGlobalObject, JsResult}; -use crate::server::{AnyServer, FileResponseStream, HTTPStatusText, RangeRequest}; - -bun_output::declare_scope!(DirectoryRoute, hidden); - -/// `wyhash(subpath) % N` direct-mapped StatHash cache; collisions overwrite. -const STAT_CACHE_SLOTS: usize = 256; - -#[derive(Default)] -struct StatCacheEntry { - path: Vec, - stat_hash: StatHash, -} - -#[derive(bun_ptr::CellRefCounted)] -#[ref_count(destroy = DirectoryRoute::deinit)] -pub struct DirectoryRoute { - ref_count: Cell, - server: Cell>, - root_fd: Cell, - /// Mount prefix with trailing `/` (`"/static/"`, or `"/"` for `"/*"`). - url_prefix: Box<[u8]>, - stat_cache: Box<[Cell]>, - /// Sum of `StatCacheEntry.path` capacities, for `memory_cost()`. - stat_cache_path_bytes: Cell, -} - -impl DirectoryRoute { - #[inline] - pub fn set_server(&self, server: Option) { - self.server.set(server); - } - - pub fn memory_cost(&self) -> usize { - size_of::() - + self.url_prefix.len() - + self.stat_cache.len() * size_of::>() - + self.stat_cache_path_bytes.get() - } - - /// Open `root` and construct the route. `url_prefix` must end in `/`. - pub fn create( - global: &JSGlobalObject, - root: &[u8], - url_prefix: &[u8], - enable_stat_cache: bool, - ) -> JsResult<*mut DirectoryRoute> { - debug_assert!(url_prefix.last() == Some(&b'/')); - debug_assert!(!strings::contains(url_prefix, b"//")); - - let root_fd = match bun_sys::open_a( - root, - bun_sys::O::DIRECTORY | bun_sys::O::CLOEXEC | bun_sys::O::RDONLY, - 0, - ) { - Ok(fd) => fd, - Err(err) => { - use bun_sys_jsc::ErrorJsc; - return Err(global.throw_value(err.to_js(global)?)); - } - }; - - let slots = if enable_stat_cache { - STAT_CACHE_SLOTS - } else { - 0 - }; - let mut stat_cache = Vec::with_capacity(slots); - for _ in 0..slots { - stat_cache.push(Cell::new(StatCacheEntry::default())); - } - - Ok(bun_core::heap::into_raw(Box::new(DirectoryRoute { - ref_count: Cell::new(1), - server: Cell::new(None), - root_fd: Cell::new(root_fd), - url_prefix: url_prefix.to_vec().into_boxed_slice(), - stat_cache: stat_cache.into_boxed_slice(), - stat_cache_path_bytes: Cell::new(0), - }))) - } - - fn deinit(this: *mut DirectoryRoute) { - // SAFETY: heap-allocated in `create`; refcount has reached 0. - let this = unsafe { bun_core::heap::take(this) }; - drop(File::from_fd(this.root_fd.get())); - } - - #[allow(clippy::not_unsafe_ptr_arg_deref)] - pub fn on_head_request(this: *mut DirectoryRoute, req: AnyRequest, resp: AnyResponse) { - Self::on(NonNull::new(this).unwrap(), req, resp, Method::HEAD); - } - - #[allow(clippy::not_unsafe_ptr_arg_deref)] - pub fn on_request(this: *mut DirectoryRoute, req: AnyRequest, resp: AnyResponse) { - let method = Method::find(req.method()).unwrap_or(Method::GET); - Self::on(NonNull::new(this).unwrap(), req, resp, method); - } - - // `this_ptr` (not `&self`) because it is stashed as `FileResponseStream`'s - // ctx userdata; `on_stream_complete` may drop the last ref after a reload, - // and `Box::from_raw` on a `&self`-derived pointer is UB under Stacked - // Borrows. See src/CLAUDE.md §Pointer provenance at FFI boundaries. - fn on( - this_ptr: NonNull, - mut req: AnyRequest, - resp: AnyResponse, - method: Method, - ) { - let this = bun_ptr::BackRef::from(this_ptr); - debug_assert!(this.server.get().is_some()); - this.ref_(); - let guard = ResponseGuard { - route: this_ptr, - resp, - }; - if let Some(mut server) = this.server.get() { - server.on_pending_request(); - resp.timeout(server.config().idle_timeout); - } - - let mut path_buf = bun_paths::path_buffer_pool::get(); - let Some((rel_len, had_trailing_slash)) = - resolve_subpath(req.url(), &this.url_prefix, &mut path_buf.0[..]) - else { - bun_output::scoped_log!(DirectoryRoute, "reject {}", bstr::BStr::new(req.url())); - write_miss(&mut req, resp); - return; - }; - let rel: &[u8] = &path_buf.0[..rel_len]; - - let (file, stat, is_index) = match this.open_subpath(rel, had_trailing_slash) { - Some(Subpath::File(f, s, idx)) => (f, s, idx), - Some(Subpath::RedirectSlash) => { - let mut loc = bun_paths::path_buffer_pool::get(); - let n = build_slash_redirect(req.url(), &mut loc.0[..]); - if n == 0 { - write_miss(&mut req, resp); - return; - } - req.set_yield(false); - write_any_status(resp, 301); - resp.write_mark(); - resp.write_header(b"location", &loc.0[..n]); - resp.end(b"", resp.should_close_connection()); - return; - } - None => { - bun_output::scoped_log!(DirectoryRoute, "miss {}", bstr::BStr::new(rel)); - write_miss(&mut req, resp); - return; - } - }; - - let size: u64 = u64::try_from(stat.st_size.max(0)).expect("int cast"); - - let (last_modified_ms, lm_buf, lm_len) = this.stat_cache_lookup(rel, &stat); - let last_modified = (lm_len > 0).then(|| &lm_buf[..lm_len]); - - let mut etag_buf = [0u8; 40]; - let etag = format_weak_etag(&mut etag_buf, size, last_modified_ms); - - let range = if method == Method::GET || method == Method::HEAD { - RangeRequest::from_request(&req, size) - } else { - RangeRequest::Result::None - }; - - let status_code = status_for_preconditions( - &req, - method, - 200, - Some(etag), - (last_modified_ms > 0).then_some(last_modified_ms), - range, - ); - - req.set_yield(false); - write_any_status(resp, status_code); - resp.write_mark(); - - let ext: &[u8] = if is_index { - b"html" - } else { - extension_for_mime(rel) - }; - resp.write_header( - b"content-type", - &bun_http_types::MimeType::by_extension(ext).value, - ); - if let Some(lm) = last_modified { - resp.write_header(b"last-modified", lm); - } - resp.write_header(b"etag", etag); - if !matches!(resp, AnyResponse::H3(_)) { - if let Some(srv) = this.server.get() { - if let Some(alt) = srv.h3_alt_svc() { - resp.write_header(b"alt-svc", alt); - } - } - } - - if HTTPStatusText::is_null_body(status_code) { - resp.end_without_body(resp.should_close_connection()); - return; - } - if status_code == 412 { - resp.end(b"", resp.should_close_connection()); - return; - } - - let (body_offset, body_len): (u64, u64) = match range { - RangeRequest::Result::Satisfiable { .. } => { - write_content_range(resp, range, size).unwrap() - } - RangeRequest::Result::Unsatisfiable => { - write_content_range(resp, range, size); - resp.end(b"", resp.should_close_connection()); - return; - } - RangeRequest::Result::None => { - resp.write_header(b"accept-ranges", b"bytes"); - (0, size) - } - }; - - if !resp.state().has_written_content_length_header() { - resp.write_header_int(b"content-length", body_len); - resp.mark_wrote_content_length_header(); - } - - if method == Method::HEAD { - resp.end_without_body(resp.should_close_connection()); - return; - } - - bun_output::scoped_log!( - DirectoryRoute, - "serve {} ({} bytes)", - bstr::BStr::new(rel), - size - ); - - let server = this.server.get().unwrap(); - FileResponseStream::start(&FileResponseStreamOptions { - fd: file.into_raw(), - auto_close: true, - resp, - vm: bun_ptr::BackRef::new(server.vm()), - file_type: FileType::File, - pollable: false, - offset: body_offset, - length: Some(body_len), - idle_timeout: server.config().idle_timeout, - ctx: guard.into_ctx(), - on_complete: on_stream_complete, - on_abort: None, - on_error: on_stream_error, - }); - } - - /// Open `rel` under the root. For directories: serve `index.html` when the - /// URL had a trailing slash, otherwise ask the caller to 301-redirect to - /// the slash form so the new request re-enters routing (the served - /// resource's canonical URL may be owned by a more-specific route). - fn open_subpath(&self, rel: &[u8], had_trailing_slash: bool) -> Option { - let open_and_stat = |p: &[u8]| -> Option<(File, bun_sys::Stat)> { - let f = self.open_beneath(p)?; - let s = f.stat().ok()?; - Some((f, s)) - }; - if rel.is_empty() { - let (f, s) = open_and_stat(b"index.html")?; - return bun_sys::S::ISREG(s.st_mode as bun_sys::Mode) - .then_some(Subpath::File(f, s, true)); - } - let (file, stat) = open_and_stat(rel)?; - let mode = stat.st_mode as bun_sys::Mode; - if bun_sys::S::ISDIR(mode) { - drop(file); - if !had_trailing_slash { - return Some(Subpath::RedirectSlash); - } - let mut buf = bun_paths::path_buffer_pool::get(); - let joined = resolve_path::join_string_buf::( - &mut buf.0[..], - &[rel, b"index.html"], - ); - let (f, s) = open_and_stat(joined)?; - return bun_sys::S::ISREG(s.st_mode as bun_sys::Mode) - .then_some(Subpath::File(f, s, true)); - } - // Trailing slash on a regular file is a miss (nginx, npm `send`): - // `/file/` would route past an exact `/file` handler in uWS. - (bun_sys::S::ISREG(mode) && !had_trailing_slash).then_some(Subpath::File(file, stat, false)) - } - - /// `openat2(RESOLVE_IN_ROOT|NO_MAGICLINKS)` on Linux, `openat` elsewhere. - fn open_beneath(&self, rel: &[u8]) -> Option { - let mut buf = bun_paths::path_buffer_pool::get(); - let zrel = resolve_path::z(rel, &mut *buf); - // NONBLOCK so opening a FIFO without a writer cannot block the event - // loop on POSIX. Not on Windows: there `openat` maps it to omitting - // FILE_SYNCHRONOUS_IO_NONALERT, which breaks the synchronous reads - // FileResponseStream issues. - #[cfg(not(windows))] - let flags = bun_sys::O::RDONLY | bun_sys::O::CLOEXEC | bun_sys::O::NONBLOCK; - #[cfg(windows)] - let flags = bun_sys::O::RDONLY | bun_sys::O::CLOEXEC; - #[cfg(any(target_os = "linux", target_os = "android"))] - let fd = bun_sys::openat2_in_root(self.root_fd.get(), zrel, flags, 0).ok()?; - #[cfg(not(any(target_os = "linux", target_os = "android")))] - let fd = bun_sys::openat(self.root_fd.get(), zrel, flags, 0).ok()?; - // Windows `openat` returns a HANDLE; `FileResponseStream` needs a - // libuv fd. `make_lib_uv_owned` is a no-op on POSIX. - use bun_sys::FdExt; - fd.make_lib_uv_owned_for_syscall(bun_sys::Tag::open, bun_sys::ErrorCase::CloseOnFail) - .ok() - .map(File::from_fd) - } - - fn stat_cache_lookup(&self, rel: &[u8], stat: &bun_sys::Stat) -> (u64, [u8; 32], usize) { - let mut buf = [0u8; 32]; - if self.stat_cache.is_empty() { - let mut sh = StatHash::default(); - sh.hash(stat, rel); - let len = sh.last_modified().map(|s| { - buf[..s.len()].copy_from_slice(s); - s.len() - }); - return (sh.last_modified_u64, buf, len.unwrap_or(0)); - } - let slot = &self.stat_cache[(bun_wyhash::hash(rel) as usize) % self.stat_cache.len()]; - let mut entry = slot.replace(StatCacheEntry::default()); - if entry.path.as_slice() != rel { - let old_cap = entry.path.capacity(); - entry.path.clear(); - entry.path.extend_from_slice(rel); - entry.stat_hash = StatHash::default(); - self.stat_cache_path_bytes - .set(self.stat_cache_path_bytes.get() + entry.path.capacity() - old_cap); - } - entry.stat_hash.hash(stat, rel); - let ms = entry.stat_hash.last_modified_u64; - let len = entry - .stat_hash - .last_modified() - .map(|s| { - buf[..s.len()].copy_from_slice(s); - s.len() - }) - .unwrap_or(0); - slot.set(entry); - (ms, buf, len) - } - - fn on_response_complete(this: NonNull, resp: AnyResponse) { - resp.clear_aborted(); - resp.clear_on_writable(); - resp.clear_timeout(); - if let Some(mut server) = bun_ptr::BackRef::from(this).server.get() { - server.on_static_request_complete(); - } - // SAFETY: intrusive refcount; `ref_()` in `on()` pairs with this. - unsafe { Self::deref(this.as_ptr()) }; - } -} - -/// Releases the route ref (and file, if any) on every non-streaming return. -struct ResponseGuard { - route: NonNull, - resp: AnyResponse, -} - -impl ResponseGuard { - fn into_ctx(self) -> *mut c_void { - core::mem::ManuallyDrop::new(self).route.as_ptr().cast() - } -} - -impl Drop for ResponseGuard { - fn drop(&mut self) { - DirectoryRoute::on_response_complete(self.route, self.resp); - } -} - -fn on_stream_complete(ctx: *mut c_void, resp: AnyResponse) { - DirectoryRoute::on_response_complete(NonNull::new(ctx.cast()).unwrap(), resp); -} - -fn on_stream_error(ctx: *mut c_void, resp: AnyResponse, _err: bun_sys::Error) { - DirectoryRoute::on_response_complete(NonNull::new(ctx.cast()).unwrap(), resp); -} - -// `Stat` is ~144 bytes; boxing it would add a heap alloc on the hot path. -#[allow(clippy::large_enum_variant)] -enum Subpath { - File(File, bun_sys::Stat, bool), - RedirectSlash, -} - -fn write_miss(req: &mut AnyRequest, resp: AnyResponse) { - req.set_yield(false); - write_any_status(resp, 404); - resp.write_mark(); - resp.end(b"", resp.should_close_connection()); -} - -/// `Location: {path}/{?query}` into `out`. `resolve_subpath` has already -/// validated `path`: it starts with `url_prefix` (which starts with `/`) and -/// its first segment is non-empty, so the result cannot be a `//...` -/// protocol-relative URL (CVE-2024-43799). -fn build_slash_redirect(url: &[u8], out: &mut [u8]) -> usize { - let (path, query) = path_and_query(url); - debug_assert!(path.first() == Some(&b'/') && path.get(1) != Some(&b'/')); - if path.len() >= out.len() { - return 0; - } - out[..path.len()].copy_from_slice(path); - out[path.len()] = b'/'; - let q = query.len().min(out.len() - path.len() - 1); - out[path.len() + 1..path.len() + 1 + q].copy_from_slice(&query[..q]); - path.len() + 1 + q -} - -/// Split a raw request-target (uWS `getFullUrl()`) into `(path, query)`. -/// Strips `?query` first, then any absolute-form scheme+authority (RFC 9112 -/// §3.2.2), mirroring uWS `getUrlForRouting()` exactly. `query` includes the -/// leading `?` when present. -fn path_and_query(url: &[u8]) -> (&[u8], &[u8]) { - let (path, query) = match strings::index_of_char(url, b'?') { - Some(i) => (&url[..i as usize], &url[i as usize..]), - None => (url, &b""[..]), - }; - let path = if !path.is_empty() && path[0] != b'/' { - let skip = if strings::has_prefix_case_insensitive(path, b"http://") { - 7 - } else if strings::has_prefix_case_insensitive(path, b"https://") { - 8 - } else { - 0 - }; - if skip > 0 { - match strings::index_of_char(&path[skip..], b'/') { - Some(i) => &path[skip + i as usize..], - None => b"/", - } - } else { - path - } - } else { - path - }; - (path, query) -} - -/// RFC 3986 `pchar` (the bytes that may appear literally in a path segment): -/// unreserved / sub-delims / ":" / "@". `%XX` encoding one of these never -/// changes the URL's meaning, so there is no legitimate reason to send it. -#[inline] -fn is_url_path_literal(b: u8) -> bool { - b.is_ascii_alphanumeric() - || matches!( - b, - b'-' | b'.' - | b'_' - | b'~' - | b'!' - | b'$' - | b'&' - | b'\'' - | b'(' - | b')' - | b'*' - | b'+' - | b',' - | b';' - | b'=' - | b':' - | b'@' - ) -} - -/// Strip `url_prefix`, percent-decode once, and validate the result is a -/// canonical relative path. `None` for any input that would make the served -/// path differ from the routed path (see comment on the segment scan below). -/// Writes into `out`; returns `(len, had_trailing_slash)`. -fn resolve_subpath(url: &[u8], url_prefix: &[u8], out: &mut [u8]) -> Option<(usize, bool)> { - let (path, _query) = path_and_query(url); - let after_prefix = if strings::starts_with(path, url_prefix) { - &path[url_prefix.len()..] - } else if path.len() + 1 == url_prefix.len() && path == &url_prefix[..url_prefix.len() - 1] { - b"" - } else { - return None; - }; - - // Leave room for the NUL `z()` appends and for `"/index.html"` when the - // resolved path turns out to be a directory. - if after_prefix.len() >= out.len().saturating_sub(b"/index.html\0".len()) { - return None; - } - - // uWS routed on the raw URL split on literal `/` with no decode and no - // normalization. Any transformation we apply that uWS did not creates a - // path uWS never matched, which can bypass a more-specific overlapping - // route. So reject every such transformation: `%XX` whose decoded byte is - // a `pchar` (would let `%61dmin` reach `admin/`); encoded `%2F`; and any - // non-canonical segment (empty / `.` / `..`). Route segments can only - // consist of `pchar`s on the wire, so rejecting encoded `pchar`s leaves - // percent-decoding as the identity on every byte that could influence - // routing, while still decoding `%20`, high-bit bytes, etc. - let mut raw_slashes = 0usize; - let mut i = 0usize; - while i < after_prefix.len() { - match after_prefix[i] { - b'/' => { - raw_slashes += 1; - i += 1; - } - b'%' if i + 2 < after_prefix.len() - && after_prefix[i + 1].is_ascii_hexdigit() - && after_prefix[i + 2].is_ascii_hexdigit() => - { - let b = (strings::to_ascii_hex_value(after_prefix[i + 1]) << 4) - | strings::to_ascii_hex_value(after_prefix[i + 2]); - if is_url_path_literal(b) { - return None; - } - i += 3; - } - _ => i += 1, - } - } - - let decoded_len = - bun_url::PercentEncoding::decode_into(&mut out[..after_prefix.len()], after_prefix).ok()? - as usize; - let decoded = &out[..decoded_len]; - - if decoded.iter().filter(|&&b| b == b'/').count() != raw_slashes { - return None; - } - if decoded_len == 0 { - return Some((0, false)); - } - let had_trailing_slash = decoded[decoded_len - 1] == b'/'; - let end = decoded_len - usize::from(had_trailing_slash); - let mut seg_start = 0; - let mut i = 0; - while i <= end { - if i == end || decoded[i] == b'/' { - let seg = &decoded[seg_start..i]; - if seg.is_empty() || seg == b"." || seg == b".." { - return None; - } - seg_start = i + 1; - } else if decoded[i] == 0 || decoded[i] == b'\\' || decoded[i] == b':' { - return None; - } - i += 1; - } - Some((end, had_trailing_slash)) -} - -/// `W/"-"` (nginx/send scheme). -fn format_weak_etag(buf: &mut [u8; 40], size: u64, mtime_ms: u64) -> &[u8] { - use core::fmt::Write as _; - let mut c = bun_core::fmt::SliceCursor::new(&mut buf[..]); - let _ = write!(c, "W/\"{:x}-{:x}\"", size, mtime_ms / 1000); - let n = c.at; - &buf[..n] -} - -fn extension_for_mime(path: &[u8]) -> &[u8] { - let ext = bun_paths::extension(path); - ext.strip_prefix(b".").unwrap_or(ext) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn resolve(url: &[u8], prefix: &[u8]) -> Option<(Vec, bool)> { - let mut out = [0u8; 4096]; - resolve_subpath(url, prefix, &mut out).map(|(n, s)| (out[..n].to_vec(), s)) - } - fn ok(bytes: &[u8], slash: bool) -> Option<(Vec, bool)> { - Some((bytes.to_vec(), slash)) - } - - #[test] - fn resolve_basic() { - assert_eq!(resolve(b"/static/a.txt", b"/static/"), ok(b"a.txt", false)); - assert_eq!( - resolve(b"/static/a/b.txt", b"/static/"), - ok(b"a/b.txt", false) - ); - assert_eq!(resolve(b"/a.txt", b"/"), ok(b"a.txt", false)); - assert_eq!(resolve(b"/", b"/"), ok(b"", false)); - assert_eq!(resolve(b"/static", b"/static/"), ok(b"", false)); - assert_eq!(resolve(b"/static/", b"/static/"), ok(b"", false)); - assert_eq!( - resolve(b"/static/a.txt?v=1", b"/static/"), - ok(b"a.txt", false) - ); - assert_eq!(resolve(b"/static?x", b"/static/"), ok(b"", false)); - assert_eq!( - resolve(b"http://x/static/a.txt", b"/static/"), - ok(b"a.txt", false) - ); - assert_eq!( - resolve(b"HTTP://x/static/a.txt", b"/static/"), - ok(b"a.txt", false) - ); - assert_eq!(resolve(b"http://x?q/admin/secret", b"/"), ok(b"", false)); - assert_eq!(resolve(b"http://x", b"/"), ok(b"", false)); - assert_eq!( - resolve(b"https://x:8080/static/a.txt?v=1", b"/static/"), - ok(b"a.txt", false) - ); - } - - #[test] - fn resolve_trailing_slash() { - assert_eq!(resolve(b"/static/a/", b"/static/"), ok(b"a", true)); - assert_eq!(resolve(b"/static/a/b/", b"/static/"), ok(b"a/b", true)); - assert_eq!(resolve(b"/static/a", b"/static/"), ok(b"a", false)); - } - - #[test] - fn resolve_traversal() { - assert_eq!(resolve(b"/static/../etc/passwd", b"/static/"), None); - assert_eq!(resolve(b"/static/..%2Fetc", b"/static/"), None); - assert_eq!(resolve(b"/static/%2e%2e/etc", b"/static/"), None); - assert_eq!(resolve(b"/static/a/../../etc", b"/static/"), None); - assert_eq!(resolve(b"/static/c:/windows", b"/static/"), None); - assert_eq!(resolve(b"/static/file::$DATA", b"/static/"), None); - assert_eq!(resolve(b"/static/a%00.txt", b"/static/"), None); - assert_eq!(resolve(b"/static/a%5Cb.txt", b"/static/"), None); - } - - #[test] - fn resolve_route_precedence_parity() { - // These all route to the outer wildcard in uWS (which matches on raw - // segments) but would reach a file under an inner prefix if we - // normalized, decoded `/`, or decoded a pchar. Reject so the served - // path equals the routed path. - assert_eq!(resolve(b"/static/a%2Fb.txt", b"/static/"), None); - assert_eq!(resolve(b"/static/a%2fb.txt", b"/static/"), None); - assert_eq!(resolve(b"/static//a/b.txt", b"/static/"), None); - assert_eq!(resolve(b"/static/a//b.txt", b"/static/"), None); - assert_eq!(resolve(b"/static//", b"/static/"), None); - assert_eq!(resolve(b"//", b"/"), None); - assert_eq!(resolve(b"/static/./a.txt", b"/static/"), None); - assert_eq!(resolve(b"/static/a/./b.txt", b"/static/"), None); - assert_eq!(resolve(b"/static/a/../b.txt", b"/static/"), None); - assert_eq!(resolve(b"/static/a/..", b"/static/"), None); - // `%XX` encoding a pchar (RFC 3986) is rejected: uWS would not have - // matched the literal segment, so decoding it creates a new path. - assert_eq!(resolve(b"/static/%61dmin/x", b"/static/"), None); - assert_eq!(resolve(b"/static/admi%6E/x", b"/static/"), None); - assert_eq!(resolve(b"/static/ad%4Din/x", b"/static/"), None); - assert_eq!(resolve(b"/static/%40user/x", b"/static/"), None); - assert_eq!(resolve(b"/static/%2Ewell-known/x", b"/static/"), None); - // Legitimate percent-encoding (bytes that cannot appear literally in - // a path segment) still works. - assert_eq!( - resolve(b"/static/hello%20world.txt", b"/static/"), - ok(b"hello world.txt", false) - ); - assert_eq!( - resolve(b"/static/%C3%A9.txt", b"/static/"), - ok(b"\xC3\xA9.txt", false) - ); - } - - #[test] - fn slash_redirect_location() { - let mut out = [0u8; 256]; - let n = build_slash_redirect(b"/static/sub", &mut out); - assert_eq!(&out[..n], b"/static/sub/"); - let n = build_slash_redirect(b"/static/sub?v=1&x=2", &mut out); - assert_eq!(&out[..n], b"/static/sub/?v=1&x=2"); - let n = build_slash_redirect(b"http://h/static/sub?v=1", &mut out); - assert_eq!(&out[..n], b"/static/sub/?v=1"); - // Path alone does not fit: bail rather than panic. - let mut small = [0u8; 8]; - assert_eq!(build_slash_redirect(b"/static/sub", &mut small), 0); - // Query truncated to fit. - let mut small = [0u8; 14]; - let n = build_slash_redirect(b"/static/sub?verylongquery", &mut small); - assert_eq!(&small[..n], b"/static/sub/?v"); - } -}