use std::fs;
use std::io::Write;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime};
use bytes::Bytes;
use http_body_util::Full;
use hyper::header::{self, HeaderName, HeaderValue};
use hyper::http::response::Builder;
use hyper::{HeaderMap, Method, Request, Response, StatusCode};
use tokio::fs::File;
use tokio::net::TcpListener;
use tokio::time::timeout;
use crate::error::StaticError;
use crate::handler::{FileBody, ResponseBody};
use crate::reload::{self, SseBody};
use crate::resolve;
use crate::resolve::HiddenFiles;
use crate::spa::{self, SpaTransition};
use crate::watcher::{start_watching, Broadcaster};
const DEFAULT_MAX_CONNECTIONS: usize = 1024;
type ImmutablePredicate = Arc<dyn Fn(&Path) -> bool + Send + Sync>;
const SERVER_COMPUTED_HEADERS: [HeaderName; 13] = [
header::CONTENT_LENGTH,
header::CONTENT_TYPE,
header::CONTENT_ENCODING,
header::CONTENT_RANGE,
header::ETAG,
header::CACHE_CONTROL,
header::VARY,
header::ACCEPT_RANGES,
header::ALLOW,
header::LOCATION,
header::CONNECTION,
header::TRANSFER_ENCODING,
header::X_CONTENT_TYPE_OPTIONS,
];
type RequestLog = Arc<Mutex<Box<dyn Write + Send>>>;
#[derive(Clone)]
pub struct Server {
root_canon: PathBuf,
max_connections: usize,
live_reload: bool,
broadcaster: Option<Broadcaster>,
spa_mode: bool,
spa_root: Option<String>,
spa_transition: SpaTransition,
not_found_page: Option<PathBuf>,
hidden_files: HiddenFiles,
precompressed: bool,
content_cache: Option<Arc<crate::cache::ContentCache>>,
request_log: Option<RequestLog>,
extra_headers: Arc<Vec<(HeaderName, HeaderValue)>>,
immutable_predicate: Option<ImmutablePredicate>,
}
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,
live_reload: false,
broadcaster: None,
spa_mode: false,
spa_root: None,
spa_transition: SpaTransition::default(),
not_found_page: None,
hidden_files: HiddenFiles::Deny,
precompressed: true,
content_cache: None,
request_log: None,
extra_headers: Arc::new(Vec::new()),
immutable_predicate: None,
})
}
pub fn with_max_connections(mut self, max: usize) -> Self {
self.max_connections = max;
self
}
pub fn with_live_reload(mut self) -> Self {
self.live_reload = true;
self
}
pub fn with_spa_mode(mut self) -> Self {
self.spa_mode = true;
self
}
pub fn with_spa_root(mut self, selector: &str) -> Self {
self.spa_mode = true;
self.spa_root = Some(selector.to_string());
self
}
pub fn with_spa_transition(mut self, transition: SpaTransition) -> Self {
self.spa_mode = true;
self.spa_transition = transition;
self
}
pub fn with_not_found_page(mut self, path: &Path) -> Result<Self, StaticError> {
let joined = self.root_canon.join(path);
let canon = joined.canonicalize().map_err(StaticError::Io)?;
if !canon.starts_with(&self.root_canon) {
return Err(StaticError::Traversal(format!(
"404 page {} lies outside the served root {}",
canon.display(),
self.root_canon.display()
)));
}
self.not_found_page = Some(canon);
Ok(self)
}
pub fn with_hidden_files(mut self) -> Self {
self.hidden_files = HiddenFiles::Serve;
self
}
fn cached_sidecar(
&self,
relative: &Path,
accept_encoding: Option<&str>,
) -> Option<(&crate::cache::CachedFile, &'static str)> {
let cache = self.content_cache.as_ref()?;
for (encoding, ext) in preferred_encodings(accept_encoding) {
let mut sibling = relative.as_os_str().to_os_string();
sibling.push(ext);
if let Some(entry) = cache.get(Path::new(&sibling)) {
return Some((entry, encoding));
}
}
None
}
fn cached_entry<S: AsRef<str>>(
&self,
segments: Option<&[S]>,
request_path: &str,
) -> Option<(PathBuf, &crate::cache::CachedFile)> {
let cache = self.content_cache.as_ref()?;
let decoded = match segments {
Some(_) => None,
None => Some(resolve::decode_segments(request_path).ok()?),
};
let key: PathBuf = match (segments, &decoded) {
(Some(given), _) => resolve::servable_segments(given, request_path, self.hidden_files)
.ok()?
.iter()
.map(|segment| segment.as_ref())
.collect(),
(None, Some(own)) => {
resolve::servable_segments(own, request_path, self.hidden_files).ok()?;
own.iter().map(|segment| segment.as_ref()).collect()
}
(None, None) => return None,
};
let direct = self.content_cache.as_ref().and_then(|c| c.get(&key)).map(|entry| (key.clone(), entry));
let found = match direct {
Some(found) => found,
None => {
let index = key.join(resolve::INDEX_FILE_NAME);
let entry = cache.get(&index)?;
(index, entry)
}
};
Some(found)
}
fn cache_conflict(&self) -> Option<StaticError> {
(self.live_reload && self.content_cache.is_some()).then(|| {
StaticError::Config(
"a content cache and live-reload cannot both be enabled: live-reload watches \
the served root for changes, and the cache is never invalidated, so every \
change it reported would be a change the server did not serve. Drop \
with_content_cache for development, or with_live_reload for production."
.to_string(),
)
})
}
pub fn with_content_cache(mut self, max_bytes: usize) -> Result<Self, StaticError> {
let cache = crate::cache::populate(&self.root_canon, max_bytes);
self.log(format_args!(
"content cache: {} files, {} bytes, {} with precompressed siblings{}",
cache.len(),
cache.bytes_held(),
cache.with_siblings(),
if cache.truncated() {
format!(" (truncated at the {max_bytes}-byte ceiling; the rest serves from disk)")
} else {
String::new()
}
));
self.content_cache = Some(Arc::new(cache));
match self.cache_conflict() {
Some(conflict) => Err(conflict),
None => Ok(self),
}
}
pub fn without_precompressed(mut self) -> Self {
self.precompressed = false;
self
}
pub fn with_response_header(mut self, name: &str, value: &str) -> Result<Self, StaticError> {
let name = HeaderName::from_bytes(name.as_bytes())
.map_err(|_| StaticError::Config(format!("invalid header name: {name}")))?;
let value = HeaderValue::from_str(value).map_err(|_| {
StaticError::Config(format!("invalid value for header {name}: {value}"))
})?;
if SERVER_COMPUTED_HEADERS.contains(&name) {
return Err(StaticError::Config(format!(
"{name} is computed per response and cannot be set as a fixed header"
)));
}
Arc::make_mut(&mut self.extra_headers).push((name, value));
Ok(self)
}
pub fn with_request_logging(self) -> Self {
self.with_request_logging_to(Box::new(std::io::stderr()))
}
pub fn with_request_logging_to(mut self, writer: Box<dyn Write + Send>) -> Self {
self.request_log = Some(Arc::new(Mutex::new(writer)));
self
}
fn response(&self, status: StatusCode) -> Builder {
let mut builder = response(status);
for (name, value) in self.extra_headers.iter() {
builder = builder.header(name, value);
}
builder
}
fn log(&self, line: std::fmt::Arguments<'_>) {
let Some(log) = &self.request_log else {
return;
};
if let Ok(mut sink) = log.lock() {
let _ = writeln!(sink, "{line}");
let _ = sink.flush();
}
}
pub fn with_immutable_assets<F>(mut self, predicate: F) -> Self
where
F: Fn(&Path) -> bool + Send + Sync + 'static,
{
self.immutable_predicate = Some(Arc::new(predicate));
self
}
fn cache_control_for(&self, path: &Path) -> &'static str {
match &self.immutable_predicate {
Some(predicate) if predicate(path) => "public, max-age=31536000, immutable",
_ => "no-cache",
}
}
fn watch_targets(&self) -> Vec<PathBuf> {
vec![self.root_canon.clone()]
}
pub fn resolve(&self, request_path: &str) -> Result<PathBuf, StaticError> {
resolve::resolve_with_policy(&self.root_canon, request_path, self.hidden_files)
}
async fn not_found_response(&self, fallback: &'static str) -> Response<ResponseBody> {
let builder = self
.response(StatusCode::NOT_FOUND)
.header("Cache-Control", "no-store");
let Some(page) = &self.not_found_page else {
return text(builder, format!("{fallback}\n"));
};
let Ok(body) = tokio::fs::read(page).await else {
return text(builder, format!("{fallback}\n"));
};
text(
builder.header("Content-Type", "text/html; charset=utf-8"),
body,
)
}
pub fn into_fallback<S: Send + Sync + 'static>(self) -> mini_serve::Handler<S> {
if let Some(conflict) = self.cache_conflict() {
let message = conflict.to_string();
self.log(format_args!("refusing to serve: {message}"));
return mini_serve::handler(move |_req, _state| {
let message = message.clone();
async move { Err(mini_serve::ServeError::new(500, message)) }
});
}
let server = Arc::new(self);
mini_serve::handler(move |req, _state| {
let server = Arc::clone(&server);
async move {
let mut req = req;
let segments = req
.extensions_mut()
.remove::<mini_serve::PathSegments>()
.map(|s| s.0)
.unwrap_or_default();
let started = Instant::now();
let method = req.method().clone();
let path = req.uri().path().to_string();
let resp = server.respond(&req, &segments).await;
let bytes = header_str(resp.headers(), "content-length").unwrap_or("-");
server.log(format_args!(
"{method} {path} {} {bytes} {:.3}ms",
resp.status().as_u16(),
started.elapsed().as_secs_f64() * 1000.0,
));
Ok(bridge_body(resp))
}
})
}
fn into_app(self, header_timeout: Duration) -> mini_serve::App<()> {
let max_connections = self.max_connections;
mini_serve::RouteBuilder::stateless()
.with_header_read_timeout(header_timeout)
.with_max_connections(max_connections)
.with_max_header_bytes(MAX_HEADER_BYTES)
.with_fallback(self.into_fallback())
.seal()
}
pub async fn run_on(
&self,
addr: SocketAddr,
header_timeout: Duration,
) -> Result<(u16, ServerHandle), StaticError> {
if let Some(conflict) = self.cache_conflict() {
return Err(conflict);
}
let listener = TcpListener::bind(addr).await.map_err(StaticError::Io)?;
let port = listener.local_addr().map_err(StaticError::Io)?.port();
let mut server = self.clone();
if server.live_reload {
let broadcaster = Broadcaster::new();
for dir in server.watch_targets() {
start_watching(Arc::new(dir), broadcaster.clone());
}
server.broadcaster = Some(broadcaster);
}
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
let app = server.into_app(header_timeout);
let accept_task = tokio::spawn(async move {
let _ = app
.run(listener, async move {
let _ = shutdown_rx.await;
})
.await;
});
Ok((
port,
ServerHandle {
shutdown_tx: Some(shutdown_tx),
accept_task,
},
))
}
pub async fn run(&self, header_timeout: Duration) -> Result<(u16, ServerHandle), StaticError> {
self.run_on((EPHEMERAL_BIND_IP, 0).into(), header_timeout).await
}
pub async fn run_all(
&self,
port: u16,
header_timeout: Duration,
) -> Result<(u16, ServerHandle), StaticError> {
self.run_on(([0, 0, 0, 0], port).into(), header_timeout)
.await
}
pub async fn run_ephemeral(&self) -> Result<(u16, ServerHandle), StaticError> {
self.run(DEFAULT_HEADER_TIMEOUT).await
}
pub async fn handle_request(
&self,
method: &Method,
request_path: &str,
headers: &HeaderMap,
) -> Response<ResponseBody> {
self.serve(method, request_path, headers, None::<&[String]>).await
}
pub async fn respond<B>(
&self,
req: &Request<B>,
segments: &[String],
) -> Response<ResponseBody> {
self.serve(req.method(), req.uri().path(), req.headers(), Some(segments))
.await
}
async fn serve<S: AsRef<str>>(
&self,
method: &Method,
request_path: &str,
headers: &HeaderMap,
segments: Option<&[S]>,
) -> Response<ResponseBody> {
if method != Method::GET && method != Method::HEAD {
return text(
self.response(StatusCode::METHOD_NOT_ALLOWED)
.header("Allow", "GET, HEAD"),
"method not allowed\n",
);
}
if *method == Method::GET && request_path == reload::LIVE_RELOAD_PATH {
if let Some(broadcaster) = &self.broadcaster {
return finish(
self.response(StatusCode::OK)
.header("Content-Type", "text/event-stream")
.header("Cache-Control", "no-cache")
.header("Connection", "keep-alive")
.body(ResponseBody::Sse(SseBody::new(broadcaster.subscribe()))),
);
}
}
let cached = self.cached_entry(segments, request_path);
let (source, metadata, path, cached_key) = match cached {
Some((relative, entry)) => (
BodySource::Memory(entry.bytes.clone()),
entry.metadata.clone(),
self.root_canon.join(&relative),
Some(relative),
),
None => {
let opened = match segments {
Some(segments) => resolve::open_segments(
&self.root_canon,
segments,
request_path,
self.hidden_files,
),
None => {
resolve::open_with_policy(&self.root_canon, request_path, self.hidden_files)
}
};
let resolved = match opened {
Err(e) => return self.not_found_response(e.user_message()).await,
Ok(resolved) => resolved,
};
(
BodySource::Descriptor(resolved.file),
resolved.metadata,
resolved.path,
None,
)
}
};
let last_segment = resolve::decode_segments(request_path)
.ok()
.and_then(|segments| segments.last().cloned())
.unwrap_or_default();
if path.file_name().is_some_and(|name| name == resolve::INDEX_FILE_NAME)
&& !request_path.ends_with('/')
&& last_segment != resolve::INDEX_FILE_NAME
{
let location = format!("{}/", request_path.trim_end_matches('/'));
return text(
self.response(StatusCode::MOVED_PERMANENTLY)
.header("Location", location),
"moved\n",
);
}
let content_type = mime_type_for_path(&path);
let wants_injection =
(self.broadcaster.is_some() || self.spa_mode) && content_type.starts_with("text/html");
let html_injection = wants_injection && metadata.len() <= MAX_INJECTABLE_HTML_BYTES;
if wants_injection && !html_injection {
self.log(format_args!(
"html injection skipped for {request_path}: {} bytes exceeds the \
{MAX_INJECTABLE_HTML_BYTES}-byte limit; serving unmodified",
metadata.len(),
));
}
let range_header = header_str(headers, "range");
let if_range_header = header_str(headers, "if-range");
let accept_encoding = header_str(headers, "accept-encoding");
let wants_sidecar = self.precompressed && !html_injection && range_header.is_none();
let cached_variant = match (wants_sidecar, &cached_key) {
(true, Some(relative)) => self.cached_sidecar(relative, accept_encoding),
_ => None,
};
let (source, metadata, content_encoding) = match cached_variant {
Some((entry, encoding)) => (
BodySource::Memory(entry.bytes.clone()),
entry.metadata.clone(),
Some(encoding),
),
None if wants_sidecar => {
match select_precompressed_sidecar(&self.root_canon, &path, accept_encoding) {
Some((sidecar_file, sidecar_metadata, encoding)) => (
BodySource::Descriptor(sidecar_file),
sidecar_metadata,
Some(encoding),
),
None => (source, metadata, None),
}
}
None => (source, metadata, None),
};
let etag = generate_etag(&metadata);
let cache_control = self.cache_control_for(&path);
if header_str(headers, "if-none-match").is_some_and(|value| is_etag_match(value, &etag)) {
return finish(
self.response(StatusCode::NOT_MODIFIED)
.header("Cache-Control", cache_control)
.header("Vary", "Accept-Encoding")
.header("ETag", etag)
.header("Accept-Ranges", "bytes")
.body(ResponseBody::Buffered(Full::new(Bytes::new()))),
);
}
let source = if html_injection {
use std::io::Read as _;
let mut html = match source {
BodySource::Memory(bytes) => bytes.to_vec(),
BodySource::Descriptor(mut file) => {
let mut buffer = Vec::with_capacity(metadata.len() as usize);
if file.read_to_end(&mut buffer).is_err() {
return internal_error_response();
}
buffer
}
};
if self.broadcaster.is_some() {
reload::inject_reload_script(&mut html);
}
if self.spa_mode {
spa::inject_spa_script(&mut html, self.spa_root.as_deref(), &self.spa_transition);
}
BodySource::Memory(Bytes::from(html))
} else {
source
};
let file_size = match &source {
BodySource::Memory(bytes) => bytes.len() as u64,
BodySource::Descriptor(_) => metadata.len(),
};
let range_outcome = range_header.map(|h| parse_range_header(h, file_size));
let range_check = if let Some(outcome) = &range_outcome {
match outcome {
RangeOutcome::Satisfiable(start, end) => {
if let Some(if_range) = if_range_header {
if !if_range_valid(if_range, &etag) {
RangeCheck::IgnoreRange
} else {
RangeCheck::Satisfiable(*start, *end)
}
} else {
RangeCheck::Satisfiable(*start, *end)
}
}
RangeOutcome::MultiRangeIgnored => RangeCheck::IgnoreRange,
RangeOutcome::Unsatisfiable => RangeCheck::Unsatisfiable,
RangeOutcome::NoRange => RangeCheck::IgnoreRange,
}
} else {
RangeCheck::IgnoreRange
};
match &range_check {
RangeCheck::Unsatisfiable => {
return finish(
Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header("Content-Range", format!("bytes */{}", file_size))
.header("Accept-Ranges", "bytes")
.body(ResponseBody::Buffered(Full::new(Bytes::new()))),
);
}
RangeCheck::Satisfiable(start, end) => {
let range_len = end - start + 1;
let body = if *method == Method::HEAD {
ResponseBody::Buffered(Full::new(Bytes::new()))
} else {
match source {
BodySource::Memory(bytes) => ResponseBody::Buffered(Full::new(
bytes.slice(*start as usize..(*end as usize + 1)),
)),
BodySource::Descriptor(mut file) => {
if std::io::Seek::seek(&mut file, std::io::SeekFrom::Start(*start))
.is_err()
{
return internal_error_response();
}
ResponseBody::Streamed(FileBody::new_ranged(
File::from_std(file),
range_len,
))
}
}
};
let mut builder = Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header("Content-Type", content_type)
.header("Content-Length", range_len.to_string())
.header(
"Content-Range",
format!("bytes {}-{}/{}", start, end, file_size),
)
.header("Cache-Control", cache_control)
.header("Vary", "Accept-Encoding")
.header("ETag", etag)
.header("Accept-Ranges", "bytes");
if let Some(encoding) = content_encoding {
builder = builder.header("Content-Encoding", encoding);
}
return finish(builder.body(body));
}
RangeCheck::IgnoreRange => {}
}
let body = if *method == Method::HEAD {
ResponseBody::Buffered(Full::new(Bytes::new()))
} else {
match source {
BodySource::Memory(bytes) => ResponseBody::Buffered(Full::new(bytes)),
BodySource::Descriptor(mut file) if metadata.len() <= INLINE_BODY_BYTES => {
let mut bytes = Vec::with_capacity(metadata.len() as usize);
use std::io::Read as _;
if file.read_to_end(&mut bytes).is_err() {
return internal_error_response();
}
ResponseBody::Buffered(Full::new(Bytes::from(bytes)))
}
BodySource::Descriptor(file) => {
ResponseBody::Streamed(FileBody::new(File::from_std(file)))
}
}
};
let mut builder = self
.response(StatusCode::OK)
.header("Content-Type", content_type)
.header("Content-Length", file_size.to_string())
.header("Cache-Control", cache_control)
.header("Vary", "Accept-Encoding")
.header("ETag", etag)
.header("Accept-Ranges", "bytes");
if let Some(encoding) = content_encoding {
builder = builder.header("Content-Encoding", encoding);
}
finish(builder.body(body))
}
}
const MAX_HEADER_BYTES: usize = 64 * 1024;
const MAX_INJECTABLE_HTML_BYTES: u64 = 8 * 1024 * 1024;
const INLINE_BODY_BYTES: u64 = 64 * 1024;
const EPHEMERAL_BIND_IP: std::net::Ipv4Addr = std::net::Ipv4Addr::LOCALHOST;
fn bridge_body(response: Response<ResponseBody>) -> Response<mini_serve::ResponseBody> {
let (parts, body) = response.into_parts();
let erased = http_body_util::BodyExt::map_err(body, mini_serve::BodyError::new);
Response::from_parts(parts, http_body_util::BodyExt::boxed(erased))
}
const DEFAULT_HEADER_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
pub struct ServerHandle {
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
accept_task: tokio::task::JoinHandle<()>,
}
impl ServerHandle {
pub async fn shutdown(self) {
self.shutdown_with_timeout(DEFAULT_SHUTDOWN_DRAIN_TIMEOUT)
.await;
}
pub async fn shutdown_with_timeout(mut self, drain_timeout: Duration) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
if timeout(drain_timeout, &mut self.accept_task).await.is_err() {
self.accept_task.abort();
}
}
}
fn header_str<'h>(headers: &'h HeaderMap, name: &str) -> Option<&'h str> {
headers.get(name).and_then(|value| value.to_str().ok())
}
fn response(status: StatusCode) -> Builder {
Response::builder()
.status(status)
.header("X-Content-Type-Options", "nosniff")
}
fn text(builder: Builder, body: impl Into<Bytes>) -> Response<ResponseBody> {
finish(builder.body(ResponseBody::Buffered(Full::new(body.into()))))
}
fn finish(built: Result<Response<ResponseBody>, hyper::http::Error>) -> Response<ResponseBody> {
built.unwrap_or_else(|_| bad_request_response())
}
fn internal_error_response() -> Response<ResponseBody> {
response(StatusCode::INTERNAL_SERVER_ERROR)
.body(ResponseBody::Buffered(Full::new(Bytes::from_static(
b"internal server error\n",
))))
.unwrap()
}
fn bad_request_response() -> Response<ResponseBody> {
response(StatusCode::BAD_REQUEST)
.body(ResponseBody::Buffered(Full::new(Bytes::from_static(
b"bad request\n",
))))
.unwrap()
}
const SIDECAR_ENCODINGS: [(&str, &str); 2] = [("br", ".br"), ("gzip", ".gz")];
const QVALUE_SCALE: f32 = 1000.0;
const DEFAULT_QVALUE: u16 = 1000;
fn encoding_quality(accept_encoding: &str, encoding: &str) -> Option<u16> {
accept_encoding.split(',').find_map(|entry| {
let mut parts = entry.split(';');
if !parts.next()?.trim().eq_ignore_ascii_case(encoding) {
return None;
}
let quality = parts
.find_map(|parameter| {
let (key, value) = parameter.split_once('=')?;
key.trim().eq_ignore_ascii_case("q").then(|| value.trim())
})
.and_then(|value| value.parse::<f32>().ok())
.map(|value| (value.clamp(0.0, 1.0) * QVALUE_SCALE).round() as u16)
.unwrap_or(DEFAULT_QVALUE);
Some(quality)
})
}
fn preferred_encodings(accept_encoding: Option<&str>) -> Vec<(&'static str, &'static str)> {
let mut candidates: Vec<(&'static str, &'static str, u16)> = SIDECAR_ENCODINGS
.iter()
.filter_map(|(encoding, ext)| {
let quality = accept_encoding.and_then(|header| encoding_quality(header, encoding))?;
(quality > 0).then_some((*encoding, *ext, quality))
})
.collect();
candidates.sort_by_key(|(_, _, quality)| std::cmp::Reverse(*quality));
candidates
.into_iter()
.map(|(encoding, ext, _)| (encoding, ext))
.collect()
}
fn select_precompressed_sidecar(
root_canon: &Path,
path: &Path,
accept_encoding: Option<&str>,
) -> Option<(std::fs::File, fs::Metadata, &'static str)> {
for (encoding, ext) in preferred_encodings(accept_encoding) {
let mut sidecar = path.as_os_str().to_os_string();
sidecar.push(ext);
let sidecar_path = PathBuf::from(sidecar);
if let Some(resolved) = resolve::open_sidecar_verified(root_canon, &sidecar_path) {
return Some((resolved.file, resolved.metadata, encoding));
}
}
None
}
fn generate_etag(metadata: &fs::Metadata) -> String {
let mtime = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
.unwrap_or_default();
format!(
"\"{}-{}.{}\"",
metadata.len(),
mtime.as_secs(),
mtime.subsec_nanos()
)
}
enum BodySource {
Memory(Bytes),
Descriptor(std::fs::File),
}
fn mime_type_for_path(path: &Path) -> &'static str {
let ext = path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or_default()
.to_lowercase();
match ext.as_str() {
"html" | "htm" => "text/html; charset=utf-8",
"css" => "text/css; charset=utf-8",
"js" => "application/javascript; charset=utf-8",
"json" => "application/json; charset=utf-8",
"svg" => "image/svg+xml",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"ico" => "image/x-icon",
"woff" => "font/woff",
"woff2" => "font/woff2",
"ttf" => "font/ttf",
"md" | "markdown" => "text/markdown; charset=utf-8",
"txt" => "text/plain; charset=utf-8",
"xml" => "application/xml",
"pdf" => "application/pdf",
"zip" => "application/zip",
_ => "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)
}
#[derive(Debug)]
enum RangeOutcome {
NoRange,
Satisfiable(u64, u64),
Unsatisfiable,
MultiRangeIgnored,
}
enum RangeCheck {
IgnoreRange,
Satisfiable(u64, u64),
Unsatisfiable,
}
fn parse_range_header(header: &str, file_size: u64) -> RangeOutcome {
let header = header.trim();
if !header.starts_with("bytes=") {
return RangeOutcome::NoRange;
}
let range_spec = &header[6..];
if range_spec.contains(',') {
return RangeOutcome::MultiRangeIgnored;
}
if let Some(suffix_pos) = range_spec.find('-') {
if suffix_pos == 0 {
let suffix_len_str = &range_spec[1..];
if let Ok(suffix_len) = suffix_len_str.parse::<u64>() {
if suffix_len == 0 {
return RangeOutcome::Unsatisfiable;
}
if suffix_len >= file_size {
return RangeOutcome::Satisfiable(0, file_size - 1);
}
return RangeOutcome::Satisfiable(file_size - suffix_len, file_size - 1);
}
return RangeOutcome::Unsatisfiable;
}
let start_str = &range_spec[..suffix_pos];
let end_str = &range_spec[suffix_pos + 1..];
if let Ok(start) = start_str.parse::<u64>() {
if start >= file_size {
return RangeOutcome::Unsatisfiable;
}
if end_str.is_empty() {
return RangeOutcome::Satisfiable(start, file_size - 1);
}
if let Ok(end) = end_str.parse::<u64>() {
if end < start {
return RangeOutcome::Unsatisfiable;
}
let clamped_end = (end + 1).min(file_size) - 1;
if start > clamped_end {
return RangeOutcome::Unsatisfiable;
}
return RangeOutcome::Satisfiable(start, clamped_end);
}
}
}
RangeOutcome::Unsatisfiable
}
fn if_range_valid(if_range_header: &str, current_etag: &str) -> bool {
if_range_header.trim() == current_etag
}
#[cfg(test)]
mod bind_address_tests {
use super::EPHEMERAL_BIND_IP;
#[test]
fn the_ephemeral_bind_address_is_loopback() {
assert!(
EPHEMERAL_BIND_IP.is_loopback(),
"run_ephemeral would expose the server on {EPHEMERAL_BIND_IP}"
);
}
}
#[cfg(test)]
#[path = "../tests/unit/server/precompressed_sidecar.rs"]
mod precompressed_sidecar_tests;
#[cfg(test)]
#[path = "../tests/unit/server/file_body.rs"]
mod file_body_tests;
#[cfg(test)]
#[path = "../tests/unit/server/finish.rs"]
mod finish_tests;
#[cfg(test)]
#[path = "../tests/unit/server/etag.rs"]
mod etag_tests;
#[cfg(test)]
#[path = "../tests/unit/server/range_header.rs"]
mod range_header_tests;