use crate::http::response::{Body, IntoResponse};
use bytes::Bytes;
use hyper::{
Response, StatusCode,
header::{
CACHE_CONTROL, CONTENT_ENCODING, CONTENT_TYPE, ETAG, IF_NONE_MATCH, VARY,
X_CONTENT_TYPE_OPTIONS,
},
};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::fs;
type HashMap<K, V> = rustc_hash::FxHashMap<K, V>;
#[derive(Clone, Debug)]
pub struct CacheConfig {
pub enabled: bool,
pub max_total_bytes: usize,
pub max_file_bytes: usize,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
enabled: true,
max_total_bytes: 64 * 1024 * 1024,
max_file_bytes: 2 * 1024 * 1024,
}
}
}
pub(crate) fn guess_mime_type(path: &Path) -> &'static str {
let Some(ext) = path.extension().and_then(|s| s.to_str()) else {
return "application/octet-stream";
};
if ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm") {
"text/html; charset=utf-8"
} else if ext.eq_ignore_ascii_case("css") {
"text/css; charset=utf-8"
} else if ext.eq_ignore_ascii_case("js") || ext.eq_ignore_ascii_case("mjs") {
"application/javascript; charset=utf-8"
} else if ext.eq_ignore_ascii_case("json") {
"application/json"
} else if ext.eq_ignore_ascii_case("wasm") {
"application/wasm"
} else if ext.eq_ignore_ascii_case("webmanifest") {
"application/manifest+json"
} else if ext.eq_ignore_ascii_case("xml") {
"text/xml; charset=utf-8"
} else if ext.eq_ignore_ascii_case("txt") {
"text/plain; charset=utf-8"
} else if ext.eq_ignore_ascii_case("csv") {
"text/csv; charset=utf-8"
} else if ext.eq_ignore_ascii_case("png") {
"image/png"
} else if ext.eq_ignore_ascii_case("jpg") || ext.eq_ignore_ascii_case("jpeg") {
"image/jpeg"
} else if ext.eq_ignore_ascii_case("gif") {
"image/gif"
} else if ext.eq_ignore_ascii_case("svg") || ext.eq_ignore_ascii_case("svgz") {
"image/svg+xml"
} else if ext.eq_ignore_ascii_case("ico") {
"image/x-icon"
} else if ext.eq_ignore_ascii_case("webp") {
"image/webp"
} else if ext.eq_ignore_ascii_case("avif") {
"image/avif"
} else if ext.eq_ignore_ascii_case("bmp") {
"image/bmp"
} else if ext.eq_ignore_ascii_case("woff") {
"font/woff"
} else if ext.eq_ignore_ascii_case("woff2") {
"font/woff2"
} else if ext.eq_ignore_ascii_case("ttf") {
"font/ttf"
} else if ext.eq_ignore_ascii_case("otf") {
"font/otf"
} else if ext.eq_ignore_ascii_case("mp3") {
"audio/mpeg"
} else if ext.eq_ignore_ascii_case("mp4") || ext.eq_ignore_ascii_case("m4v") {
"video/mp4"
} else if ext.eq_ignore_ascii_case("webm") {
"video/webm"
} else if ext.eq_ignore_ascii_case("pdf") {
"application/pdf"
} else if ext.eq_ignore_ascii_case("zip") {
"application/zip"
} else if ext.eq_ignore_ascii_case("gz") {
"application/gzip"
} else {
"application/octet-stream"
}
}
fn is_safe_path(base: &Path, candidate: &Path) -> bool {
candidate.starts_with(base)
}
#[derive(Clone, Debug)]
struct StaticAsset {
content: Bytes,
content_gz: Option<Bytes>,
content_br: Option<Bytes>,
etag: String,
etag_header: hyper::header::HeaderValue,
headers: hyper::HeaderMap,
}
#[derive(Clone, Debug)]
pub struct ServeDir {
base_path: PathBuf,
memory_cache: Option<Arc<HashMap<String, StaticAsset>>>,
index_file: Option<String>,
cache_config: CacheConfig,
}
impl ServeDir {
pub fn new(path: impl AsRef<Path>) -> Self {
let base = path.as_ref().to_path_buf();
let base = std::fs::canonicalize(&base)
.or_else(|_| std::env::current_dir().map(|cd| cd.join(&base)))
.unwrap_or(base);
Self {
base_path: base,
memory_cache: None,
index_file: None,
cache_config: CacheConfig::default(),
}
}
#[must_use]
pub const fn cache(mut self, config: CacheConfig) -> Self {
self.cache_config = config;
self
}
#[must_use]
pub fn index(mut self, file: impl Into<String>) -> Self {
self.index_file = Some(file.into());
self
}
pub async fn preload(mut self) -> std::io::Result<Self> {
if !self.cache_config.enabled {
return Ok(self);
}
if let Ok(canonical) = fs::canonicalize(&self.base_path).await {
self.base_path = canonical;
}
let mut cache = HashMap::default();
let mut current_total = 0usize;
Self::crawl_dir(
&self.base_path.clone(),
&self.base_path.clone(),
&mut cache,
&mut current_total,
self.cache_config.max_file_bytes,
self.cache_config.max_total_bytes,
)
.await?;
if let Some(ref idx) = self.index_file
&& let Some(asset) = cache.get(idx.as_str()).cloned()
{
let _ = cache.insert(String::new(), asset);
}
self.memory_cache = Some(Arc::new(cache));
Ok(self)
}
#[allow(clippy::too_many_lines)]
async fn crawl_dir(
base: &Path,
current: &Path,
cache: &mut HashMap<String, StaticAsset>,
current_total: &mut usize,
max_file_bytes: usize,
max_total_bytes: usize,
) -> std::io::Result<()> {
if !current.exists() {
return Ok(());
}
let mut entries = fs::read_dir(current).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
match fs::canonicalize(&path).await {
Ok(real) if real.starts_with(base) => {}
_ => {
tracing::warn!(
path = %path.display(),
"Skipping cache entry that resolves outside the served directory"
);
continue;
}
}
if path.is_dir() {
Box::pin(Self::crawl_dir(
base,
&path,
cache,
current_total,
max_file_bytes,
max_total_bytes,
))
.await?;
continue;
}
let path_str = path.to_string_lossy();
if let Some(base_str) = path_str
.strip_suffix(".gz")
.or_else(|| path_str.strip_suffix(".br"))
&& fs::metadata(base_str).await.is_ok()
{
continue;
}
let meta = fs::metadata(&path).await?;
if usize::try_from(meta.len()).unwrap_or(usize::MAX) > max_file_bytes {
tracing::debug!(
"Skipping cache for large file: {} ({} bytes)",
path.display(),
meta.len()
);
continue;
}
if *current_total >= max_total_bytes {
tracing::warn!("RAM cache budget exhausted; remaining files served from disk");
break;
}
let content = fs::read(&path).await?;
let relative = match path.strip_prefix(base) {
Ok(rel) => rel
.to_string_lossy()
.trim_start_matches('/')
.replace('\\', "/"),
Err(_) => continue,
};
let gz_path = PathBuf::from(format!("{}.gz", path.display()));
let br_path = PathBuf::from(format!("{}.br", path.display()));
let content_gz = fs::read(&gz_path).await.ok().map(Bytes::from);
let content_br = fs::read(&br_path).await.ok().map(Bytes::from);
let etag = make_etag(&content);
let etag_header = hyper::header::HeaderValue::from_str(&etag)
.unwrap_or_else(|_| hyper::header::HeaderValue::from_static("\"0\""));
let mime_type = guess_mime_type(&path);
let mut headers = hyper::HeaderMap::new();
let _ = headers.insert(
CONTENT_TYPE,
hyper::header::HeaderValue::from_static(mime_type),
);
let _ = headers.insert(
X_CONTENT_TYPE_OPTIONS,
hyper::header::HeaderValue::from_static("nosniff"),
);
let cc = if mime_type.starts_with("text/html") {
"public, max-age=300"
} else {
"public, max-age=3600, immutable"
};
let _ = headers.insert(CACHE_CONTROL, hyper::header::HeaderValue::from_static(cc));
if content_gz.is_some() || content_br.is_some() {
let _ = headers.insert(
VARY,
hyper::header::HeaderValue::from_static("Accept-Encoding"),
);
}
*current_total += content.len()
+ content_gz.as_ref().map_or(0, Bytes::len)
+ content_br.as_ref().map_or(0, Bytes::len);
let _ = cache.insert(
relative,
StaticAsset {
content: Bytes::from(content),
content_gz,
content_br,
etag,
etag_header,
headers,
},
);
}
Ok(())
}
}
#[inline]
fn make_etag(content: &[u8]) -> String {
let len = content.len();
let mut sample: u64 = 0;
for &b in content.iter().take(8) {
sample = sample.wrapping_mul(31).wrapping_add(u64::from(b));
}
for &b in content.iter().rev().take(4) {
sample = sample.wrapping_mul(37).wrapping_add(u64::from(b));
}
format!("\"{len:x}-{sample:x}\"")
}
impl ServeDir {
pub async fn handle_request(&self, req_path: &str) -> Result<Response<Body>, StatusCode> {
self.handle_request_with_encoding(req_path, "", "").await
}
#[allow(clippy::too_many_lines)]
pub async fn handle_request_with_encoding(
&self,
req_path: &str,
accept_encoding: &str,
if_none_match: &str,
) -> Result<Response<Body>, StatusCode> {
let Some(decoded) = crate::routing::percent_decode(req_path) else {
return Err(StatusCode::BAD_REQUEST);
};
let req_clean = decoded.trim_start_matches('/');
if req_clean.contains("..") || req_clean.contains('\0') {
return Err(StatusCode::FORBIDDEN);
}
let resolved = if req_clean.is_empty() {
self.index_file.as_deref().unwrap_or("")
} else {
req_clean
};
if resolved.is_empty() {
return Err(StatusCode::NOT_FOUND);
}
if let Some(cache) = &self.memory_cache
&& let Some(asset) = cache.get(resolved)
{
if !if_none_match.is_empty() && if_none_match == asset.etag {
return Ok(Response::builder()
.status(StatusCode::NOT_MODIFIED)
.body(Body::empty())
.unwrap_or_else(|_| Response::new(Body::empty())));
}
let (body_bytes, encoding) =
if !accept_encoding.is_empty() && accept_encoding.contains("br") {
asset.content_br.as_ref().map_or_else(
|| (asset.content.clone(), None),
|b| (b.clone(), Some("br")),
)
} else if !accept_encoding.is_empty() && accept_encoding.contains("gzip") {
asset.content_gz.as_ref().map_or_else(
|| (asset.content.clone(), None),
|b| (b.clone(), Some("gzip")),
)
} else {
(asset.content.clone(), None)
};
let mut resp = Response::new(Body::full(body_bytes));
*resp.headers_mut() = asset.headers.clone();
let _ = resp
.headers_mut()
.insert(ETAG, asset.etag_header.clone());
if let Some(enc) = encoding {
let enc_val = hyper::header::HeaderValue::from_static(enc);
let _ = resp.headers_mut().insert(CONTENT_ENCODING, enc_val);
}
return Ok(resp);
}
let candidate = self.base_path.join(resolved);
let Ok(canonical) = fs::canonicalize(&candidate).await else {
return Err(StatusCode::NOT_FOUND);
};
if !is_safe_path(&self.base_path, &canonical) {
tracing::warn!(
path = %canonical.display(),
base = %self.base_path.display(),
"Rejected path traversal attempt"
);
return Err(StatusCode::FORBIDDEN);
}
let Ok(meta) = fs::metadata(&canonical).await else {
return Err(StatusCode::NOT_FOUND);
};
if !meta.is_file() {
return Err(StatusCode::NOT_FOUND);
}
match fs::read(&canonical).await {
Ok(content) => {
let mime_type = guess_mime_type(&canonical);
let mut resp = Response::new(Body::full(Bytes::from(content)));
let _ = resp.headers_mut().insert(
CONTENT_TYPE,
hyper::header::HeaderValue::from_static(mime_type),
);
let _ = resp.headers_mut().insert(
X_CONTENT_TYPE_OPTIONS,
hyper::header::HeaderValue::from_static("nosniff"),
);
Ok(resp)
}
Err(e) => {
tracing::error!(path = %canonical.display(), error = %e, "Failed to read static file");
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
#[must_use]
pub fn into_method_router<S>(self) -> crate::routing::MethodRouter<S>
where
S: Clone + Send + Sync + 'static,
{
let self_arc = std::sync::Arc::new(self);
crate::routing::get(move |req: hyper::Request<Bytes>| {
let this = self_arc.clone();
async move {
let path_ext = req
.extensions()
.get::<crate::routing::extract::PathParams>();
let file_path = path_ext
.and_then(|p| {
p.0.iter()
.find(|(k, _)| k.as_ref() == "path" || k.as_ref() == "*path")
.map(|(_, v)| v.as_str())
})
.unwrap_or_else(|| req.uri().path());
let accept_enc = req
.headers()
.get(hyper::header::ACCEPT_ENCODING)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let if_none_match = req
.headers()
.get(IF_NONE_MATCH)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
match this
.handle_request_with_encoding(file_path, accept_enc, if_none_match)
.await
{
Ok(resp) => resp,
Err(status) => status.into_response(),
}
}
})
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use std::fs;
fn make_temp_dir() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
fs::write(
dir.path().join("index.html"),
b"<html><script>var x=1;</script></html>",
)
.unwrap();
fs::write(dir.path().join("style.css"), b"body{}").unwrap();
fs::write(dir.path().join("app.js"), b"console.log(1)").unwrap();
dir
}
#[tokio::test]
async fn test_serve_existing_file() {
let dir = make_temp_dir();
let sd = ServeDir::new(dir.path()).preload().await.unwrap();
let resp = sd.handle_request("style.css").await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp.headers().get(CONTENT_TYPE).unwrap().to_str().unwrap();
assert!(ct.contains("text/css"), "ct: {ct}");
}
#[tokio::test]
async fn test_nosniff_header_preloaded() {
let dir = make_temp_dir();
let sd = ServeDir::new(dir.path()).preload().await.unwrap();
let resp = sd.handle_request("style.css").await.unwrap();
assert_eq!(
resp.headers().get(X_CONTENT_TYPE_OPTIONS).unwrap(),
"nosniff"
);
}
#[tokio::test]
async fn test_nosniff_header_dynamic() {
let dir = make_temp_dir();
let sd = ServeDir::new(dir.path());
let resp = sd.handle_request("style.css").await.unwrap();
assert_eq!(
resp.headers().get(X_CONTENT_TYPE_OPTIONS).unwrap(),
"nosniff"
);
}
#[test]
fn test_svg_mime_type() {
assert_eq!(guess_mime_type(Path::new("logo.svg")), "image/svg+xml");
}
#[tokio::test]
async fn test_not_found() {
let dir = make_temp_dir();
let sd = ServeDir::new(dir.path()).preload().await.unwrap();
assert_eq!(
sd.handle_request("missing.txt").await.unwrap_err(),
StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn test_index_file_on_root_request() {
let dir = make_temp_dir();
let sd = ServeDir::new(dir.path())
.index("index.html")
.preload()
.await
.unwrap();
let resp = sd.handle_request("").await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp.headers().get(CONTENT_TYPE).unwrap().to_str().unwrap();
assert!(ct.contains("text/html"), "ct: {ct}");
}
#[tokio::test]
async fn test_path_traversal_dotdot() {
let dir = make_temp_dir();
let sd = ServeDir::new(dir.path()).preload().await.unwrap();
let err = sd.handle_request("../../etc/passwd").await.unwrap_err();
assert_eq!(err, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn test_path_traversal_dynamic_mode() {
let dir = make_temp_dir();
let sd = ServeDir::new(dir.path());
let err = sd.handle_request("../../../etc/passwd").await.unwrap_err();
assert!(err == StatusCode::FORBIDDEN || err == StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_null_byte_rejected() {
let dir = make_temp_dir();
let sd = ServeDir::new(dir.path()).preload().await.unwrap();
assert_eq!(
sd.handle_request("style\x00.css").await.unwrap_err(),
StatusCode::FORBIDDEN
);
}
#[test]
fn test_guess_mime_types() {
let cases = [
("index.html", "text/html; charset=utf-8"),
("style.css", "text/css; charset=utf-8"),
("app.js", "application/javascript; charset=utf-8"),
("data.json", "application/json"),
("file.wasm", "application/wasm"),
("manifest.webmanifest", "application/manifest+json"),
("feed.xml", "text/xml; charset=utf-8"),
("doc.txt", "text/plain; charset=utf-8"),
("sheet.csv", "text/csv; charset=utf-8"),
("img.png", "image/png"),
("pic.jpg", "image/jpeg"),
("anim.gif", "image/gif"),
("vector.svg", "image/svg+xml"),
("fav.ico", "image/x-icon"),
("pic.webp", "image/webp"),
("pic.avif", "image/avif"),
("pic.bmp", "image/bmp"),
("font.woff", "font/woff"),
("font.woff2", "font/woff2"),
("font.ttf", "font/ttf"),
("font.otf", "font/otf"),
("audio.mp3", "audio/mpeg"),
("video.mp4", "video/mp4"),
("video.webm", "video/webm"),
("doc.pdf", "application/pdf"),
("archive.zip", "application/zip"),
("archive.gz", "application/gzip"),
("no_ext", "application/octet-stream"),
("file.unknown", "application/octet-stream"),
];
for (filename, expected) in cases {
let path = Path::new(filename);
assert_eq!(guess_mime_type(path), expected, "failed on {filename}");
}
}
#[test]
fn test_is_safe_path() {
let base = Path::new("/var/www");
let safe = Path::new("/var/www/index.html");
let unsafe_path = Path::new("/var/etc/passwd");
assert!(is_safe_path(base, safe));
assert!(!is_safe_path(base, unsafe_path));
}
#[tokio::test]
async fn test_crawl_dir_edge_cases() {
let dir = tempfile::tempdir().unwrap();
let mut cache = HashMap::default();
let mut current_total = 0usize;
let res = ServeDir::crawl_dir(
dir.path(),
&dir.path().join("missing"),
&mut cache,
&mut current_total,
2 * 1024 * 1024,
64 * 1024 * 1024,
)
.await;
assert!(res.is_ok());
let large_path = dir.path().join("large.txt");
let large_content = vec![0u8; 6 * 1024 * 1024]; fs::write(&large_path, large_content).unwrap();
let sd = ServeDir::new(dir.path()).preload().await.unwrap();
assert!(sd.memory_cache.as_ref().unwrap().get("large.txt").is_none());
let resp = sd.handle_request("large.txt").await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_handle_request_edge_cases() {
let dir = make_temp_dir();
let sd_dyn = ServeDir::new(dir.path());
let resp = sd_dyn.handle_request("style.css").await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
sd_dyn.handle_request("style%x.css").await.unwrap_err(),
StatusCode::BAD_REQUEST
);
let sd_no_index = ServeDir::new(dir.path());
assert_eq!(
sd_no_index.handle_request("").await.unwrap_err(),
StatusCode::NOT_FOUND
);
assert_eq!(
sd_dyn.handle_request("nonexistent.txt").await.unwrap_err(),
StatusCode::NOT_FOUND
);
let sd_traversal = ServeDir::new(dir.path());
let res = sd_traversal
.handle_request("../../../../../../../../../etc/passwd")
.await;
assert!(res.is_err());
}
#[tokio::test]
async fn test_into_method_router_fallback() {
let dir = make_temp_dir();
let sd = ServeDir::new(dir.path()).preload().await.unwrap();
let router = sd.into_method_router::<()>();
let req = hyper::Request::builder()
.method("GET")
.uri("/style.css")
.body(Body::empty())
.unwrap();
let h = router.handlers[super::super::IDX_GET].as_ref().unwrap();
let resp = h.call(req, Arc::new(())).await;
assert_eq!(resp.status(), StatusCode::OK);
}
}