diff --git a/arvik-core/src/accept.rs b/arvik-core/src/accept.rs index d872239..1972f48 100644 --- a/arvik-core/src/accept.rs +++ b/arvik-core/src/accept.rs @@ -37,26 +37,51 @@ pub fn parse_accept_encodings(value: &str) -> Vec<(String, f32)> { /// /// An exact `;q=0` entry refuses that coding even when `*` is present; codings /// not mentioned at all fall back to the `*` wildcard's weight. +/// +/// Allocation-free: compares case-insensitively over the raw header slice. pub fn negotiate<'a>(supported: &[&'a str], accept_encoding: &str) -> Option<&'a str> { - let entries = parse_accept_encodings(accept_encoding); - let wildcard_q = entries - .iter() - .rev() - .find(|(coding, _)| coding == "*") - .map(|(_, q)| *q) - .unwrap_or(0.0); + for candidate in supported { + let mut exact_seen = false; + let mut exact_allowed = false; + let mut wildcard_allowed = false; - supported.iter().copied().find(|candidate| { - let candidate = candidate.to_ascii_lowercase(); - match entries - .iter() - .rev() - .find(|(coding, _)| *coding == candidate) + for entry in accept_encoding.split(',') { + let mut segments = entry.trim().split(';'); + let coding = segments.next().map(str::trim).unwrap_or(""); + if coding.eq_ignore_ascii_case(candidate) { + exact_seen = true; + exact_allowed = parse_q(segments) > 0.0; + } else if coding == "*" { + wildcard_allowed = parse_q(segments) > 0.0; + } + } + + let allowed = if exact_seen { + exact_allowed + } else { + wildcard_allowed + }; + if allowed { + return Some(candidate); + } + } + None +} + +/// Parse the `;q=` parameter from the remaining attribute segments +/// (defaulting to 1.0). +fn parse_q<'a, I: Iterator>(segments: I) -> f32 { + let mut quality = 1.0_f32; + for parameter in segments { + let parameter = parameter.trim(); + if let Some(q) = parameter + .strip_prefix("q=") + .or_else(|| parameter.strip_prefix("Q=")) { - Some((_, q)) => *q > 0.0, - None => wildcard_q > 0.0, + quality = q.trim().parse().unwrap_or(1.0); } - }) + } + quality.clamp(0.0, 1.0) } #[cfg(test)] diff --git a/arvik-core/src/body.rs b/arvik-core/src/body.rs index 620048e..ba2098c 100644 --- a/arvik-core/src/body.rs +++ b/arvik-core/src/body.rs @@ -140,7 +140,10 @@ impl Body { } } - let mut buf = bytes::BytesMut::new(); + // Collect frame handles first (no copying); typical requests arrive + // as a single hyper frame, which is returned zero-copy. + let mut frames: Vec = Vec::new(); + let mut total = 0usize; let mut body = self; while let Some(frame) = BodyExt::frame(&mut body).await { let data = frame?.into_data().map_err(|_| { @@ -149,12 +152,24 @@ impl Body { "unexpected body trailer", )) as BoxError })?; - if buf.len() + data.len() > limit { + total += data.len(); + if total > limit { return Err(BodyLimitError::TooLarge); } - buf.extend_from_slice(&data); + frames.push(data); + } + + match frames.len() { + 0 => Ok(Bytes::new()), + 1 => Ok(frames.pop().unwrap_or_default()), + _ => { + let mut buf = bytes::BytesMut::with_capacity(total); + for frame in frames { + buf.extend_from_slice(&frame); + } + Ok(buf.freeze()) + } } - Ok(buf.freeze()) } /// Collect the entire body into a UTF-8 [`String`]. diff --git a/arvik-core/src/handler.rs b/arvik-core/src/handler.rs index f735b21..53d73e5 100644 --- a/arvik-core/src/handler.rs +++ b/arvik-core/src/handler.rs @@ -180,7 +180,11 @@ pub trait ErasedHandler: Send + Sync { fn clone_box(&self) -> Box>; /// Call this handler, returning a boxed future. - fn call(self: Box, req: Request, state: S) -> BoxFuture<'static, Response>; + /// + /// Takes `&self` — the concrete handler is cloned into the returned + /// future (it must be `Clone`), so dispatch never allocates an extra + /// boxed handler per request. + fn call(&self, req: Request, state: S) -> BoxFuture<'static, Response>; } impl ErasedHandler for ErasedHandlerWrapper @@ -193,8 +197,9 @@ where Box::new(self.clone()) } - fn call(self: Box, req: Request, state: S) -> BoxFuture<'static, Response> { - let fut = self.handler.call(req, state); + fn call(&self, req: Request, state: S) -> BoxFuture<'static, Response> { + let handler = self.handler.clone(); + let fut = handler.call(req, state); Box::pin(fut) } } diff --git a/arvik-core/src/response.rs b/arvik-core/src/response.rs index 632b0f1..21e7e5c 100644 --- a/arvik-core/src/response.rs +++ b/arvik-core/src/response.rs @@ -119,6 +119,18 @@ impl ResponseBuilder { .body(Body::from(html.into())) } + /// Build a plain text response from static data. + /// + /// Zero-copy: both the body bytes and the Content-Type header come from + /// `'static` data, avoiding a String allocation per response. + pub fn text_static(self, text: &'static str) -> Response { + self.header( + http::header::CONTENT_TYPE, + http::header::HeaderValue::from_static("text/plain; charset=utf-8"), + ) + .body(Body::from_static(text.as_bytes())) + } + /// Build a plain text response. /// /// Sets `Content-Type: text/plain; charset=utf-8`. diff --git a/arvik-extract/src/form.rs b/arvik-extract/src/form.rs index bd963ad..457367c 100644 --- a/arvik-extract/src/form.rs +++ b/arvik-extract/src/form.rs @@ -72,8 +72,12 @@ fn form_content_type(headers: &http::HeaderMap) -> bool { Err(_) => return false, }; - // Parse as a media type so parameter suffixes (`;charset=utf-8`) pass but - // bogus extensions (`…urlencodedfoo`) do not. + // Fast path for the exact constant. + if content_type.eq_ignore_ascii_case("application/x-www-form-urlencoded") { + return true; + } + + // Parameterized variants need a real parse. match content_type.parse::() { Ok(mime) => mime.type_() == mime::APPLICATION && mime.subtype() == "x-www-form-urlencoded", Err(_) => false, diff --git a/arvik-extract/src/json.rs b/arvik-extract/src/json.rs index 7fa8f98..fb99035 100644 --- a/arvik-extract/src/json.rs +++ b/arvik-extract/src/json.rs @@ -101,15 +101,21 @@ fn json_content_type(headers: &http::HeaderMap) -> bool { Err(_) => return false, }; - let mime: mime::Mime = match content_type.parse() { - Ok(m) => m, - Err(_) => return false, - }; + // Fast path for the exact constant — avoids a full Mime parse per request. + if content_type.eq_ignore_ascii_case("application/json") { + return true; + } - // Accept application/json and application/*+json (e.g., application/vnd.api+json) - mime.type_() == mime::APPLICATION - && (mime.subtype() == mime::JSON - || mime.suffix().is_some_and(|suffix| suffix == mime::JSON)) + // Parameterized / suffixed variants (e.g. application/vnd.api+json, + // application/json;charset=utf-8) need a real parse. + match content_type.parse::() { + Ok(mime) => { + mime.type_() == mime::APPLICATION + && (mime.subtype() == mime::JSON + || mime.suffix().is_some_and(|suffix| suffix == mime::JSON)) + } + Err(_) => false, + } } #[cfg(test)] diff --git a/arvik-middleware/src/body_limit.rs b/arvik-middleware/src/body_limit.rs index afb7bd2..b7e0deb 100644 --- a/arvik-middleware/src/body_limit.rs +++ b/arvik-middleware/src/body_limit.rs @@ -20,7 +20,6 @@ //! ``` use std::convert::Infallible; -use std::future::Future; use std::pin::Pin; use std::task::{Context, Poll}; @@ -66,11 +65,11 @@ pub struct RequestBodyLimitService { impl Service for RequestBodyLimitService where S: Service + Clone + Send + 'static, - S::Future: Send + 'static, + S::Future: Send + Unpin + 'static, { type Response = Response; type Error = Infallible; - type Future = Pin> + Send + 'static>>; + type Future = RequestBodyLimitFuture; fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { self.inner.poll_ready(cx) @@ -79,42 +78,67 @@ where fn call(&mut self, req: Request) -> Self::Future { let limit = self.limit; + // Fast-path: reject based on Content-Length immediately. Only this + // rare rejection path pays an allocation — the accepted path returns + // the inner future directly with no extra boxing. + if let Some(content_length) = req + .headers() + .get(http::header::CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse::().ok()) + { + if content_length > limit { + tracing::warn!( + content_length = content_length, + limit = limit, + "Request body exceeds size limit (Content-Length check)" + ); + return RequestBodyLimitFuture::Reject(std::future::ready(Ok(payload_too_large( + limit, + )))); + } + } + + // For streaming bodies: pass the body through unbuffered with a + // counting cap — readers get an error once the limit is crossed. + let (parts, body) = req.into_request_parts(); + let limited = Body::new(LimitedBody { + inner: body, + limit: limit as u64, + read: 0, + }); + let req = Request::from_request_parts(parts, limited); + let cloned = self.inner.clone(); let mut inner = std::mem::replace(&mut self.inner, cloned); + RequestBodyLimitFuture::Accept(inner.call(req)) + } +} - Box::pin(async move { - // Fast-path: reject based on Content-Length header immediately. - if let Some(content_length) = req - .headers() - .get(http::header::CONTENT_LENGTH) - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.parse::().ok()) - { - if content_length > limit { - tracing::warn!( - content_length = content_length, - limit = limit, - "Request body exceeds size limit (Content-Length check)" - ); - return Ok::(payload_too_large(limit)); - } - } +/// Happy path delegates to the inner service's own (unboxed) future; only +/// the rare over-limit rejection uses a tiny ready future. +pub enum RequestBodyLimitFuture +where + S: Service, +{ + Accept(S::Future), + Reject(std::future::Ready>), +} + +impl std::future::Future for crate::body_limit::RequestBodyLimitFuture +where + S: Service, + S::Future: Send + Unpin + 'static, +{ + type Output = Result; - // For streaming bodies: pass the body through unbuffered with a - // counting cap — readers get an error once the limit is crossed. - let (parts, body) = req.into_request_parts(); - let limited = Body::new(LimitedBody { - inner: body, - limit: limit as u64, - read: 0, - }); - let req = Request::from_request_parts(parts, limited); - inner.call(req).await - }) + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + match &mut *self { + Self::Accept(fut) => std::pin::Pin::new(fut).poll(cx), + Self::Reject(fut) => std::pin::Pin::new(fut).poll(cx), + } } } - -/// Streaming frame filter that errors once more than `limit` bytes have been /// read. Frames themselves are passed through untouched — nothing is buffered. struct LimitedBody { inner: Body, @@ -235,18 +259,15 @@ where { type Response = Response; type Error = Infallible; - type Future = Pin> + Send + 'static>>; + type Future = S::Future; fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { self.inner.poll_ready(cx) } fn call(&mut self, mut req: Request) -> Self::Future { - let cloned = self.inner.clone(); - let mut inner = std::mem::replace(&mut self.inner, cloned); - req.extensions_mut().insert(DefaultBodyLimit(self.limit)); - Box::pin(inner.call(req)) + self.inner.call(req) } } diff --git a/arvik-middleware/src/compression.rs b/arvik-middleware/src/compression.rs index 33833f8..1734495 100644 --- a/arvik-middleware/src/compression.rs +++ b/arvik-middleware/src/compression.rs @@ -143,29 +143,19 @@ impl CompressionLayer { } fn preferred_encoding(&self, accept_encoding: &str) -> Option { - // Priority order zstd > br > gzip > deflate, negotiated with real - // q-value semantics (substring matching used to serve brotli to - // clients that explicitly refused it with `br;q=0`). - let mut candidates = Vec::with_capacity(4); - if self.zstd { - candidates.push(("zstd", Encoding::Zstd)); - } - if self.br { - candidates.push(("br", Encoding::Br)); - } - if self.gzip { - candidates.push(("gzip", Encoding::Gzip)); - } - if self.deflate { - candidates.push(("deflate", Encoding::Deflate)); + // Priority order zstd > br > gzip > deflate. Allocation-free: fixed + // candidate list filtered by the layer flags. + for (enabled, name, encoding) in [ + (self.zstd, "zstd", Encoding::Zstd), + (self.br, "br", Encoding::Br), + (self.gzip, "gzip", Encoding::Gzip), + (self.deflate, "deflate", Encoding::Deflate), + ] { + if enabled && arvik_core::accept::negotiate(&[name], accept_encoding).is_some() { + return Some(encoding); + } } - let names: Vec<&str> = candidates.iter().map(|(name, _)| *name).collect(); - arvik_core::accept::negotiate(&names, accept_encoding).and_then(|chosen| { - candidates - .into_iter() - .find(|(name, _)| *name == chosen) - .map(|(_, encoding)| encoding) - }) + None } } @@ -202,21 +192,29 @@ where fn call(&mut self, req: Request) -> Self::Future { let config = self.config.clone(); - let accept_encoding = req + let is_head = req.method() == http::Method::HEAD; + + // Negotiate up front from the borrowed header — negotiation does not + // depend on the response. A missing/empty Accept-Encoding (plain + // clients, load tools) skips straight to pass-through with zero + // allocations. + let encoding = req .headers() .get(ACCEPT_ENCODING) - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_string(); + .and_then(|value| value.to_str().ok()) + .filter(|value| !value.is_empty()) + .and_then(|accept| config.preferred_encoding(accept)); let cloned = self.inner.clone(); let mut inner = std::mem::replace(&mut self.inner, cloned); - let is_head = req.method() == http::Method::HEAD; - Box::pin(async move { let response = inner.call(req).await?; + let Some(encoding) = encoding else { + return Ok(response); + }; + // Don't compress if already encoded. if response.headers().contains_key(CONTENT_ENCODING) { return Ok(response); @@ -232,11 +230,6 @@ where return Ok(response); } - let encoding = match config.preferred_encoding(&accept_encoding) { - Some(e) => e, - None => return Ok(response), - }; - Ok(compress_response(response, encoding, &config).await) }) } diff --git a/arvik-middleware/src/cors.rs b/arvik-middleware/src/cors.rs index c1b5d3d..d7a7ad6 100644 --- a/arvik-middleware/src/cors.rs +++ b/arvik-middleware/src/cors.rs @@ -86,6 +86,78 @@ struct CorsConfig { expose_headers: Vec, allow_credentials: bool, max_age_secs: Option, + // ── Derived once at construction (audit O15) — these are pure functions + // of config, so preflight/apply paths stop re-joining Strings per request. + methods_hv: HeaderValue, + allow_headers_list_hv: Option, + expose_hv: Option, + max_age_hv: Option, +} + +impl CorsConfig { + fn derive_from( + parts: impl FnOnce() -> ( + AllowOrigin, + AllowMethods, + AllowHeaders, + Vec, + bool, + Option, + ), + ) -> Self { + let ( + allow_origins, + allow_methods, + allow_headers, + expose_headers, + allow_credentials, + max_age_secs, + ) = parts(); + let methods_hv = match &allow_methods { + AllowMethods::Any => { + HeaderValue::from_static("GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS") + } + AllowMethods::List(ms) => { + HeaderValue::from_str(&ms.iter().map(Method::as_str).collect::>().join(", ")) + .unwrap_or_else(|_| HeaderValue::from_static("GET")) + } + }; + let allow_headers_list_hv = match &allow_headers { + AllowHeaders::List(hs) if !hs.is_empty() => HeaderValue::from_str( + &hs.iter() + .map(HeaderName::as_str) + .collect::>() + .join(", "), + ) + .ok(), + _ => None, + }; + let expose_hv = if expose_headers.is_empty() { + None + } else { + HeaderValue::from_str( + &expose_headers + .iter() + .map(HeaderName::as_str) + .collect::>() + .join(", "), + ) + .ok() + }; + let max_age_hv = max_age_secs.and_then(|age| HeaderValue::from_str(&age.to_string()).ok()); + Self { + allow_origins, + allow_methods, + allow_headers, + expose_headers, + allow_credentials, + max_age_secs, + methods_hv, + allow_headers_list_hv, + expose_hv, + max_age_hv, + } + } } #[derive(Debug, Clone)] @@ -121,20 +193,22 @@ impl CorsLayer { /// Use the builder methods to configure origins, methods, and headers. pub fn new() -> Self { Self { - config: Arc::new(CorsConfig { - allow_origins: AllowOrigin::List(Vec::new()), - allow_methods: AllowMethods::List(vec![ - Method::GET, - Method::POST, - Method::PUT, - Method::DELETE, - Method::PATCH, - ]), - allow_headers: AllowHeaders::List(Vec::new()), - expose_headers: Vec::new(), - allow_credentials: false, - max_age_secs: Some(3600), - }), + config: Arc::new(CorsConfig::derive_from(|| { + ( + AllowOrigin::List(Vec::new()), + AllowMethods::List(vec![ + Method::GET, + Method::POST, + Method::PUT, + Method::DELETE, + Method::PATCH, + ]), + AllowHeaders::List(Vec::new()), + Vec::new(), + false, + Some(3600), + ) + })), } } @@ -144,14 +218,16 @@ impl CorsLayer { /// Suitable for fully public read-only APIs. pub fn permissive() -> Self { Self { - config: Arc::new(CorsConfig { - allow_origins: AllowOrigin::Any, - allow_methods: AllowMethods::Any, - allow_headers: AllowHeaders::Any, - expose_headers: Vec::new(), - allow_credentials: false, - max_age_secs: Some(86400), - }), + config: Arc::new(CorsConfig::derive_from(|| { + ( + AllowOrigin::Any, + AllowMethods::Any, + AllowHeaders::Any, + Vec::new(), + false, + Some(86400), + ) + })), } } @@ -165,14 +241,16 @@ impl CorsLayer { /// [`CorsLayer::allow_credentials`]. pub fn very_permissive() -> Self { Self { - config: Arc::new(CorsConfig { - allow_origins: AllowOrigin::Mirror, - allow_methods: AllowMethods::Any, - allow_headers: AllowHeaders::Mirror, - expose_headers: Vec::new(), - allow_credentials: false, - max_age_secs: Some(86400), - }), + config: Arc::new(CorsConfig::derive_from(|| { + ( + AllowOrigin::Mirror, + AllowMethods::Any, + AllowHeaders::Mirror, + Vec::new(), + false, + Some(86400), + ) + })), } } @@ -181,6 +259,17 @@ impl CorsLayer { fn mutate(self, f: F) -> Self { let mut cfg = Arc::try_unwrap(self.config).unwrap_or_else(|arc| (*arc).clone()); f(&mut cfg); + let mut cfg = CorsConfig::derive_from(|| { + ( + cfg.allow_origins, + cfg.allow_methods, + cfg.allow_headers, + std::mem::take(&mut cfg.expose_headers), + cfg.allow_credentials, + cfg.max_age_secs, + ) + }); + let _ = &mut cfg; assert_credentials_safe(&cfg); Self { config: Arc::new(cfg), @@ -411,33 +500,15 @@ impl CorsConfig { } } - fn methods_header(&self) -> HeaderValue { - match &self.allow_methods { - AllowMethods::Any => { - HeaderValue::from_static("GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS") - } - AllowMethods::List(ms) => { - let s = ms.iter().map(Method::as_str).collect::>().join(", "); - HeaderValue::from_str(&s).unwrap_or_else(|_| HeaderValue::from_static("GET")) - } - } + fn methods_header(&self) -> &HeaderValue { + &self.methods_hv } fn headers_header(&self, request_headers: &http::HeaderMap) -> Option { match &self.allow_headers { AllowHeaders::Any => Some(HeaderValue::from_static("*")), AllowHeaders::Mirror => request_headers.get(ACCESS_CONTROL_REQUEST_HEADERS).cloned(), - AllowHeaders::List(hs) => { - if hs.is_empty() { - return None; - } - let s = hs - .iter() - .map(HeaderName::as_str) - .collect::>() - .join(", "); - HeaderValue::from_str(&s).ok() - } + AllowHeaders::List(_) => self.allow_headers_list_hv.clone(), } } @@ -467,10 +538,8 @@ impl CorsConfig { builder = builder.header(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"); } - if let Some(age) = self.max_age_secs { - if let Ok(v) = HeaderValue::from_str(&age.to_string()) { - builder = builder.header(ACCESS_CONTROL_MAX_AGE, v); - } + if let Some(age) = &self.max_age_hv { + builder = builder.header(ACCESS_CONTROL_MAX_AGE, age.clone()); } // Preflight replies are cacheable and differ per Origin, per requested @@ -478,7 +547,9 @@ impl CorsConfig { // cache/CDN cannot serve one page's preflight to another. builder = builder.header( VARY, - "Origin, Access-Control-Request-Method, Access-Control-Request-Headers", + HeaderValue::from_static( + "Origin, Access-Control-Request-Method, Access-Control-Request-Headers", + ), ); builder.body(Body::empty()) @@ -504,16 +575,8 @@ impl CorsConfig { ); } - if !self.expose_headers.is_empty() { - let s = self - .expose_headers - .iter() - .map(HeaderName::as_str) - .collect::>() - .join(", "); - if let Ok(v) = HeaderValue::from_str(&s) { - headers.insert(ACCESS_CONTROL_EXPOSE_HEADERS, v); - } + if let Some(expose) = &self.expose_hv { + headers.insert(ACCESS_CONTROL_EXPOSE_HEADERS, expose.clone()); } // Vary: Origin — ensures caches don't serve wrong-origin responses diff --git a/arvik-middleware/src/rate_limit.rs b/arvik-middleware/src/rate_limit.rs index 71954db..7ba7768 100644 --- a/arvik-middleware/src/rate_limit.rs +++ b/arvik-middleware/src/rate_limit.rs @@ -108,24 +108,34 @@ impl Bucket { // ── Shared, sharded bucket state ───────────────────────────────────────────── -/// Sharded bucket store: each shard has its own lock so concurrent requests -/// with different keys never serialize behind one mutex. +/// One independently locked shard plus its own sweep counter — no cross-core +/// atomic traffic, and sweeps spread across whichever threads touch the shard. +struct Shard { + buckets: Mutex>, + calls: AtomicU64, +} + +/// Sharded bucket store: concurrent requests with different keys never +/// serialize behind one mutex. struct BucketStore { - shards: Vec>>, + shards: Vec, hasher: RandomState, - calls: AtomicU64, } impl BucketStore { fn new(_window: Duration) -> Self { Self { - shards: (0..SHARDS).map(|_| Mutex::new(HashMap::new())).collect(), + shards: (0..SHARDS) + .map(|_| Shard { + buckets: Mutex::new(HashMap::new()), + calls: AtomicU64::new(0), + }) + .collect(), hasher: RandomState::new(), - calls: AtomicU64::new(0), } } - fn shard_for(&self, key: &str) -> &Mutex> { + fn shard_for(&self, key: &str) -> &Shard { let idx = (self.hasher.hash_one(key) % self.shards.len() as u64) as usize; &self.shards[idx] } @@ -134,13 +144,18 @@ impl BucketStore { /// /// Returns `None` when allowed, or `Some(retry_after_secs)` when limited. fn try_consume(&self, key: &str, capacity: u64, window: Duration) -> Option { - // Amortized idle-bucket sweep keeps memory bounded under key churn. - let calls = self.calls.fetch_add(1, Ordering::Relaxed); - if calls % SWEEP_EVERY == 0 { - self.evict_idle(); + let shard = self.shard_for(key); + + // Amortized idle-bucket sweep scoped to this shard — bounded memory + // under key churn without a global contended counter. + if shard.calls.fetch_add(1, Ordering::Relaxed) % SWEEP_EVERY == 0 { + shard + .buckets + .lock() + .retain(|_, bucket| bucket.last_seen.elapsed() < IDLE_TTL); } - let mut map = self.shard_for(key).lock(); + let mut map = shard.buckets.lock(); // Hot path: existing bucket, no allocation. if let Some(bucket) = map.get_mut(key) { return bucket.try_consume(); @@ -150,15 +165,6 @@ impl BucketStore { map.insert(key.to_string(), bucket); outcome } - - /// Drop buckets untouched for longer than [`IDLE_TTL`]. - fn evict_idle(&self) { - for shard in &self.shards { - shard - .lock() - .retain(|_, bucket| bucket.last_seen.elapsed() < IDLE_TTL); - } - } } // ── Key extraction ──────────────────────────────────────────────────────────── @@ -573,13 +579,19 @@ mod tests { // Age every bucket past the TTL. for shard in &store.shards { - for bucket in shard.lock().values_mut() { + for bucket in shard.buckets.lock().values_mut() { bucket.last_seen -= IDLE_TTL + Duration::from_secs(1); } } - store.evict_idle(); - let live: usize = store.shards.iter().map(|s| s.lock().len()).sum(); + // Sweep each shard (mirrors the in-line sweep in try_consume). + for shard in &store.shards { + shard + .buckets + .lock() + .retain(|_, bucket| bucket.last_seen.elapsed() < IDLE_TTL); + } + let live: usize = store.shards.iter().map(|s| s.buckets.lock().len()).sum(); assert_eq!(live, 0, "idle buckets should be gone"); // A fresh bucket is created for the next request. @@ -587,7 +599,7 @@ mod tests { store.try_consume("stale-key", 10, Duration::from_secs(1)), None ); - let live: usize = store.shards.iter().map(|s| s.lock().len()).sum(); + let live: usize = store.shards.iter().map(|s| s.buckets.lock().len()).sum(); assert_eq!(live, 1); } @@ -611,7 +623,7 @@ mod tests { for handle in handles { handle.join().unwrap(); } - let total: usize = store.shards.iter().map(|s| s.lock().len()).sum(); + let total: usize = store.shards.iter().map(|s| s.buckets.lock().len()).sum(); assert_eq!(total, 8 * 250); } diff --git a/arvik-middleware/src/request_id.rs b/arvik-middleware/src/request_id.rs index 88e6bba..1352a35 100644 --- a/arvik-middleware/src/request_id.rs +++ b/arvik-middleware/src/request_id.rs @@ -122,13 +122,17 @@ where .map(RequestId::from_string) .unwrap_or_else(RequestId::new); - let id_str = request_id.0.clone(); + // Build the header value ONCE and clone it (a cheap Bytes refcount + // bump) for the second insert — previously the id string was cloned + // and re-parsed/validated into two separate HeaderValues per request. + let request_header = HeaderValue::from_str(&request_id.0).ok(); + let response_header = request_header.clone(); // Insert as extension so handlers can access it req.extensions_mut().insert(request_id); // Also set on request headers for downstream middleware - if let Ok(val) = HeaderValue::from_str(&id_str) { + if let Some(val) = request_header { req.headers_mut().insert(X_REQUEST_ID, val); } @@ -139,7 +143,7 @@ where let mut response = inner.call(req).await?; // Propagate the request ID to the response - if let Ok(val) = HeaderValue::from_str(&id_str) { + if let Some(val) = response_header { response.headers_mut().insert(X_REQUEST_ID, val); } diff --git a/arvik-middleware/src/security_headers.rs b/arvik-middleware/src/security_headers.rs index 92109a1..2bffa95 100644 --- a/arvik-middleware/src/security_headers.rs +++ b/arvik-middleware/src/security_headers.rs @@ -231,12 +231,12 @@ pub struct SetRequestHeaderService { impl Service for SetRequestHeaderService where - S: Service + Clone + Send + 'static, - S::Future: Send + 'static, + S: Service + Clone + Send + Sync + 'static, + S::Future: Send + Unpin + 'static, { type Response = Response; type Error = Infallible; - type Future = Pin> + Send + 'static>>; + type Future = S::Future; fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { self.inner.poll_ready(cx) @@ -260,7 +260,7 @@ where let cloned = self.inner.clone(); let mut inner = std::mem::replace(&mut self.inner, cloned); - Box::pin(async move { inner.call(req).await }) + inner.call(req) } } @@ -319,12 +319,12 @@ pub struct SensitiveHeadersService { impl Service for SensitiveHeadersService where - S: Service + Clone + Send + 'static, - S::Future: Send + 'static, + S: Service + Clone + Send + Sync + 'static, + S::Future: Send + Unpin + 'static, { type Response = Response; type Error = Infallible; - type Future = Pin> + Send + 'static>>; + type Future = S::Future; fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { self.inner.poll_ready(cx) @@ -336,7 +336,7 @@ where let cloned = self.inner.clone(); let mut inner = std::mem::replace(&mut self.inner, cloned); - Box::pin(async move { inner.call(req).await }) + inner.call(req) } } diff --git a/arvik-middleware/src/trace.rs b/arvik-middleware/src/trace.rs index 22f9c42..14acdd3 100644 --- a/arvik-middleware/src/trace.rs +++ b/arvik-middleware/src/trace.rs @@ -110,9 +110,18 @@ impl DefaultMakeSpan { return Span::none(); } - let method = req.method().as_str().to_owned(); - let path = req.uri().path().to_owned(); - let version = format!("{:?}", req.version()); + // Borrow instead of allocating: tracing copies field data itself, + // and the version is one of five constants. + let method = req.method().as_str(); + let path = req.uri().path(); + let version: &str = match req.version() { + http::Version::HTTP_09 => "HTTP/0.9", + http::Version::HTTP_10 => "HTTP/1.0", + http::Version::HTTP_11 => "HTTP/1.1", + http::Version::HTTP_2 => "HTTP/2.0", + http::Version::HTTP_3 => "HTTP/3.0", + _ => "unknown", + }; let span = match self.level { Level::ERROR => tracing::error_span!( @@ -271,6 +280,13 @@ where // while the task awaits across worker threads. let response = inner.call(req).instrument(span.clone()).await?; + // When the span was gated off at make_span, skip the latency + // formatting and every record/event below — they were pure + // allocation on filtered-out requests. + if span.is_none() { + return Ok(response); + } + let elapsed = start.elapsed(); let status = response.status(); let latency_str = latency_unit.format(elapsed); diff --git a/arvik-observe/src/logging.rs b/arvik-observe/src/logging.rs index 4d6e198..d5580b1 100644 --- a/arvik-observe/src/logging.rs +++ b/arvik-observe/src/logging.rs @@ -323,9 +323,14 @@ where }; let request_id = request_id(&req, &self.request_id_header); + // One parse/validate; the response side clones (cheap Bytes bump) + // instead of re-running from_str per request. + let request_header = HeaderValue::from_str(&request_id).ok(); + let response_header = request_header.clone(); + req.extensions_mut() .insert(RequestId::from_string(request_id.clone())); - if let Ok(value) = HeaderValue::from_str(&request_id) { + if let Some(value) = request_header { req.headers_mut() .insert(self.request_id_header.clone(), value); } @@ -343,7 +348,6 @@ where }; let request_id_header = self.request_id_header.clone(); - let request_id_for_response = request_id.clone(); let cloned = self.inner.clone(); let mut inner = std::mem::replace(&mut self.inner, cloned); @@ -351,26 +355,33 @@ where let started = Instant::now(); let mut response = inner.call(req).instrument(span.clone()).await?; let status = response.status(); - let route = response - .extensions() - .get::() - .map(|matched| matched.0.to_string()) - .unwrap_or_else(|| "__unknown".to_string()); let latency_ms = started.elapsed().as_millis(); - if let Ok(value) = HeaderValue::from_str(&request_id_for_response) { + if let Some(value) = response_header { response.headers_mut().insert(request_id_header, value); } + // Route is only consumed by span records/events — skipped entirely + // when the span was gated off. + let route: &str = if span.is_none() { + "" + } else { + response + .extensions() + .get::() + .map(|matched| matched.0.as_ref()) + .unwrap_or("__unknown") + }; + span.record("http.status_code", status.as_u16()); - span.record("http.route", route.as_str()); + span.record("http.route", route); span.record("latency_ms", latency_ms); if status.is_server_error() { tracing::error!( parent: &span, status = status.as_u16(), - route = route.as_str(), + route = route, latency_ms = latency_ms, "request completed" ); @@ -378,7 +389,7 @@ where tracing::warn!( parent: &span, status = status.as_u16(), - route = route.as_str(), + route = route, latency_ms = latency_ms, "request completed" ); @@ -386,7 +397,7 @@ where tracing::info!( parent: &span, status = status.as_u16(), - route = route.as_str(), + route = route, latency_ms = latency_ms, "request completed" ); diff --git a/arvik-observe/src/metrics.rs b/arvik-observe/src/metrics.rs index 828cdaf..c4fc1cc 100644 --- a/arvik-observe/src/metrics.rs +++ b/arvik-observe/src/metrics.rs @@ -285,6 +285,7 @@ where } fn call(&mut self, req: Request) -> Self::Future { + // Owned: `method` must outlive `req`, which moves into the future. let method = req.method().as_str().to_owned(); let registry = self.registry.clone(); let labels = self.labels.clone(); @@ -298,20 +299,13 @@ where req }; - let pending_status = "pending"; - let pending_values = labels.values(&method, PENDING_ROUTE, pending_status); - registry + let pending_values = labels.values(&method, PENDING_ROUTE, "pending"); + let in_flight_gauge = registry .inner .requests_in_flight - .with_label_values(&pending_values) - .inc(); - let in_flight = InFlightGuard::new( - registry.clone(), - labels.clone(), - method.clone(), - PENDING_ROUTE.to_string(), - pending_status.to_string(), - ); + .with_label_values(&pending_values); + in_flight_gauge.inc(); + let in_flight = InFlightGuard::new(in_flight_gauge.clone()); let cloned = self.inner.clone(); let mut inner = std::mem::replace(&mut self.inner, cloned); @@ -320,7 +314,9 @@ where let start = Instant::now(); let mut response = inner.call(req).await?; let elapsed = start.elapsed().as_secs_f64(); - let status = response.status().as_u16().to_string(); + let raw_status = response.status().as_u16(); + // Owned: `response` is reassigned below for body counting. + let status = format!("{raw_status:03}"); let route = matched_route(response.extensions()); let values = labels.values(&method, &route, &status); @@ -479,28 +475,14 @@ impl Drop for CountingBody { } struct InFlightGuard { - registry: MetricsRegistry, - labels: StaticLabels, - method: String, - route: String, - status: String, + gauge: prometheus::IntGauge, active: bool, } impl InFlightGuard { - fn new( - registry: MetricsRegistry, - labels: StaticLabels, - method: String, - route: String, - status: String, - ) -> Self { + fn new(gauge: prometheus::IntGauge) -> Self { Self { - registry, - labels, - method, - route, - status, + gauge, active: true, } } @@ -509,13 +491,7 @@ impl InFlightGuard { impl Drop for InFlightGuard { fn drop(&mut self) { if self.active { - let values = self.labels.values(&self.method, &self.route, &self.status); - self.registry - .inner - .requests_in_flight - .with_label_values(&values) - .dec(); - self.active = false; + self.gauge.dec(); } } } diff --git a/arvik-observe/src/trace.rs b/arvik-observe/src/trace.rs index 3592c5f..e2e575a 100644 --- a/arvik-observe/src/trace.rs +++ b/arvik-observe/src/trace.rs @@ -161,10 +161,9 @@ fn make_http_span(service_name: &str, method: &str, url: &str, user_agent: &str) http.status_code = tracing::field::Empty, http.route = tracing::field::Empty, ); - span.set_attribute("service.name", service_name.to_owned()); - span.set_attribute("http.method", method.to_owned()); - span.set_attribute("http.url", url.to_owned()); - span.set_attribute("http.user_agent", user_agent.to_owned()); + // The four fields above are converted to OTel attributes by the + // tracing-opentelemetry layer itself — setting them again here duplicated + // every allocation per request (audit O23). span } diff --git a/arvik-router/src/method_router.rs b/arvik-router/src/method_router.rs index cf0c18e..1a3970e 100644 --- a/arvik-router/src/method_router.rs +++ b/arvik-router/src/method_router.rs @@ -52,7 +52,14 @@ pub struct MethodRouter { /// Bitmask of all registered methods — used to build the `Allow` header. allow_methods: MethodFilter, /// Tower layers applied to each matched handler (innermost = first in vec). - layers: Vec, + /// Behind an Arc so cloning the router stays allocation-free. + layers: std::sync::Arc>, + /// Per-handler layer stacks folded ONCE after `with_state` binds the + /// state (indexed like `handlers`; `None` = no layers → fast path). + /// Valid only once the state is fixed (`S = ()`), hence the flag. + baked: std::sync::OnceLock>>>, + /// True after `with_state` — the baked stacks above may be built. + bakeable: bool, } /// Mutable access to a handler list that may be shared behind an Arc. @@ -82,7 +89,9 @@ impl MethodRouter { Self { handlers: std::sync::Arc::new(Vec::new()), allow_methods: MethodFilter::NONE, - layers: Vec::new(), + layers: std::sync::Arc::new(Vec::new()), + baked: std::sync::OnceLock::new(), + bakeable: false, } } @@ -94,6 +103,7 @@ impl MethodRouter { { self.allow_methods |= filter; handlers_mut(&mut self.handlers).push((filter, into_erased(handler))); + self.baked = std::sync::OnceLock::new(); self } @@ -109,7 +119,9 @@ impl MethodRouter { self.allow_methods |= other.allow_methods; handlers_mut(&mut self.handlers) .extend(other.handlers.iter().map(|(f, h)| (*f, h.clone_box()))); - self.layers.extend(other.layers); + std::sync::Arc::make_mut(&mut self.layers).extend(other.layers.iter().cloned()); + self.baked = std::sync::OnceLock::new(); + self.bakeable = false; self } @@ -207,7 +219,8 @@ impl MethodRouter { + 'static, >::Future: Send + 'static, { - self.layers.push(into_layer_fn(layer)); + std::sync::Arc::make_mut(&mut self.layers).push(into_layer_fn(layer)); + self.baked = std::sync::OnceLock::new(); self } @@ -223,26 +236,49 @@ impl MethodRouter { /// (e.g. `PURGE`) unless the route registered [`MethodFilter::EXTENSION`] /// or [`MethodFilter::ANY`]. pub async fn call(&self, req: Request, state: S) -> Response { - let method = req.method().clone(); - let method_filter = MethodFilter::from_method(&method); + // Borrowed — no Method clone per request. + let method = req.method(); + let method_filter = MethodFilter::from_method(method); // RFC 9110 §9.3.2: a HEAD response is a GET response without a body, // so fall back to the GET handler unless HEAD was explicitly bound. let head_falls_back_to_get = - method == http::Method::HEAD && !self.allow_methods.contains(MethodFilter::HEAD); - - for (filter, handler) in self.handlers.iter() { + *method == http::Method::HEAD && !self.allow_methods.contains(MethodFilter::HEAD); + + // Baked per-handler layer stacks exist once the state is bound + // (`with_state`): fold every layer exactly once instead of rebuilding + // the whole stack per request. + let baked = if self.bakeable && !self.layers.is_empty() { + Some(self.baked.get_or_init(|| { + std::sync::Arc::new( + self.handlers + .iter() + .map(|(_, handler)| { + let base = BoxCloneService::new(HandlerService { + handler: handler.clone_box(), + state: state.clone(), + }); + Some(apply_layers(base, &self.layers)) + }) + .collect(), + ) + })) + } else { + None + }; + + for (idx, (filter, handler)) in self.handlers.iter().enumerate() { let matched = filter.contains(method_filter) || (head_falls_back_to_get && filter.contains(MethodFilter::GET)); if matched { + if let Some(baked) = baked.as_ref().and_then(|stack| stack[idx].as_ref()) { + return oneshot(baked.clone(), req).await; + } if self.layers.is_empty() { // ── Fast path: no per-route layers ────────────────────── - let h = handler.clone_box(); - return h.call(req, state).await; + return handler.call(req, state).await; } else { - // ── Layered path ───────────────────────────────────────── - // Wrap the handler in a Tower service so layers can compose - // around it with the standard Layer protocol. + // ── Layered path (unbound generic router; rare) ───────── let h = handler.clone_box(); let base = BoxCloneService::new(HandlerService { handler: h, @@ -259,7 +295,7 @@ impl MethodRouter { ResponseBuilder::new() .status(StatusCode::METHOD_NOT_ALLOWED) .header(http::header::ALLOW, allow) - .text("Method Not Allowed") + .text_static("Method Not Allowed") } /// Append already-erased layers to this router's per-route stack. @@ -269,7 +305,12 @@ impl MethodRouter { /// into the parent: appended layers wrap the ones registered earlier, /// matching the router-level (`layer` outside `route_layer`) ordering. pub(crate) fn extend_layers(&mut self, extra: impl IntoIterator) { - self.layers.extend(extra); + let extra: Vec = extra.into_iter().collect(); + if extra.is_empty() { + return; + } + std::sync::Arc::make_mut(&mut self.layers).extend(extra); + self.baked = std::sync::OnceLock::new(); } /// Bind application state, converting `MethodRouter` → `MethodRouter<()>`. @@ -305,6 +346,8 @@ impl MethodRouter { handlers, allow_methods: self.allow_methods, layers: self.layers, // LayerFn is state-independent — pass through + baked: std::sync::OnceLock::new(), + bakeable: true, // state is now fixed to () — stacks may be baked } } } @@ -315,7 +358,11 @@ impl Clone for MethodRouter { Self { handlers: std::sync::Arc::clone(&self.handlers), allow_methods: self.allow_methods, - layers: self.layers.clone(), // LayerFn is Arc-backed — O(n) cheap + layers: std::sync::Arc::clone(&self.layers), + // Clones share handlers but not a fixed state binding, so they + // rebuild their own stacks lazily only if re-bound via with_state. + baked: std::sync::OnceLock::new(), + bakeable: false, } } } @@ -379,7 +426,11 @@ impl ErasedHandler<()> for StateBound { }) } - fn call(self: Box, req: Request, _state: ()) -> BoxFuture<'static, Response> { + fn call(&self, req: Request, _state: ()) -> BoxFuture<'static, Response> { + // The wrapped handler still expects an owned S, so the Arc'd state is + // cloned here. Eliminating this clone needs the Handler-trait + // state-by-ref migration (audit O1 — deferred, breaking); until then, + // wrap AppState in Arc internally (documented idiom). let state = (*self.state).clone(); self.inner.call(req, state) } diff --git a/arvik-router/src/router.rs b/arvik-router/src/router.rs index f2e65b1..c12b04d 100644 --- a/arvik-router/src/router.rs +++ b/arvik-router/src/router.rs @@ -35,6 +35,13 @@ use crate::method_router::{MethodRouter, StateBound}; use crate::params::PathParams; use crate::service::{ServiceHandler, StripPrefixService}; +/// Shared sentinel labels for unmatched / fallback dispatch — avoids a heap +/// allocation per 404 under path-scanning load. +static FALLBACK_LABEL: std::sync::LazyLock> = + std::sync::LazyLock::new(|| Arc::from("__fallback")); +static UNMATCHED_LABEL: std::sync::LazyLock> = + std::sync::LazyLock::new(|| Arc::from("__unmatched")); + /// Metadata emitted by route macros and used by `collect_routes!`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RouteMeta { @@ -160,6 +167,10 @@ pub struct Router { struct RouteEntry { pattern: Arc, method_router: MethodRouter, + /// Param names interned from the FIRST match — matchit owns the pattern + /// strings for the router's lifetime, so later requests only refcount + /// instead of re-allocating each key (`Arc::from(k)`). + param_keys: std::sync::OnceLock>>, } impl RouteEntry { @@ -167,6 +178,7 @@ impl RouteEntry { Self { pattern: Arc::from(path), method_router, + param_keys: std::sync::OnceLock::new(), } } } @@ -406,6 +418,7 @@ impl Router { .map(|entry| RouteEntry { pattern: entry.pattern, method_router: entry.method_router.with_state((*state).clone()), + param_keys: entry.param_keys, }) .collect(); @@ -442,11 +455,13 @@ impl Router { if !matched.params.is_empty() { let mut pp = PathParams::new(); - // matchit's keys are the declared names — including - // mid-segment params like `{id}.png` that positional - // pattern parsing cannot align with. - for (k, v) in matched.params.iter() { - pp.push(Arc::from(k), percent_decode(v).into_owned()); + // Keys are interned on first sight of this route; values + // only allocate when decoding actually changed bytes. + let keys = entry + .param_keys + .get_or_init(|| matched.params.iter().map(|(k, _)| Arc::from(k)).collect()); + for (idx, (_, v)) in matched.params.iter().enumerate() { + pp.push(Arc::clone(&keys[idx]), percent_decode(v).into_owned()); } req.extensions_mut().insert(pp); } @@ -559,11 +574,13 @@ impl RouterInner { if !matched.params.is_empty() { let mut pp = PathParams::new(); - // matchit's keys are the declared names — including - // mid-segment params like `{id}.png` that positional - // pattern parsing cannot align with. - for (k, v) in matched.params.iter() { - pp.push(Arc::from(k), percent_decode(v).into_owned()); + // Keys are interned on first sight of this route; values + // only allocate when decoding actually changed bytes. + let keys = entry + .param_keys + .get_or_init(|| matched.params.iter().map(|(k, _)| Arc::from(k)).collect()); + for (idx, (_, v)) in matched.params.iter().enumerate() { + pp.push(Arc::clone(&keys[idx]), percent_decode(v).into_owned()); } req.extensions_mut().insert(pp); } @@ -583,13 +600,13 @@ impl RouterInner { let mut response = fb.clone_box().call(req, ()).await; response .extensions_mut() - .insert(MatchedPathExt(Arc::from("__fallback"))); + .insert(MatchedPathExt(std::sync::Arc::clone(&FALLBACK_LABEL))); return response; } let mut response = not_found(); response .extensions_mut() - .insert(MatchedPathExt(Arc::from("__unmatched"))); + .insert(MatchedPathExt(std::sync::Arc::clone(&UNMATCHED_LABEL))); response } } @@ -651,7 +668,7 @@ fn not_found() -> Response { ResponseBuilder::new() .status(StatusCode::NOT_FOUND) .header(http::header::CONTENT_TYPE, "text/plain; charset=utf-8") - .text("Not Found") + .text_static("Not Found") } fn normalize_service_prefix(prefix: &str) -> String { diff --git a/arvik-router/src/service.rs b/arvik-router/src/service.rs index 1ff0355..4d81cbe 100644 --- a/arvik-router/src/service.rs +++ b/arvik-router/src/service.rs @@ -103,6 +103,7 @@ where } fn call(&mut self, mut req: Request) -> Self::Future { + // Single clone: serves both the OriginalUri extension and the strip. let original_uri = req.uri().clone(); if req.extensions().get::().is_none() { req.extensions_mut() @@ -119,16 +120,17 @@ where fn strip_uri_prefix(uri: &Uri, prefix: &str) -> Option { let path = uri.path(); - let stripped_path = if prefix == "/" { - path + let stripped_path = if prefix == "/" || !path.starts_with(prefix) { + // Nothing to strip — callers keep the original Uri without a + // to_string + re-parse round-trip (audit O6). + return None; } else if path == prefix || (path.len() == prefix.len() + 1 && path.starts_with(prefix) && path.ends_with('/')) { "/" } else { path.strip_prefix(prefix) - .filter(|rest| rest.starts_with('/')) - .unwrap_or(path) + .filter(|rest| rest.starts_with('/'))? }; let mut path_and_query = stripped_path.to_string(); diff --git a/arvik-sse/src/event.rs b/arvik-sse/src/event.rs index d99f569..2f841ba 100644 --- a/arvik-sse/src/event.rs +++ b/arvik-sse/src/event.rs @@ -73,7 +73,13 @@ impl Event { /// /// `\r\n` and lone `\r` are normalized to `\n` (SSE spec §9.2). pub fn data(mut self, data: impl Into) -> Self { - let s = data.into().replace("\r\n", "\n").replace('\r', "\n"); + // Skip two allocations when there is no CR to normalize. + let raw = data.into(); + let s = if raw.contains('\r') { + raw.replace("\r\n", "\n").replace('\r', "\n") + } else { + raw + }; self.data = Some(s); self } @@ -131,7 +137,12 @@ impl Event { /// /// [`KeepAlive`]: crate::KeepAlive pub fn comment(mut self, comment: impl Into) -> Self { - let s = comment.into().replace("\r\n", "\n").replace('\r', "\n"); + let raw = comment.into(); + let s = if raw.contains('\r') { + raw.replace("\r\n", "\n").replace('\r', "\n") + } else { + raw + }; self.comment = Some(s); self } diff --git a/arvik-static/src/lib.rs b/arvik-static/src/lib.rs index 291473e..2699f79 100644 --- a/arvik-static/src/lib.rs +++ b/arvik-static/src/lib.rs @@ -45,6 +45,9 @@ pub struct ServeDir { /// When false (default), requests resolving through a symlink to outside /// the (canonicalized) root are rejected. follow_symlinks: bool, + /// Canonicalized serve root, resolved once on first use instead of per + /// request (each `canonicalize` is a blocking-pool round-trip). + canonical_root: tokio::sync::OnceCell>>, } #[cfg(feature = "fs")] @@ -62,6 +65,7 @@ impl ServeDir { chunk_size: DEFAULT_CHUNK_SIZE, cache_control: None, follow_symlinks: false, + canonical_root: tokio::sync::OnceCell::new(), } } @@ -157,7 +161,9 @@ impl ServeDir { // Symlink containment: a planted link must not serve bytes from // outside the (canonicalized) root. Checked on the canonical path so // escapes through intermediate directories are caught too. - if !self.follow_symlinks && !path_contained(&self.root, &candidate).await { + if !self.follow_symlinks + && !path_contained(&self.root, &self.canonical_root, &candidate).await + { return self.call_fallback(req).await; } @@ -174,7 +180,13 @@ impl ServeDir { let accepts_gzip = self.precompressed_gzip && accepts_encoding(&req, "gzip"); match self - .select_file(candidate, &relative.asset_path, accepts_br, accepts_gzip) + .select_file( + candidate, + metadata, + &relative.asset_path, + accepts_br, + accepts_gzip, + ) .await { Ok(asset) => { @@ -198,17 +210,21 @@ impl ServeDir { if self.append_index_html_on_directories { let index_path = dir.join(INDEX_FILE); - if tokio::fs::metadata(&index_path) - .await - .map(|m| m.is_file()) - .unwrap_or(false) - { + let index_meta = tokio::fs::metadata(&index_path).await; + if index_meta.as_ref().is_ok_and(|m| m.is_file()) { + let index_meta = index_meta.unwrap(); let index_asset_path = join_asset_path(&relative.asset_path, INDEX_FILE); let accepts_br = self.precompressed_br && accepts_encoding(&req, "br"); let accepts_gzip = self.precompressed_gzip && accepts_encoding(&req, "gzip"); return match self - .select_file(index_path, &index_asset_path, accepts_br, accepts_gzip) + .select_file( + index_path, + index_meta, + &index_asset_path, + accepts_br, + accepts_gzip, + ) .await { Ok(asset) => { @@ -236,6 +252,7 @@ impl ServeDir { async fn select_file( &self, path: PathBuf, + existing: std::fs::Metadata, asset_path: &str, accepts_br: bool, accepts_gzip: bool, @@ -272,7 +289,10 @@ impl ServeDir { } } - if !tokio::fs::metadata(&path).await.is_ok_and(|m| m.is_file()) { + // `handle` already statted this exact path and ruled out directories — + // reuse that metadata instead of a second syscall. A file vanishing + // between here and the open fails safely inside `serve_fs_asset`. + if !existing.is_file() { return Err(()); } Ok(FsCandidate { @@ -1019,12 +1039,26 @@ fn serve_embedded_asset( /// links are caught as well as direct file links. Unresolvable paths are /// treated as not contained. #[cfg(feature = "fs")] -async fn path_contained(root: &Path, path: &Path) -> bool { - let Ok(canonical_root) = tokio::fs::canonicalize(root).await else { +async fn path_contained( + root: &Path, + cached_root: &tokio::sync::OnceCell>>, + path: &Path, +) -> bool { + // The serve root is canonicalized exactly once; only the candidate path + // pays a realpath walk per request. + let canonical_root = cached_root + .get_or_init(|| async { + tokio::fs::canonicalize(root) + .await + .ok() + .map(std::sync::Arc::new) + }) + .await; + let Some(canonical_root) = canonical_root else { return false; }; match tokio::fs::canonicalize(path).await { - Ok(canonical_path) => canonical_path.starts_with(&canonical_root), + Ok(canonical_path) => canonical_path.starts_with(&**canonical_root), Err(_) => false, } }