use std::convert::Infallible;
use std::fs;
use std::io::Read;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use bytes::Bytes;
use hyper::{Method, Response, StatusCode, Request};
use hyper::service::service_fn;
use http_body_util::Full;
use hyper::body::Incoming;
use hyper_util::rt::TokioExecutor;
use hyper_util::rt::TokioIo;
use hyper_util::server::conn::auto::Builder as AutoBuilder;
use tokio::fs::File;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
use tokio::time::timeout;
use crate::error::StaticError;
use crate::handler::{FileBody, ResponseBody};
use crate::resolve;
const ACCEPT_BACKOFF_INITIAL: Duration = Duration::from_millis(10);
const ACCEPT_BACKOFF_MAX: Duration = Duration::from_secs(1);
trait TcpAccept {
async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)>;
}
impl TcpAccept for TcpListener {
async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)> {
TcpListener::accept(self).await
}
}
struct Backoff {
delay: Duration,
}
impl Backoff {
fn new() -> Self {
Backoff { delay: ACCEPT_BACKOFF_INITIAL }
}
fn next_delay(&mut self) -> Duration {
let delay = self.delay;
self.delay = (self.delay * 2).min(ACCEPT_BACKOFF_MAX);
delay
}
fn reset(&mut self) {
self.delay = ACCEPT_BACKOFF_INITIAL;
}
}
async fn accept_and_permit<L: TcpAccept>(
listener: &L,
backoff: &mut Backoff,
semaphore: &Arc<Semaphore>,
) -> Option<(TcpStream, OwnedSemaphorePermit)> {
loop {
let stream = match listener.accept().await {
Ok((stream, _)) => {
backoff.reset();
stream
}
Err(_) => {
tokio::time::sleep(backoff.next_delay()).await;
continue;
}
};
return match semaphore.clone().acquire_owned().await {
Ok(permit) => Some((stream, permit)),
Err(_) => None,
};
}
}
#[derive(Clone)]
pub struct Server {
root_canon: PathBuf,
max_connections: usize,
}
impl Server {
pub fn new(root: &Path) -> Result<Self, StaticError> {
let root_canon = root.canonicalize().map_err(StaticError::Io)?;
Ok(Server { root_canon, max_connections: DEFAULT_MAX_CONNECTIONS })
}
pub fn with_max_connections(mut self, max: usize) -> Self {
self.max_connections = max;
self
}
pub fn resolve(&self, request_path: &str) -> Result<PathBuf, StaticError> {
resolve::resolve_with_canonical_root(&self.root_canon, request_path)
}
pub fn handle_request(&self, request_path: &str) -> Response<ResponseBody> {
self.handle_request_with_method(&Method::GET, request_path)
}
pub fn handle_request_with_method(
&self,
method: &Method,
request_path: &str,
) -> Response<ResponseBody> {
self.handle_request_with_headers(method, request_path, None, None)
}
pub async fn run_on(&self, addr: SocketAddr, header_timeout: Duration) -> Result<(u16, ServerHandle), StaticError> {
let listener = TcpListener::bind(addr)
.await
.map_err(StaticError::Io)?;
let port = listener
.local_addr()
.map_err(StaticError::Io)?
.port();
let server = self.clone();
let semaphore = Arc::new(Semaphore::new(server.max_connections));
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
let accept_task = tokio::spawn(async move {
let mut backoff = Backoff::new();
let mut join_set: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
let mut shutdown_pin = std::pin::pin!(shutdown_rx);
let mut shutting_down = false;
loop {
if !shutting_down {
tokio::select! {
accepted = accept_and_permit(&listener, &mut backoff, &semaphore) => {
match accepted {
Some((stream, permit)) => {
let server = server.clone();
join_set.spawn(async move {
let _permit = permit;
serve_connection(stream, server, header_timeout).await;
});
}
None => shutting_down = true,
}
}
_ = shutdown_pin.as_mut() => {
shutting_down = true;
}
}
continue;
}
match join_set.join_next().await {
Some(_) => continue,
None => break,
}
}
});
Ok((port, ServerHandle { shutdown_tx: Some(shutdown_tx), accept_task }))
}
pub async fn run(&self, header_timeout: Duration) -> Result<(u16, ServerHandle), StaticError> {
let addr: SocketAddr = ([127, 0, 0, 1], 0).into();
self.run_on(addr, header_timeout).await
}
pub async fn run_all(&self, port: u16, header_timeout: Duration) -> Result<(u16, ServerHandle), StaticError> {
let addr: SocketAddr = ([0, 0, 0, 0], port).into();
self.run_on(addr, header_timeout).await
}
pub async fn run_ephemeral(&self) -> Result<(u16, ServerHandle), StaticError> {
self.run(Duration::from_secs(30)).await
}
pub async fn handle_request_async(
&self,
method: &Method,
request_path: &str,
if_none_match: Option<&str>,
if_modified_since: Option<&str>,
) -> Response<ResponseBody> {
if method != Method::GET && method != Method::HEAD {
return finish(Response::builder()
.status(StatusCode::METHOD_NOT_ALLOWED)
.header("Allow", "GET, HEAD")
.header("X-Content-Type-Options", "nosniff")
.body(into_response_body(Full::new(Bytes::from("method not allowed\n")))));
}
let server = self.clone();
let owned_request_path = request_path.to_string();
let resolved = tokio::task::spawn_blocking(move || server.resolve(&owned_request_path)).await;
let resolved = match resolved {
Ok(r) => r,
Err(_) => return internal_error_response(),
};
match resolved {
Ok(path) => {
let decoded_request_path = resolve::decode_request_path(request_path);
if path.file_name().is_some_and(|name| name == "index.html")
&& !decoded_request_path.ends_with('/')
&& !decoded_request_path.ends_with("index.html")
{
let location = format!("{}/", request_path.trim_end_matches('/'));
return finish(Response::builder()
.status(StatusCode::MOVED_PERMANENTLY)
.header("Location", location)
.header("X-Content-Type-Options", "nosniff")
.body(into_response_body(Full::new(Bytes::from("moved\n")))));
}
let file = match File::open(&path).await {
Ok(f) => f,
Err(_) => return internal_error_response(),
};
let metadata = match file.metadata().await {
Ok(m) => m,
Err(_) => return internal_error_response(),
};
let file_size = metadata.len();
let etag = generate_etag(&metadata);
if let Some(if_none_match) = if_none_match {
if is_etag_match(if_none_match, &etag) {
return finish(Response::builder()
.status(StatusCode::NOT_MODIFIED)
.header("ETag", etag)
.body(into_response_body(Full::new(Bytes::new()))));
}
}
if let Some(if_modified_since) = if_modified_since {
if is_not_modified_since(if_modified_since, &metadata) {
return finish(Response::builder()
.status(StatusCode::NOT_MODIFIED)
.header("ETag", etag)
.body(into_response_body(Full::new(Bytes::new()))));
}
}
let body: ResponseBody = if *method == Method::HEAD {
into_response_body(Full::new(Bytes::new()))
} else {
ResponseBody::Streamed(FileBody::new(file))
};
let content_type = mime_type_for_path(&path);
finish(Response::builder()
.status(StatusCode::OK)
.header("X-Content-Type-Options", "nosniff")
.header("Content-Type", content_type)
.header("Content-Length", file_size.to_string())
.header("ETag", etag)
.body(body))
}
Err(e) => {
let message = e.user_message();
let body = format!("{}\n", message);
finish(Response::builder()
.status(StatusCode::NOT_FOUND)
.header("X-Content-Type-Options", "nosniff")
.body(into_response_body(Full::new(Bytes::from(body)))))
}
}
}
pub fn handle_request_with_headers(
&self,
method: &Method,
request_path: &str,
_range_header: Option<&str>,
_if_range_header: Option<&str>,
) -> Response<ResponseBody> {
if method != Method::GET && method != Method::HEAD {
return finish(Response::builder()
.status(StatusCode::METHOD_NOT_ALLOWED)
.header("Allow", "GET, HEAD")
.header("X-Content-Type-Options", "nosniff")
.body(into_response_body(Full::new(Bytes::from("method not allowed\n")))));
}
match self.resolve(request_path) {
Ok(path) => {
let decoded_request_path = resolve::decode_request_path(request_path);
if path.file_name().is_some_and(|name| name == "index.html")
&& !decoded_request_path.ends_with('/')
&& !decoded_request_path.ends_with("index.html")
{
let location = format!("{}/", request_path.trim_end_matches('/'));
return finish(Response::builder()
.status(StatusCode::MOVED_PERMANENTLY)
.header("Location", location)
.header("X-Content-Type-Options", "nosniff")
.body(into_response_body(Full::new(Bytes::from("moved\n")))));
}
let file = match fs::File::open(&path) {
Ok(f) => f,
Err(_) => return internal_error_response(),
};
let metadata = match file.metadata() {
Ok(m) => m,
Err(_) => return internal_error_response(),
};
let file_size = metadata.len();
let etag = generate_etag(&metadata);
let body_bytes = if *method == Method::HEAD {
Bytes::new()
} else {
let mut buf = Vec::with_capacity(file_size as usize);
let mut file = file;
if file.read_to_end(&mut buf).is_err() {
return internal_error_response();
}
Bytes::from(buf)
};
finish(Response::builder()
.status(StatusCode::OK)
.header("X-Content-Type-Options", "nosniff")
.header("Content-Length", file_size.to_string())
.header("ETag", etag)
.body(into_response_body(Full::new(body_bytes))))
}
Err(e) => {
let message = e.user_message();
let body = format!("{}\n", message);
finish(Response::builder()
.status(StatusCode::NOT_FOUND)
.header("X-Content-Type-Options", "nosniff")
.body(into_response_body(Full::new(Bytes::from(body)))))
}
}
}
}
async fn serve_connection(stream: TcpStream, server: Server, header_timeout: Duration) {
let io = TokioIo::new(stream);
let svc = service_fn(move |req: Request<Incoming>| {
let server = server.clone();
async move {
let method = req.method().clone();
let path = req.uri().path().to_string();
let if_none_match = req.headers().get("if-none-match").and_then(|v| v.to_str().ok());
let if_modified_since = req.headers().get("if-modified-since").and_then(|v| v.to_str().ok());
let resp = server.handle_request_async(&method, &path, if_none_match, if_modified_since).await;
Ok::<_, Infallible>(resp)
}
});
let _ = timeout(
header_timeout,
AutoBuilder::new(TokioExecutor::new()).serve_connection(io, svc),
).await;
}
pub struct ServerHandle {
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
accept_task: tokio::task::JoinHandle<()>,
}
impl ServerHandle {
pub async fn shutdown(mut self) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
let _ = self.accept_task.await;
}
}
fn into_response_body(body: Full<Bytes>) -> ResponseBody {
ResponseBody::Buffered(body)
}
fn finish(built: Result<Response<ResponseBody>, hyper::http::Error>) -> Response<ResponseBody> {
built.unwrap_or_else(|_| bad_request_response())
}
const DEFAULT_MAX_CONNECTIONS: usize = 1024;
fn internal_error_response() -> Response<ResponseBody> {
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header("X-Content-Type-Options", "nosniff")
.body(into_response_body(Full::new(Bytes::from(
"internal server error\n",
))))
.unwrap()
}
fn bad_request_response() -> Response<ResponseBody> {
Response::builder()
.status(StatusCode::BAD_REQUEST)
.header("X-Content-Type-Options", "nosniff")
.body(into_response_body(Full::new(Bytes::from("bad request\n"))))
.unwrap()
}
fn generate_etag(metadata: &fs::Metadata) -> String {
let size = metadata.len();
let mtime = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
format!("\"{}-{}\"", size, mtime)
}
fn mime_type_for_path(path: &Path) -> &'static str {
path.extension()
.and_then(|ext| ext.to_str())
.and_then(|ext| match ext.to_lowercase().as_str() {
"html" | "htm" => Some("text/html; charset=utf-8"),
"css" => Some("text/css; charset=utf-8"),
"js" => Some("application/javascript; charset=utf-8"),
"json" => Some("application/json; charset=utf-8"),
"svg" => Some("image/svg+xml"),
"png" => Some("image/png"),
"jpg" | "jpeg" => Some("image/jpeg"),
"gif" => Some("image/gif"),
"webp" => Some("image/webp"),
"ico" => Some("image/x-icon"),
"woff" => Some("font/woff"),
"woff2" => Some("font/woff2"),
"ttf" => Some("font/ttf"),
"md" | "markdown" => Some("text/markdown; charset=utf-8"),
"txt" => Some("text/plain; charset=utf-8"),
"xml" => Some("application/xml"),
"pdf" => Some("application/pdf"),
"zip" => Some("application/zip"),
_ => None,
})
.unwrap_or("application/octet-stream")
}
fn is_etag_match(if_none_match: &str, etag: &str) -> bool {
if if_none_match == "*" {
return true;
}
if_none_match.split(',').any(|tag| tag.trim() == etag)
}
fn is_not_modified_since(if_modified_since: &str, metadata: &fs::Metadata) -> bool {
let file_mtime = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
if let Ok(client_time) = if_modified_since.parse::<u64>() {
return file_mtime <= client_time;
}
false
}
#[cfg(test)]
mod file_body_tests {
use super::*;
use crate::handler::FILE_CHUNK_SIZE;
use http_body_util::BodyExt;
#[tokio::test]
async fn file_body_yields_multiple_bounded_chunks_not_one_buffered_frame() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("big.bin");
let content = vec![7u8; FILE_CHUNK_SIZE * 3 + 12_345];
fs::write(&path, &content).unwrap();
let file = File::open(&path).await.unwrap();
let mut body = FileBody::new(file);
let mut frame_count = 0usize;
let mut max_frame_len = 0usize;
let mut reassembled = Vec::new();
while let Some(frame) = body.frame().await {
let frame = frame.unwrap();
let data = frame.into_data().unwrap();
frame_count += 1;
max_frame_len = max_frame_len.max(data.len());
reassembled.extend_from_slice(&data);
}
assert!(
frame_count > 1,
"expected the file to be delivered as multiple frames, got {frame_count}"
);
assert!(
max_frame_len <= FILE_CHUNK_SIZE,
"no single frame should exceed the chunk size ({FILE_CHUNK_SIZE}), got {max_frame_len}"
);
assert_eq!(reassembled, content, "reassembled chunks must match original file content exactly");
}
}
#[cfg(test)]
mod accept_tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
#[test]
fn backoff_doubles_up_to_max() {
let mut backoff = Backoff::new();
let mut last = backoff.next_delay();
assert_eq!(last, ACCEPT_BACKOFF_INITIAL);
for _ in 0..20 {
last = backoff.next_delay();
}
assert_eq!(last, ACCEPT_BACKOFF_MAX);
}
#[test]
fn backoff_reset_returns_to_initial_delay() {
let mut backoff = Backoff::new();
backoff.next_delay();
backoff.next_delay();
backoff.reset();
assert_eq!(backoff.next_delay(), ACCEPT_BACKOFF_INITIAL);
}
struct FlakyListener {
inner: TcpListener,
remaining_failures: AtomicUsize,
attempts: Mutex<Vec<tokio::time::Instant>>,
}
impl TcpAccept for FlakyListener {
async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)> {
self.attempts.lock().unwrap().push(tokio::time::Instant::now());
if self.remaining_failures.fetch_sub(1, Ordering::SeqCst) > 0 {
Err(std::io::Error::other("simulated accept error"))
} else {
TcpAccept::accept(&self.inner).await
}
}
}
#[tokio::test(start_paused = true)]
async fn accept_loop_backs_off_between_repeated_errors_instead_of_busy_spinning() {
let inner = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let addr = inner.local_addr().unwrap();
let flaky = FlakyListener {
inner,
remaining_failures: AtomicUsize::new(5),
attempts: Mutex::new(Vec::new()),
};
tokio::spawn(async move {
let _ = TcpStream::connect(addr).await;
});
let semaphore = Arc::new(Semaphore::new(1));
let mut backoff = Backoff::new();
let result = accept_and_permit(&flaky, &mut backoff, &semaphore).await;
assert!(result.is_some(), "accept should eventually succeed once the flaky listener stops failing");
let recorded = flaky.attempts.lock().unwrap();
assert_eq!(recorded.len(), 6, "5 failures then 1 success");
let expected_gaps = [
ACCEPT_BACKOFF_INITIAL,
ACCEPT_BACKOFF_INITIAL * 2,
ACCEPT_BACKOFF_INITIAL * 4,
ACCEPT_BACKOFF_INITIAL * 8,
ACCEPT_BACKOFF_INITIAL * 16,
];
for (i, expected) in expected_gaps.iter().enumerate() {
let gap = recorded[i + 1] - recorded[i];
assert_eq!(
gap, *expected,
"gap between attempt {i} and {} should reflect the backoff delay, not a busy spin",
i + 1
);
}
}
}
#[cfg(test)]
mod finish_tests {
use super::*;
#[test]
fn finish_degrades_to_400_on_invalid_header_value_instead_of_panicking() {
let built = Response::builder()
.status(StatusCode::OK)
.header("X-Test", "invalid\r\nvalue")
.body(into_response_body(Full::new(Bytes::new())));
assert!(built.is_err(), "CR/LF in a header value should be rejected by the builder");
let response = finish(built);
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"finish() should degrade to 400 rather than panicking on an invalid header value"
);
}
}