diff --git a/docs/runtime/http/routing.mdx b/docs/runtime/http/routing.mdx index 9b09b294e52f..36295e669f1e 100644 --- a/docs/runtime/http/routing.mdx +++ b/docs/runtime/http/routing.mdx @@ -194,6 +194,34 @@ Bun.serve({ - **Memory efficient** - Only buffers small chunks during transfer, not entire file - **Best for**: Large files, dynamic content, user uploads, files that change frequently +### Directory routes + +To serve an entire directory tree at a URL prefix, pass `{ dir }` as the route value. The route path must end in `/*`. + +```ts +Bun.serve({ + routes: { + "/static/*": { dir: "./public" }, + }, +}); +``` + +The part of the request URL after the prefix is percent-decoded once and opened relative to `dir`. Non-canonical paths (those containing `.`, `..`, empty segments, `%2F`, or a `%XX` sequence encoding a character that may appear literally in a path segment) are rejected with `404`, so the served path is always the path the router matched. On Linux the open uses `openat2(RESOLVE_IN_ROOT)`, so symlinks that would escape `dir` are clamped by the kernel. + +{% callout %} +Routing is case-sensitive but filesystems on macOS and Windows are case-insensitive by default, so a case-varied URL (`/static/Admin/secret.txt`) will route to the directory wildcard rather than a sibling `/static/admin/*` handler and still open `admin/secret.txt`. As with nginx, Caddy, and other static file servers, do not place access-controlled content inside `dir` and rely on an overlapping route to gate it. +{% /callout %} + +Directory routes share the response path with file routes: + +- **Content-Type** is set from the file extension. +- **Last-Modified** and a weak `ETag` (`W/"-"`) are sent on every response, and `If-Modified-Since` / `If-None-Match` are honored with `304 Not Modified`. +- **Range requests** are supported with `Accept-Ranges: bytes` and `Content-Range`. +- A request that resolves to a directory without a trailing `/` is answered with a `301` redirect to the trailing-slash URL; with the trailing slash, `index.html` from that directory is served. +- Missing files return `404`. + +Pass `statCache: false` to disable the per-path `Last-Modified` cache (saves roughly 20 KB per route). + --- ## Streaming files diff --git a/packages/bun-types/serve.d.ts b/packages/bun-types/serve.d.ts index f733b770170d..fee1ee6e0f2a 100644 --- a/packages/bun-types/serve.d.ts +++ b/packages/bun-types/serve.d.ts @@ -579,7 +579,48 @@ declare module "bun" { type Handler = (request: Req, server: S) => MaybePromise; - type BaseRouteValue = Response | false | HTMLBundle | BunFile; + /** + * Serve a directory tree at a URL prefix. + * + * The route path **must** end in `/*`. The part of the request URL after + * the prefix is percent-decoded once and opened relative to `dir`. + * Non-canonical paths (containing `.`, `..`, empty segments, `%2F`, or a + * `%XX` sequence encoding a character that may appear literally in a path + * segment) are rejected with `404` so the served path is always the path + * the router matched. On Linux the open uses `openat2(RESOLVE_IN_ROOT)`, + * so symlinks that would escape `dir` are clamped by the kernel. Routing + * is case-sensitive but filesystems on macOS and Windows are not by + * default: do not place access-controlled content inside `dir` and rely + * on an overlapping route to gate it. + * + * Responses carry `Content-Type` (from the file extension), + * `Last-Modified`, a weak `ETag`, and support single-range `Range` + * requests. A request that resolves to a directory without a trailing + * `/` is redirected (`301`) to the trailing-slash URL; with the trailing + * slash, `index.html` from that directory is served. Missing files + * return `404`. + * + * @example + * ```ts + * Bun.serve({ + * routes: { + * "/static/*": { dir: "./public" }, + * }, + * }); + * ``` + */ + interface DirectoryRouteOptions { + /** Path to the directory to serve. */ + dir: string; + /** + * Cache formatted `Last-Modified` strings per path so repeated requests + * for an unchanged file skip the date formatter. Uses ~20 KB per route. + * @default true + */ + statCache?: boolean; + } + + type BaseRouteValue = Response | false | HTMLBundle | BunFile | DirectoryRouteOptions; type Routes = { [Path in R]: diff --git a/src/http_types/MimeType.rs b/src/http_types/MimeType.rs index 326065165989..751f8a2ba9da 100644 --- a/src/http_types/MimeType.rs +++ b/src/http_types/MimeType.rs @@ -432,7 +432,7 @@ pub fn by_extension(ext_without_leading_dot: &[u8]) -> MimeType { } pub fn by_extension_no_default(ext_without_leading_dot: &[u8]) -> Option { - if let Some(entry) = EXTENSIONS.get(ext_without_leading_dot) { + if let Some(entry) = EXTENSIONS.get_ascii_case_insensitive(ext_without_leading_dot) { return Some(Compact::from(*entry).to_mime_type()); } None diff --git a/src/paths/resolve_path.rs b/src/paths/resolve_path.rs index 416ff9096931..68df995dc1f8 100644 --- a/src/paths/resolve_path.rs +++ b/src/paths/resolve_path.rs @@ -33,7 +33,7 @@ fn tl_buf_mut(b: &UnsafeCell<[u8; N]>) -> &'static mut [u8; N] { } pub fn z<'a>(input: &[u8], output: &'a mut PathBuffer) -> &'a ZStr { - if input.len() > MAX_PATH_BYTES { + if input.len() >= MAX_PATH_BYTES { if cfg!(debug_assertions) { panic!("path too long"); } 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/src/runtime/server/FileRoute.rs b/src/runtime/server/FileRoute.rs index 88691374f5f4..f11b96079d8b 100644 --- a/src/runtime/server/FileRoute.rs +++ b/src/runtime/server/FileRoute.rs @@ -17,7 +17,7 @@ use crate::node::types::PathOrFileDescriptor; use crate::server::file_response_stream::StartOptions as FileResponseStreamOptions; use crate::server::jsc::{JSGlobalObject, JSValue, JsResult, VirtualMachine}; -use crate::server::{AnyServer, FileResponseStream, HTTPStatusText, RangeRequest, write_status}; +use crate::server::{AnyServer, FileResponseStream, HTTPStatusText, RangeRequest}; use crate::webcore::blob::store::Data as StoreData; use crate::webcore::body::Value as BodyValue; use crate::webcore::{Blob, FetchHeaders, Response}; @@ -273,19 +273,6 @@ impl FileRoute { } } - fn write_status_code(&self, status: u16, resp: AnyResponse) { - match resp { - AnyResponse::SSL(r) => write_status::(r, status), - AnyResponse::TCP(r) => write_status::(r, status), - AnyResponse::H3(r) => { - let mut b = bun_core::fmt::ItoaBuf::new(); - let s = bun_core::fmt::itoa(&mut b, status); - // S008: `h3::Response` is an `opaque_ffi!` ZST — safe deref. - bun_opaque::opaque_deref_mut(r).write_status(s); - } - } - } - /// # Safety /// `this` must point to a live heap `FileRoute` (intrusive ref held by the /// route table) for the duration of the call. @@ -386,16 +373,6 @@ impl FileRoute { } }); - // `parse_http_date` maps a parse failure to `None`, so a - // malformed If-Modified-Since header degrades to "serve the file - // unconditionally" — the RFC 9110 §13.1.3-correct behaviour. - // - // LAYERING: the parse step lives HERE (T6) because it needs `bun_jsc` — - // so `bun_uws_sys` (T0) carries no upward hook. - let input_if_modified_since_date: Option = req - .header(b"if-modified-since") - .and_then(crate::jsc_hooks::parse_http_date); - let (can_serve_file, size, file_type, pollable): (bool, u64, FileType, bool) = 'brk: { let stat = match bun_sys::fstat(fd) { Ok(s) => s, @@ -448,79 +425,29 @@ impl FileRoute { RangeRequest::Result::None }; - let status_code: u16 = 'brk: { - // RFC 9110 §13.2.2: preconditions evaluate before Range, in order: - // (1) If-Match, else (2) If-Unmodified-Since; then (3) If-None-Match, - // else (4) If-Modified-Since. Steps 1/2 yield 412 on failure and must - // run before steps 3/4 can yield 304. - if (method == Method::HEAD || method == Method::GET) && this.status_code == 200 { - // Step 1: If-Match (strong comparison). - if let Some(im) = req.header(b"if-match").filter(|v| !v.is_empty()) { - let etag = this.headers.get(b"etag").filter(|v| !v.is_empty()); - if !ETag::if_match(etag, im) { - break 'brk 412; - } - // Step 2: If-Unmodified-Since (only when If-Match is absent). - } else if let Some(ius) = req - .header(b"if-unmodified-since") - .and_then(crate::jsc_hooks::parse_http_date) - { - let Ok(lmd) = this.last_modified_date() else { - return; - }; - if let Some(lm) = lmd { - if lm / 1000 > ius / 1000 { - break 'brk 412; - } - } - } - } - - if method == Method::HEAD || method == Method::GET { - if let Some(inm) = req.header(b"if-none-match").filter(|v| !v.is_empty()) { - if this.status_code == 200 { - let matched = match this.headers.get(b"etag").filter(|v| !v.is_empty()) { - Some(etag) => ETag::if_none_match(etag, inm), - // No stored ETag: only `*` can match (RFC 9110 - // §13.1.2 — any current representation). - None => strings::trim(inm, b" \t") == b"*", - }; - if matched { - break 'brk 304; - } - } - // If-None-Match present but did not match: condition is - // true, fall through to Range/200 without consulting - // If-Modified-Since. - } else if let Some(requested_if_modified_since) = input_if_modified_since_date { - let Ok(lmd) = this.last_modified_date() else { - return; - }; // TODO: properly propagate exception upwards - if let Some(actual_last_modified_at) = lmd { - // Compare at second precision: the Last-Modified header we - // emit is second-granular (HTTP-date), so a sub-second - // mtime would otherwise never satisfy `<=` against the - // client's echoed value. - if actual_last_modified_at / 1000 <= requested_if_modified_since / 1000 { - break 'brk 304; - } - } - } - } - - if matches!(range, RangeRequest::Result::Unsatisfiable) { - break 'brk 416; - } - if matches!(range, RangeRequest::Result::Satisfiable { .. }) { - break 'brk 206; - } - - this.status_code + let etag = this.headers.get(b"etag").filter(|v| !v.is_empty()); + let last_modified_ms = if req.header(b"if-modified-since").is_some() + || req.header(b"if-unmodified-since").is_some() + { + let Ok(lmd) = this.last_modified_date() else { + return; + }; + lmd + } else { + None }; + let status_code = status_for_preconditions( + &req, + method, + this.status_code, + etag, + last_modified_ms, + range, + ); req.set_yield(false); - this.write_status_code(status_code, resp); + write_any_status(resp, status_code); if this.has_date_header { resp.mark_wrote_date_header(); } @@ -540,22 +467,12 @@ impl FileRoute { } let (body_offset, body_len): (u64, Option) = match range { - RangeRequest::Result::Satisfiable { start, end } => { - let mut crbuf = [0u8; RangeRequest::CONTENT_RANGE_BUF]; - resp.write_header( - b"content-range", - RangeRequest::format_content_range(&mut crbuf, range, Some(size)), - ); - resp.write_header(b"accept-ranges", b"bytes"); - (this.blob.offset.get() + start, Some(end - start + 1)) + RangeRequest::Result::Satisfiable { .. } => { + let (start, len) = write_content_range(resp, range, size).unwrap(); + (this.blob.offset.get() + start, Some(len)) } RangeRequest::Result::Unsatisfiable => { - let mut crbuf = [0u8; RangeRequest::CONTENT_RANGE_BUF]; - resp.write_header( - b"content-range", - RangeRequest::format_content_range(&mut crbuf, range, Some(size)), - ); - resp.write_header(b"accept-ranges", b"bytes"); + write_content_range(resp, range, size); resp.end(b"", resp.should_close_connection()); return; } @@ -624,3 +541,95 @@ fn on_stream_complete(ctx: *mut c_void, resp: AnyResponse) { fn on_stream_error(ctx: *mut c_void, resp: AnyResponse, _err: bun_sys::Error) { FileRoute::on_response_complete(ctx.cast::(), resp); } + +/// RFC 9110 §13.2.2 precondition evaluation for a GET/HEAD file response. +/// Order: (1) If-Match, else (2) If-Unmodified-Since; then (3) If-None-Match, +/// else (4) If-Modified-Since. Steps 1/2 yield 412 on failure and must run +/// before steps 3/4 can yield 304. Preconditions only apply when the selected +/// representation would otherwise be 200 (§13.1.1). +pub(crate) fn status_for_preconditions( + req: &AnyRequest, + method: Method, + base_status: u16, + etag: Option<&[u8]>, + last_modified_ms: Option, + range: RangeRequest::Result, +) -> u16 { + if (method == Method::HEAD || method == Method::GET) && base_status == 200 { + if let Some(im) = req.header(b"if-match").filter(|v| !v.is_empty()) { + if !ETag::if_match(etag, im) { + return 412; + } + } else if let Some(ius) = req + .header(b"if-unmodified-since") + .and_then(crate::jsc_hooks::parse_http_date) + { + if let Some(lm) = last_modified_ms { + if lm / 1000 > ius / 1000 { + return 412; + } + } + } + + if let Some(inm) = req.header(b"if-none-match").filter(|v| !v.is_empty()) { + let matched = match etag { + Some(etag) => ETag::if_none_match(etag, inm), + // No stored ETag: only `*` can match (§13.1.2). + None => strings::trim(inm, b" \t") == b"*", + }; + if matched { + return 304; + } + // Did not match: fall through to Range/200 without consulting IMS. + } else if let Some(ims) = req + .header(b"if-modified-since") + .and_then(crate::jsc_hooks::parse_http_date) + { + // Compare at second precision: the Last-Modified we emit is + // second-granular (HTTP-date), so a sub-second mtime would never + // satisfy `<=` against the client's echoed value otherwise. + if let Some(lm) = last_modified_ms { + if lm / 1000 <= ims / 1000 { + return 304; + } + } + } + } + + match range { + RangeRequest::Result::Unsatisfiable => 416, + RangeRequest::Result::Satisfiable { .. } => 206, + RangeRequest::Result::None => base_status, + } +} + +/// Write a 206/416 `Content-Range` header plus `Accept-Ranges: bytes`, and +/// return the `(offset, length)` to stream for a 206, or `None` for 416. +pub(crate) fn write_content_range( + resp: AnyResponse, + range: RangeRequest::Result, + size: u64, +) -> Option<(u64, u64)> { + let mut crbuf = [0u8; RangeRequest::CONTENT_RANGE_BUF]; + resp.write_header( + b"content-range", + RangeRequest::format_content_range(&mut crbuf, range, Some(size)), + ); + resp.write_header(b"accept-ranges", b"bytes"); + match range { + RangeRequest::Result::Satisfiable { start, end } => Some((start, end - start + 1)), + _ => None, + } +} + +pub(crate) fn write_any_status(resp: AnyResponse, status: u16) { + match resp { + AnyResponse::SSL(r) => crate::server::write_status::(r, status), + AnyResponse::TCP(r) => crate::server::write_status::(r, status), + AnyResponse::H3(r) => { + let mut b = bun_core::fmt::ItoaBuf::new(); + let s = bun_core::fmt::itoa(&mut b, status); + bun_opaque::opaque_deref_mut(r).write_status(s); + } + } +} diff --git a/src/runtime/server/ServerConfig.rs b/src/runtime/server/ServerConfig.rs index 191a5136a37a..fcabc27135c4 100644 --- a/src/runtime/server/ServerConfig.rs +++ b/src/runtime/server/ServerConfig.rs @@ -534,6 +534,27 @@ impl StaticRouteLike for super::FileRoute { } } +impl StaticRouteLike for super::DirectoryRoute { + unsafe fn set_server(this: *mut Self, server: AnyServer) { + // SAFETY: caller guarantees `this` is live. + unsafe { (*this).set_server(Some(server)) }; + } + unsafe fn on_request( + this: *mut Self, + req: bun_uws_sys::AnyRequest, + resp: bun_uws_sys::AnyResponse, + ) { + Self::on_request(this, req, resp) + } + unsafe fn on_head_request( + this: *mut Self, + req: bun_uws_sys::AnyRequest, + resp: bun_uws_sys::AnyResponse, + ) { + Self::on_head_request(this, req, resp) + } +} + impl StaticRouteLike for super::html_bundle::Route { unsafe fn set_server(this: *mut Self, server: AnyServer) { // SAFETY: caller guarantees `this` is live. diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index c47f970f7c72..1bdd78af123a 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -77,6 +77,10 @@ pub use static_route::StaticRoute; pub mod file_route; pub use file_route::FileRoute; +#[path = "DirectoryRoute.rs"] +pub mod directory_route; +pub use directory_route::DirectoryRoute; + #[path = "FileResponseStream.rs"] pub mod file_response_stream; pub use file_response_stream::FileResponseStream; @@ -144,6 +148,8 @@ pub enum AnyRoute { Static(core::ptr::NonNull), /// Serve a file from disk File(core::ptr::NonNull), + /// Serve a directory tree — `"/static/*": { dir: "./public" }` + Directory(core::ptr::NonNull), /// Bundle an HTML import — `import html from "./index.html"; "/": html` Html(bun_ptr::RefPtr), /// Use file-system routing — `"/*": { dir: …, style: "nextjs-pages" }` @@ -161,6 +167,7 @@ impl AnyRoute { match self { AnyRoute::Static(p) => bun_ptr::BackRef::from(*p).memory_cost(), AnyRoute::File(p) => bun_ptr::BackRef::from(*p).memory_cost(), + AnyRoute::Directory(p) => bun_ptr::BackRef::from(*p).memory_cost(), AnyRoute::Html(r) => r.data().memory_cost(), AnyRoute::FrameworkRouter(_) => { core::mem::size_of::() @@ -172,6 +179,7 @@ impl AnyRoute { match self { AnyRoute::Static(p) => bun_ptr::BackRef::from(*p).ref_(), AnyRoute::File(p) => bun_ptr::BackRef::from(*p).ref_(), + AnyRoute::Directory(p) => bun_ptr::BackRef::from(*p).ref_(), AnyRoute::Html(r) => { // SAFETY: RefPtr keeps the pointee live while held in the route table. unsafe { bun_ptr::RefCount::::ref_(r.as_ptr()) }; @@ -185,6 +193,8 @@ impl AnyRoute { AnyRoute::Static(p) => unsafe { StaticRoute::deref_(p.as_ptr()) }, // SAFETY: see above. AnyRoute::File(p) => unsafe { FileRoute::deref(p.as_ptr()) }, + // SAFETY: see above. + AnyRoute::Directory(p) => unsafe { DirectoryRoute::deref(p.as_ptr()) }, AnyRoute::Html(r) => r.deref(), AnyRoute::FrameworkRouter(_) => {} // not reference counted } @@ -2370,6 +2380,29 @@ impl NewServer { } } } + AnyRoute::Directory(p) => { + server_config::apply_static_route::( + any_server, + app, + p.as_ptr(), + &entry.path, + entry.method, + path_has_user_head_route, + ); + if Self::HAS_H3 { + if let Some(h3_app) = self.h3_app { + server_config::apply_static_route_h3::( + any_server, + // S008: `h3::App` is an `opaque_ffi!` ZST — safe deref. + bun_opaque::opaque_deref_mut(h3_app), + p.as_ptr(), + &entry.path, + entry.method, + path_has_user_head_route, + ); + } + } + } AnyRoute::Html(r) => { server_config::apply_static_route::( any_server, diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index b14647a9bc51..a05687551be7 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -772,20 +772,49 @@ impl AnyRoute { if let Some(dir) = argument.get_optional_slice(global, b"dir")? { let relative_root = init_ctx.js_string_allocations.track(dir); - let style: FrameworkRouter::Style = - if let Some(style_js) = argument.get(global, b"style")? { - FrameworkRouter::Style::from_js(style_js, global)? - } else { - FrameworkRouter::Style::NextjsPages - }; - // Style impls Drop; `?` drops it on the error path. - if !strings::ends_with(path, b"/*") { return Err(global.throw_invalid_arguments(format_args!( "To mount a directory, make sure the path ends in `/*`" ))); } + let style_js = argument.get(global, b"style")?; + if style_js.is_none() { + if strings::index_of_char(path, b':').is_some() { + return Err(global.throw_invalid_arguments(format_args!( + "Directory routes do not support :parameters; use a fixed prefix ending in `/*`" + ))); + } + if strings::contains(path, b"//") { + return Err(global.throw_invalid_arguments(format_args!( + "Directory route paths cannot contain empty segments" + ))); + } + // `{ dir }` without `style` serves the directory tree + // verbatim; `{ dir, style }` opts into framework routing. + let url_prefix: &[u8] = if path.len() == 2 { + b"/" + } else { + &path[..path.len() - 1] + }; + let stat_cache = argument + .get_boolean_loose(global, b"statCache")? + .unwrap_or(true); + let route = super::DirectoryRoute::create( + global, + relative_root, + url_prefix, + stat_cache, + )?; + return Ok(Some(AnyRoute::Directory(NonNull::new(route).expect( + "DirectoryRoute::create returns a fresh heap allocation", + )))); + } + + let style: FrameworkRouter::Style = + FrameworkRouter::Style::from_js(style_js.unwrap(), global)?; + // Style impls Drop; `?` drops it on the error path. + // trim the /* // NOTE: `FileSystemRouterType` fields are `Cow<'static,[u8]>`. // Rather diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 90d4cdbac33f..45450350f79f 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -1935,6 +1935,28 @@ mod posix_impl { super::linux_syscall::openat2_beneath(dir, path, flags, mode) .map_err(|e| Error::from_code_int(e, Tag::open).with_path(path.as_bytes())) } + /// `openat2(RESOLVE_IN_ROOT | RESOLVE_NO_MAGICLINKS)`: resolves `path` as + /// if `dir` were `/`. Falls back to plain `openat` on kernels without + /// `openat2` (or when seccomp blocks it), caching the unavailability. + #[cfg(any(target_os = "linux", target_os = "android"))] + pub fn openat2_in_root(dir: impl AsFd, path: &ZStr, flags: i32, mode: Mode) -> Maybe { + use core::sync::atomic::{AtomicBool, Ordering}; + static UNAVAILABLE: AtomicBool = AtomicBool::new(false); + + let dir = dir.as_fd(); + if !UNAVAILABLE.load(Ordering::Relaxed) { + match super::linux_syscall::openat2_in_root(dir, path, flags, mode) { + Ok(fd) => return Ok(fd), + Err(libc::ENOSYS | libc::EPERM | libc::EINVAL | libc::E2BIG) => { + UNAVAILABLE.store(true, Ordering::Relaxed); + } + Err(e) => { + return Err(Error::from_code_int(e, Tag::open).with_path(path.as_bytes())); + } + } + } + openat(dir, path, flags, mode) + } pub fn close(fd: Fd) -> Maybe<()> { // Call close ONCE; never retry on EINTR (Linux may have already // released the fd, retrying would close someone else's). Only EBADF surfaces. diff --git a/src/sys/linux_syscall.rs b/src/sys/linux_syscall.rs index 433cbb830ab3..40c6c95f8cf4 100644 --- a/src/sys/linux_syscall.rs +++ b/src/sys/linux_syscall.rs @@ -106,6 +106,23 @@ pub(crate) fn openat2_beneath(dir: Fd, path: &ZStr, flags: i32, mode: Mode) -> R .map(own_fd) } +#[inline] +pub(crate) fn openat2_in_root(dir: Fd, path: &ZStr, flags: i32, mode: Mode) -> Result { + let oflags = rustix::fs::OFlags::from_bits_retain(flags as u32); + let mode = rustix::fs::Mode::from_raw_mode(mode); + let dir = dir.as_borrowed_fd(); + retry(|| { + rustix::fs::openat2( + dir, + path.as_cstr(), + oflags, + mode, + rustix::fs::ResolveFlags::IN_ROOT | rustix::fs::ResolveFlags::NO_MAGICLINKS, + ) + }) + .map(own_fd) +} + #[inline] pub(crate) fn read(fd: Fd, buf: &mut [u8]) -> Result { let fd = fd.as_borrowed_fd(); diff --git a/test/js/bun/http/serve-directory-routes.test.ts b/test/js/bun/http/serve-directory-routes.test.ts new file mode 100644 index 000000000000..14d0fd41a18a --- /dev/null +++ b/test/js/bun/http/serve-directory-routes.test.ts @@ -0,0 +1,713 @@ +import { serve, type Server } from "bun"; +import { afterEach, describe, expect, it } from "bun:test"; +import { symlinkSync } from "fs"; +import { isLinux, tempDir } from "harness"; +import { join } from "path"; + +describe("Bun.serve() directory routes", () => { + let server: Server | undefined; + + afterEach(() => { + server?.stop(true); + server = undefined; + }); + + // fetch() normalizes `..` client-side, so the traversal/adversarial tests + // send raw request bytes over a socket. + async function raw( + path: string, + ): Promise<{ status: number; headers: Record; head: string; body: string }> { + const { promise, resolve } = Promise.withResolvers(); + let buf = ""; + const sock = await Bun.connect({ + hostname: "127.0.0.1", + port: server!.port, + socket: { + data(_s, chunk) { + buf += chunk.toString("latin1"); + }, + close() { + resolve(buf); + }, + error() { + resolve(buf); + }, + }, + }); + sock.write(`GET ${path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n`); + const full = await promise; + const status = parseInt(full.slice(9, 12), 10); + const headEnd = full.indexOf("\r\n\r\n"); + const head = full.slice(0, headEnd); + const headers: Record = {}; + for (const line of head.slice(head.indexOf("\r\n") + 2).split("\r\n")) { + const i = line.indexOf(":"); + if (i > 0) headers[line.slice(0, i).toLowerCase()] = line.slice(i + 1).trim(); + } + const body = full.slice(headEnd + 4); + return { status, headers, head, body }; + } + + it("serves files from a directory at /*", async () => { + using dir = tempDir("serve-dir-root", { + "public/index.html": "

Hello World

", + "public/style.css": "body { margin: 0; }", + "public/script.js": "console.log('hello');", + }); + + server = serve({ + port: 0, + routes: { "/*": { dir: join(String(dir), "public") } }, + }); + + const html = await fetch(`${server.url}index.html`); + expect(html.status).toBe(200); + expect(html.headers.get("content-type")).toContain("text/html"); + expect(await html.text()).toBe("

Hello World

"); + + const css = await fetch(`${server.url}style.css`); + expect(css.status).toBe(200); + expect(css.headers.get("content-type")).toContain("text/css"); + expect(await css.text()).toBe("body { margin: 0; }"); + + const js = await fetch(`${server.url}script.js`); + expect(js.status).toBe(200); + expect(js.headers.get("content-type")).toContain("javascript"); + expect(await js.text()).toBe("console.log('hello');"); + }); + + it("serves nested directories", async () => { + using dir = tempDir("serve-dir-nested", { + "public/assets/images/logo.svg": "", + "public/assets/styles/main.css": "body { color: red; }", + "public/js/app.js": "const x = 1;", + }); + + server = serve({ + port: 0, + routes: { "/*": { dir: join(String(dir), "public") } }, + }); + + expect(await (await fetch(`${server.url}assets/images/logo.svg`)).text()).toBe(""); + expect(await (await fetch(`${server.url}assets/styles/main.css`)).text()).toBe("body { color: red; }"); + expect(await (await fetch(`${server.url}js/app.js`)).text()).toBe("const x = 1;"); + }); + + it("serves from a custom prefix", async () => { + using dir = tempDir("serve-dir-prefix", { + "assets/file.txt": "Hello from assets", + "assets/sub/deep.txt": "deep", + }); + + server = serve({ + port: 0, + routes: { "/static/*": { dir: join(String(dir), "assets") } }, + fetch: () => new Response("fallback", { status: 404 }), + }); + + const res = await fetch(`${server.url}static/file.txt`); + expect(res.status).toBe(200); + expect(await res.text()).toBe("Hello from assets"); + + const deep = await fetch(`${server.url}static/sub/deep.txt`); + expect(deep.status).toBe(200); + expect(await deep.text()).toBe("deep"); + }); + + it("serves index.html for directory requests", async () => { + using dir = tempDir("serve-dir-index", { + "public/index.html": "

root

", + "public/sub/index.html": "

sub

", + }); + + server = serve({ + port: 0, + routes: { "/*": { dir: join(String(dir), "public") } }, + }); + + const root = await fetch(`${server.url}`); + expect(root.status).toBe(200); + expect(root.headers.get("content-type")).toContain("text/html"); + expect(await root.text()).toBe("

root

"); + + const sub = await fetch(`${server.url}sub/`); + expect(sub.status).toBe(200); + expect(await sub.text()).toBe("

sub

"); + + // Without a trailing slash the server 301-redirects to the slash form so + // the followed request re-enters routing. + const noSlash = await fetch(`${server.url}sub`, { redirect: "manual" }); + expect(noSlash.status).toBe(301); + expect(noSlash.headers.get("location")).toBe("/sub/"); + + const withQuery = await fetch(`${server.url}sub?v=1`, { redirect: "manual" }); + expect(withQuery.status).toBe(301); + expect(withQuery.headers.get("location")).toBe("/sub/?v=1"); + + const followed = await fetch(`${server.url}sub`); + expect(followed.status).toBe(200); + expect(await followed.text()).toBe("

sub

"); + + // A trailing slash on a regular file is a miss (nginx/send behavior). + const fileSlash = await fetch(`${server.url}index.html/`, { redirect: "manual" }); + expect(fileSlash.status).toBe(404); + }); + + it("sets Content-Type case-insensitively by extension", async () => { + using dir = tempDir("serve-dir-mime-case", { + "public/photo.JPG": Buffer.alloc(8, 0).toString("binary"), + "public/style.CSS": "body{}", + "public/app.MJS": "export{}", + }); + server = serve({ + port: 0, + routes: { "/*": { dir: join(String(dir), "public") } }, + }); + + const jpg = await fetch(`${server.url}photo.JPG`); + expect(jpg.status).toBe(200); + expect(jpg.headers.get("content-type")).toContain("image/jpeg"); + + const css = await fetch(`${server.url}style.CSS`); + expect(css.headers.get("content-type")).toContain("text/css"); + + const mjs = await fetch(`${server.url}app.MJS`); + expect(mjs.headers.get("content-type")).toContain("javascript"); + }); + + describe.each(["/static/*", "/*"] as const)("mounted at %s", prefix => { + const base = prefix === "/*" ? "" : "static/"; + it("returns 404 for missing files", async () => { + using dir = tempDir("serve-dir-404", { + "public/exists.txt": "yes", + }); + + server = serve({ + port: 0, + routes: { [prefix]: { dir: join(String(dir), "public") } }, + fetch: () => new Response("fallback", { status: 200 }), + }); + + const hit = await fetch(`${server.url}${base}exists.txt`); + expect(hit.status).toBe(200); + expect(await hit.text()).toBe("yes"); + + // A miss returns a plain 404 from the directory route itself; the + // fetch handler is not consulted for paths under the mounted prefix. + const miss = await fetch(`${server.url}${base}nope.txt`); + expect(miss.status).toBe(404); + expect(await miss.text()).toBe(""); + }); + }); + + it("supports HEAD", async () => { + using dir = tempDir("serve-dir-head", { + "public/large.txt": Buffer.alloc(10000, "x").toString(), + }); + + server = serve({ + port: 0, + routes: { "/*": { dir: join(String(dir), "public") } }, + }); + + const res = await fetch(`${server.url}large.txt`, { method: "HEAD" }); + expect(res.status).toBe(200); + expect(res.headers.get("content-length")).toBe("10000"); + expect(await res.text()).toBe(""); + }); + + it("sends Last-Modified and a weak ETag", async () => { + using dir = tempDir("serve-dir-lm", { + "public/data.json": '{"key":"value"}', + }); + + server = serve({ + port: 0, + routes: { "/*": { dir: join(String(dir), "public") } }, + }); + + const res = await fetch(`${server.url}data.json`); + expect(res.status).toBe(200); + expect(res.headers.get("last-modified")).toBeTruthy(); + const etag = res.headers.get("etag"); + expect(etag).toMatch(/^W\/"[0-9a-f]+-[0-9a-f]+"$/); + expect(res.headers.get("accept-ranges")).toBe("bytes"); + }); + + it("honors If-None-Match with 304", async () => { + using dir = tempDir("serve-dir-inm", { + "public/data.json": '{"key":"value"}', + }); + + server = serve({ + port: 0, + routes: { "/*": { dir: join(String(dir), "public") } }, + }); + + const first = await fetch(`${server.url}data.json`); + const etag = first.headers.get("etag")!; + expect(etag).toBeTruthy(); + + const second = await fetch(`${server.url}data.json`, { + headers: { "if-none-match": etag }, + }); + expect(second.status).toBe(304); + expect(await second.text()).toBe(""); + }); + + it("honors If-Modified-Since with 304", async () => { + using dir = tempDir("serve-dir-ims", { + "public/data.json": '{"key":"value"}', + }); + + server = serve({ + port: 0, + routes: { "/*": { dir: join(String(dir), "public") } }, + }); + + const first = await fetch(`${server.url}data.json`); + const lm = first.headers.get("last-modified")!; + expect(lm).toBeTruthy(); + + const second = await fetch(`${server.url}data.json`, { + headers: { "if-modified-since": lm }, + }); + expect(second.status).toBe(304); + + const stale = await fetch(`${server.url}data.json`, { + headers: { "if-modified-since": "Sat, 01 Jan 2000 00:00:00 GMT" }, + }); + expect(stale.status).toBe(200); + expect(await stale.text()).toBe('{"key":"value"}'); + }); + + it("handles Range requests", async () => { + using dir = tempDir("serve-dir-range", { + "public/data.bin": "0123456789", + }); + + server = serve({ + port: 0, + routes: { "/*": { dir: join(String(dir), "public") } }, + }); + + const res = await fetch(`${server.url}data.bin`, { + headers: { range: "bytes=2-5" }, + }); + expect(res.status).toBe(206); + expect(res.headers.get("content-range")).toBe("bytes 2-5/10"); + expect(await res.text()).toBe("2345"); + + const unsat = await fetch(`${server.url}data.bin`, { + headers: { range: "bytes=100-200" }, + }); + expect(unsat.status).toBe(416); + expect(unsat.headers.get("content-range")).toBe("bytes */10"); + }); + + it("rejects path traversal", async () => { + using dir = tempDir("serve-dir-traversal", { + "secret.txt": "SECRET", + "public/ok.txt": "ok", + }); + + server = serve({ + port: 0, + routes: { "/static/*": { dir: join(String(dir), "public") } }, + fetch: () => new Response("fallback", { status: 404 }), + }); + + expect(await (await fetch(`${server.url}static/ok.txt`)).text()).toBe("ok"); + + for (const p of [ + "/static/../secret.txt", + "/static/..%2Fsecret.txt", + "/static/%2e%2e/secret.txt", + "/static/%2e%2e%2fsecret.txt", + "/static/ok.txt/../../secret.txt", + "/static/a%00.txt", + "/static/a%5Cb.txt", + "/static/c:/windows/win.ini", + "/static/c%3A/windows/win.ini", + "/static/ok.txt::$DATA", + ]) { + const { status, body } = await raw(p); + expect(body).not.toContain("SECRET"); + expect([404, 400]).toContain(status); + } + + // Double-encoded `..` should decode once to `%2e%2e` and miss on disk. + const dbl = await raw("/static/%252e%252e/secret.txt"); + expect(dbl.body).not.toContain("SECRET"); + }); + + it.skipIf(!isLinux)("rejects symlink escapes on Linux via RESOLVE_IN_ROOT", async () => { + using dir = tempDir("serve-dir-symlink", { + "secret.txt": "SECRET", + "public/ok.txt": "ok", + "public/inside.txt": "inside", + }); + + const root = String(dir); + // Absolute target: IN_ROOT resolves it against dirfd, so this looks for + // public/ which doesn't exist. + symlinkSync(join(root, "secret.txt"), join(root, "public", "escape-abs")); + // Relative target climbing out: `..` is clamped at the root. + symlinkSync("../secret.txt", join(root, "public", "escape-rel")); + symlinkSync("inside.txt", join(root, "public", "alias")); + + server = serve({ + port: 0, + routes: { "/static/*": { dir: join(root, "public") } }, + fetch: () => new Response("fallback", { status: 404 }), + }); + + // Symlink that stays inside the root is allowed. + const inside = await fetch(`${server.url}static/alias`); + expect(inside.status).toBe(200); + expect(await inside.text()).toBe("inside"); + + // Symlinks that escape the root are clamped by the kernel. + for (const name of ["escape-abs", "escape-rel"]) { + const res = await fetch(`${server.url}static/${name}`); + expect(await res.text()).not.toContain("SECRET"); + expect(res.status).toBe(404); + } + }); + + it("percent-decodes file names", async () => { + using dir = tempDir("serve-dir-pct", { + "public/hello world.txt": "hi", + }); + + server = serve({ + port: 0, + routes: { "/*": { dir: join(String(dir), "public") } }, + }); + + const res = await fetch(`${server.url}hello%20world.txt`); + expect(res.status).toBe(200); + expect(await res.text()).toBe("hi"); + }); + + it("ignores the query string", async () => { + using dir = tempDir("serve-dir-query", { + "public/app.js": "ok", + "public/index.html": "root", + }); + + server = serve({ + port: 0, + routes: { "/*": { dir: join(String(dir), "public") } }, + }); + + const res = await fetch(`${server.url}app.js?v=abc123`); + expect(res.status).toBe(200); + expect(await res.text()).toBe("ok"); + + const root = await fetch(`${server.url}?foo=bar`); + expect(root.status).toBe(200); + expect(await root.text()).toBe("root"); + }); + + it("supports multiple directory routes", async () => { + using dir = tempDir("serve-dir-multi", { + "a/one.txt": "one", + "b/two.txt": "two", + }); + + server = serve({ + port: 0, + routes: { + "/a/*": { dir: join(String(dir), "a") }, + "/b/*": { dir: join(String(dir), "b") }, + }, + }); + + expect(await (await fetch(`${server.url}a/one.txt`)).text()).toBe("one"); + expect(await (await fetch(`${server.url}b/two.txt`)).text()).toBe("two"); + }); + + it("rejects non-wildcard paths", () => { + using dir = tempDir("serve-dir-nowild", { "public/x.txt": "x" }); + expect(() => + serve({ + port: 0, + routes: { "/static": { dir: join(String(dir), "public") } }, + }), + ).toThrow(/ends in `\/\*`/); + }); + + it("rejects :parameters in the route path", () => { + using dir = tempDir("serve-dir-param", { "public/x.txt": "x" }); + expect(() => + serve({ + port: 0, + routes: { "/users/:id/files/*": { dir: join(String(dir), "public") } }, + }), + ).toThrow(/do not support :parameters/); + }); + + it("rejects empty segments in the route path", () => { + using dir = tempDir("serve-dir-empty-seg", { "public/x.txt": "x" }); + for (const key of ["//*", "//assets/*", "/a//*"]) { + expect(() => + serve({ + port: 0, + routes: { [key]: { dir: join(String(dir), "public") } }, + }), + ).toThrow(/empty segments/); + } + }); + + it("throws if the directory does not exist", () => { + using dir = tempDir("serve-dir-enoent", {}); + expect(() => + serve({ + port: 0, + routes: { "/static/*": { dir: join(String(dir), "does-not-exist") } }, + }), + ).toThrow(expect.objectContaining({ code: "ENOENT" })); + }); + + it("serves correctly with more unique paths than stat-cache slots", async () => { + const N = 300; // > STAT_CACHE_SLOTS (256) + const files: Record = {}; + for (let i = 0; i < N; i++) files[`public/f${i}.txt`] = `v${i}`; + using dir = tempDir("serve-dir-exhaust", files); + + server = serve({ + port: 0, + routes: { "/*": { dir: join(String(dir), "public") } }, + }); + + // Walk the full set twice so every slot is guaranteed to have been + // evicted and reused between the two visits to any given path. + for (let pass = 0; pass < 2; pass++) { + const batch = 32; + for (let base = 0; base < N; base += batch) { + const chunk = Array.from({ length: Math.min(batch, N - base) }, (_, j) => base + j); + const bodies = await Promise.all(chunk.map(i => fetch(`${server!.url}f${i}.txt`).then(r => r.text()))); + expect(bodies).toEqual(chunk.map(i => `v${i}`)); + } + } + + // After eviction churn, conditionals on a hot path still work. + const first = await fetch(`${server.url}f0.txt`); + const lm = first.headers.get("last-modified")!; + const etag = first.headers.get("etag")!; + expect((await fetch(`${server.url}f0.txt`, { headers: { "if-modified-since": lm } })).status).toBe(304); + expect((await fetch(`${server.url}f0.txt`, { headers: { "if-none-match": etag } })).status).toBe(304); + }); + + describe.each([true, false])("statCache: %p", statCache => { + it("serves and honors conditionals", async () => { + using dir = tempDir(`serve-dir-cache-${statCache}`, { + "public/a.txt": "a", + }); + server = serve({ + port: 0, + routes: { "/*": { dir: join(String(dir), "public"), statCache } }, + }); + + const res = await fetch(`${server.url}a.txt`); + expect(res.status).toBe(200); + expect(await res.text()).toBe("a"); + const lm = res.headers.get("last-modified")!; + expect(lm).toBeTruthy(); + expect(res.headers.get("etag")).toMatch(/^W\/"/); + + const cond = await fetch(`${server.url}a.txt`, { headers: { "if-modified-since": lm } }); + expect(cond.status).toBe(304); + }); + }); + + it("handles a concurrent burst on one file", async () => { + using dir = tempDir("serve-dir-burst", { + "public/hot.txt": "hot", + }); + server = serve({ + port: 0, + routes: { "/*": { dir: join(String(dir), "public") } }, + }); + + const results = await Promise.all( + Array.from({ length: 128 }, () => fetch(`${server!.url}hot.txt`).then(r => r.text())), + ); + expect(results.every(r => r === "hot")).toBe(true); + }); + + it("cannot bypass a more-specific overlapping route via path manipulation", async () => { + using dir = tempDir("serve-dir-bypass", { + "public/admin/secret.txt": "SECRET", + "public/admin/index.html": "SECRET-INDEX", + "public/secret.pdf": "SECRET-PDF", + "public/ok.txt": "ok", + }); + + server = serve({ + port: 0, + routes: { + "/static/admin/*": () => new Response("auth", { status: 401 }), + "/static/secret.pdf": () => new Response("auth", { status: 401 }), + "/static/*": { dir: join(String(dir), "public") }, + }, + }); + + // Canonical path hits the inner route. + expect((await fetch(`${server.url}static/admin/secret.txt`)).status).toBe(401); + expect((await fetch(`${server.url}static/admin/`)).status).toBe(401); + expect((await fetch(`${server.url}static/secret.pdf`)).status).toBe(401); + + // Non-canonical forms that uWS routes to the outer wildcard must not + // reach public/admin/ via the directory route. + for (const p of [ + "/static//admin/secret.txt", + "/static/./admin/secret.txt", + "/static/x/../admin/secret.txt", + "/static/admin%2Fsecret.txt", + // `%XX` encoding a character that can appear literally in a path + // segment: uWS sees `%61dmin` != `admin` and routes to `/static/*`. + "/static/%61dmin/secret.txt", + "/static/admi%6E/secret.txt", + "/static/ad%4Din/secret.txt", + "/static/%61dmin/", + ]) { + const r = await raw(p); + expect(r.body).not.toContain("SECRET"); + expect([401, 404]).toContain(r.status); + } + + // A directory hit without a trailing slash 301-redirects to the slash + // form, which re-enters routing and matches `/static/admin/*`. It must + // not serve `admin/index.html` directly. + const noSlash = await raw("/static/admin"); + expect(noSlash.body).not.toContain("SECRET"); + expect(noSlash.status).toBe(301); + expect(noSlash.headers.location).toBe("/static/admin/"); + // Exactly one Content-Length header (strict intermediaries reject duplicates). + expect(noSlash.head.toLowerCase().match(/^content-length:/gm)?.length).toBe(1); + expect((await fetch(`${server.url}static/admin`)).status).toBe(401); + + // A trailing slash on a regular file routes past the exact `/static/secret.pdf` + // handler in uWS; the directory route must not strip the slash and serve it. + const fileSlash = await raw("/static/secret.pdf/"); + expect(fileSlash.body).not.toContain("SECRET"); + expect(fileSlash.status).toBe(404); + + // Canonical paths under the outer route still work. + expect(await (await fetch(`${server.url}static/ok.txt`)).text()).toBe("ok"); + }); + + it("yields to more-specific overlapping routes", async () => { + using dir = tempDir("serve-dir-precedence", { + "public/file.txt": "from-dir", + "public/api": "from-dir", + "public/sub/x.txt": "parent", + "public/other.txt": "from-dir", + "inner/x.txt": "inner", + }); + + server = serve({ + port: 0, + routes: { + "/static/file.txt": new Response("exact", { status: 200 }), + "/static/api": () => new Response("handler"), + "/static/sub/*": { dir: join(String(dir), "inner") }, + "/static/*": { dir: join(String(dir), "public") }, + }, + }); + + // Exact static route beats the wildcard directory. + expect(await (await fetch(`${server.url}static/file.txt`)).text()).toBe("exact"); + // Handler route beats the directory. + expect(await (await fetch(`${server.url}static/api`)).text()).toBe("handler"); + // More specific wildcard prefix beats the broader one. + expect(await (await fetch(`${server.url}static/sub/x.txt`)).text()).toBe("inner"); + // Paths only the broad wildcard matches still reach it. + expect(await (await fetch(`${server.url}static/other.txt`)).text()).toBe("from-dir"); + }); + + it.skipIf(!isLinux)("is case-sensitive on a case-sensitive filesystem", async () => { + using dir = tempDir("serve-dir-case", { + "public/File.txt": "upper", + }); + server = serve({ + port: 0, + routes: { "/static/*": { dir: join(String(dir), "public") } }, + fetch: () => new Response("miss", { status: 404 }), + }); + + expect(await (await fetch(`${server.url}static/File.txt`)).text()).toBe("upper"); + expect((await fetch(`${server.url}static/file.txt`)).status).toBe(404); + expect((await fetch(`${server.url}static/FILE.TXT`)).status).toBe(404); + }); + + it("rejects adversarial inputs", async () => { + using dir = tempDir("serve-dir-adversarial", { + "secret.txt": "SECRET", + "public/ok.txt": "ok", + "public/a/b/c/d/e/f/g/h/target.txt": "deep", + "public/\u00e9.txt": "utf8", + }); + + server = serve({ + port: 0, + routes: { "/static/*": { dir: join(String(dir), "public") } }, + fetch: () => new Response("fallback", { status: 404 }), + }); + + // `..`, `.`, empty segments, and encoded `/` are rejected outright so the + // served path matches what uWS routed on (route-precedence parity). + for (const p of [ + "/static/a/b/c/d/e/f/g/h/../../../../../../../../a/b/c/d/e/f/g/h/target.txt", + "/static/a/b/c/d/e/f/g/h/../../../../../../../../../secret.txt", + "/static////////ok.txt", + "/static/./ok.txt", + "/static/a/../ok.txt", + "/static/a%2Fb%2Fok.txt", + ]) { + const r = await raw(p); + expect(r.body).not.toContain("SECRET"); + expect(r.status).toBe(404); + } + + // Absolute-form request-target (RFC 9112 §3.2.2). + const abs = await raw("http://x/static/ok.txt"); + expect(abs.status).toBe(200); + expect(abs.body).toContain("ok"); + + // Percent-encoded UTF-8 filename. + const utf8 = await fetch(`${server.url}static/%C3%A9.txt`); + expect(utf8.status).toBe(200); + expect(await utf8.text()).toBe("utf8"); + + // Paths at and around PATH_MAX (1024 on macOS, 4096 on Linux) must not + // crash the server; they yield or 404. + for (const len of [1023, 1024, 1025, 4095, 4096, 4097, 8000]) { + const r = await raw("/static/" + Buffer.alloc(len, "a").toString()); + expect([404, 400, 414]).toContain(r.status); + } + // Server still responds after the boundary probes. + expect((await fetch(`${server.url}static/ok.txt`)).status).toBe(200); + + // Oversized Range start must not overflow. + const hugeRange = await fetch(`${server.url}static/ok.txt`, { + headers: { range: "bytes=999999999999999999999999-" }, + }); + expect([200, 416]).toContain(hugeRange.status); + }); + + it("survives server.reload()", async () => { + using dir = tempDir("serve-dir-reload", { "public/x.txt": "x" }); + server = serve({ + port: 0, + routes: { "/static/*": { dir: join(String(dir), "public") } }, + }); + expect(await (await fetch(`${server.url}static/x.txt`)).text()).toBe("x"); + server.reload({ + routes: { "/static/*": { dir: join(String(dir), "public") } }, + }); + expect(await (await fetch(`${server.url}static/x.txt`)).text()).toBe("x"); + }); +});