//! Static file handler.
//!
//! Serves files from a document root with support for:
//! - Index file resolution (index.html, index.htm, ...)
//! - Automatic directory listing
//! - ETag / If-Modified-Since / If-None-Match conditional responses
//! - Byte-range requests (single range)
//! - Gzip / deflate compression for text-based content types
//! - Security: directory traversal prevention, hidden-file denial
use std::fs::{self, File, Metadata};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, UNIX_EPOCH};
use bytes::Bytes;
use chrono::{DateTime, Utc};
use flate2::read::{DeflateEncoder, GzEncoder};
use flate2::Compression;
use tracing::{debug, warn};
use crate::handler::file_cache::{CachedFile, FileCache};
use crate::http::request::Request;
use crate::http::response::{Body, CachedResponse, Response};
use crate::http::status::HttpStatusCode;
use crate::util::path as util_path;
// ---------------------------------------------------------------------------
// Default configuration values
// ---------------------------------------------------------------------------
const DEFAULT_INDEX_FILES: &[&str] = &["index.html", "index.htm"];
const DEFAULT_GZIP_TYPES: &[&str] = &[
"text/html",
"text/css",
"text/plain",
"text/xml",
"text/javascript",
"application/json",
"application/javascript",
"application/xml",
"application/xhtml+xml",
"application/rss+xml",
"application/atom+xml",
"application/ld+json",
"image/svg+xml",
];
const DEFAULT_GZIP_MIN_LENGTH: usize = 256;
const GZIP_COMPRESSION_LEVEL: u32 = 6;
/// How long an open-file-cache entry stays valid before re-validation.
const FILE_CACHE_TTL: Duration = Duration::from_secs(2);
/// Maximum number of distinct files held open in the cache.
const FILE_CACHE_MAX_ENTRIES: usize = 1024;
/// Files at or below this size are cached with body inline and served with a
/// single `write` (cheaper than `sendfile`'s syscall overhead for tiny
/// payloads). Larger files are streamed zero-copy with `sendfile`, which
/// avoids the userspace copy that would otherwise dominate their cost.
const SMALL_FILE_LIMIT: u64 = 4 * 1024;
// Common MIME type constant -- avoids repeated string allocations.
const CT_HTML: &str = "text/html; charset=utf-8";
// ---------------------------------------------------------------------------
// StaticFileHandler
// ---------------------------------------------------------------------------
/// Serves static files from a configured document root.
#[derive(Debug, Clone)]
pub struct StaticFileHandler {
/// Document root directory.
root: PathBuf,
/// Index file names tried when a directory is requested.
index_files: Vec<String>,
/// When `true`, generate an HTML directory listing if no index file is
/// found.
autoindex: bool,
/// When `true`, generate `ETag` headers and honour conditional requests.
etag: bool,
/// When `true`, compress eligible responses with gzip/deflate.
gzip: bool,
/// MIME types eligible for compression.
gzip_types: Vec<String>,
/// Minimum response body length (bytes) before compression is applied.
gzip_min_length: usize,
/// Open-file cache shared across clones of this handler.
cache: Arc<FileCache>,
}
impl StaticFileHandler {
// ------------------------------------------------------------------
// Construction
// ------------------------------------------------------------------
/// Create a new handler with explicit configuration.
pub fn new(
root: PathBuf,
index_files: Vec<String>,
autoindex: bool,
etag: bool,
gzip: bool,
gzip_types: Vec<String>,
gzip_min_length: usize,
) -> Self {
Self {
root,
index_files,
autoindex,
etag,
gzip,
gzip_types,
gzip_min_length,
cache: Arc::new(FileCache::new(FILE_CACHE_TTL, FILE_CACHE_MAX_ENTRIES)),
}
}
/// Create a handler with sensible defaults rooted at `root`.
pub fn with_defaults(root: PathBuf) -> Self {
Self {
root,
index_files: DEFAULT_INDEX_FILES.iter().map(|s| s.to_string()).collect(),
autoindex: false,
etag: true,
gzip: true,
gzip_types: DEFAULT_GZIP_TYPES.iter().map(|s| s.to_string()).collect(),
gzip_min_length: DEFAULT_GZIP_MIN_LENGTH,
cache: Arc::new(FileCache::new(FILE_CACHE_TTL, FILE_CACHE_MAX_ENTRIES)),
}
}
// ------------------------------------------------------------------
// Public entry point
// ------------------------------------------------------------------
/// Handle an incoming HTTP request and return a response.
///
/// Streamlined flow:
/// 1. Method check (GET/HEAD only)
/// 2. Path safety (starts_with, no canonicalize)
/// 3. Hidden-file denial
/// 4. Stat and serve (file, directory, or 404)
pub fn handle(&self, request: &Request) -> Response {
// 1. Only GET and HEAD are meaningful for static files.
// Compare directly against method() which returns &'static str,
// avoiding a String allocation from to_uppercase().
if request.method() != "GET" && request.method() != "HEAD" {
return Response::new()
.status(HttpStatusCode::METHOD_NOT_ALLOWED)
.header("Allow", "GET, HEAD")
.header("Content-Length", "0");
}
let uri = request.uri();
// 2. Reject obvious directory-traversal attempts before touching the
// filesystem.
if !util_path::is_safe_path(uri) {
warn!(uri = %uri, "rejected directory traversal attempt");
return self.error_response(HttpStatusCode::FORBIDDEN, "403 Forbidden");
}
// Map the URI to a filesystem path.
let path = util_path::uri_to_path(&self.root, uri);
// Security: the resolved path must remain under the document root.
// Uses component-based starts_with instead of canonicalize() to
// avoid two expensive syscalls (one for the path, one for root).
// Path::starts_with compares at component boundaries so
// "/var/www-evil" does NOT match root "/var/www".
if !path.starts_with(&self.root) {
warn!(path = %path.display(), "path escapes document root");
return self.error_response(HttpStatusCode::FORBIDDEN, "403 Forbidden");
}
// 3. Deny access to hidden files (names starting with '.').
if self.is_hidden(&path) {
return self.error_response(HttpStatusCode::FORBIDDEN, "403 Forbidden");
}
// 3.5 Open-file-cache fast path.
//
// For a plain GET/HEAD with no Range or conditional headers, and no
// applicable compression, a cache hit serves the file with zero
// filesystem syscalls (no stat, no open) -- only the final
// sendfile(2) remains. This is the hot path for repeated requests
// to the same file.
if self.fast_path_eligible(request) {
if let Some(cached) = self.cache.get(&path) {
return self.build_cached_response(&cached, request.method() == "HEAD");
}
}
// 4. Stat the path and serve.
match fs::metadata(&path) {
Ok(metadata) => {
if metadata.is_dir() {
self.serve_directory(&path, uri)
} else {
self.serve_file_with_conditions(request, &path, &metadata)
}
}
Err(e) => match e.kind() {
std::io::ErrorKind::NotFound | std::io::ErrorKind::PermissionDenied => {
self.error_response(HttpStatusCode::NOT_FOUND, "404 Not Found")
}
_ => {
warn!(error = %e, path = %path.display(), "stat failed");
self.error_response(
HttpStatusCode::INTERNAL_SERVER_ERROR,
"500 Internal Server Error",
)
}
},
}
}
// ------------------------------------------------------------------
// File serving
// ------------------------------------------------------------------
/// Serve a regular file, respecting conditional and range headers.
fn serve_file_with_conditions(
&self,
request: &Request,
path: &Path,
metadata: &Metadata,
) -> Response {
// ETag / If-None-Match / If-Modified-Since conditional checks.
if self.etag {
if let Some(response) = self.check_etag(request, path, metadata) {
return response;
}
}
// Range request?
if request.header("Range").is_some() {
return self.check_range(request, path, metadata);
}
self.serve_file(path, metadata, request)
}
/// Build a 200 OK response for the given file.
///
/// On the common (uncompressed) path the file is opened once, its
/// metadata is precomputed, and the open descriptor is inserted into the
/// open-file cache so subsequent requests take the syscall-free fast
/// path. The response carries a `Body::FileFd` so the connection layer
/// can `sendfile(2)` directly from the cached descriptor.
fn serve_file(&self, path: &Path, metadata: &Metadata, request: &Request) -> Response {
let size = metadata.len();
// Try compression first when the client actually asked for it. This
// reads the file into memory and therefore bypasses the fd cache.
if self.gzip {
let accept_encoding = request.header("Accept-Encoding").unwrap_or("");
// Cheapest check first: does the client accept a coding we emit?
if accept_encoding.contains("gzip") || accept_encoding.contains("deflate") {
let mime = Self::guess_mime_type(path);
if let Some((encoding, compressed)) =
self.try_compress(path, &mime, accept_encoding, size)
{
let compressed_len = compressed.len();
let mut response = Response::new()
.status(HttpStatusCode::OK)
.header("Content-Type", &mime)
.header("Accept-Ranges", "bytes");
if self.etag {
response = response
.header("ETag", &Self::generate_etag(metadata))
.header("Last-Modified", &Self::last_modified_str(metadata));
}
return response
.header("Content-Encoding", encoding)
.header("Content-Length", &compressed_len.to_string())
.header("Vary", "Accept-Encoding")
.body(compressed);
}
}
}
// Uncompressed path: open, precompute metadata, populate the cache,
// and respond with a zero-copy file descriptor body.
match self.open_and_cache(path, metadata) {
Ok(cached) => self.build_cached_response(&cached, request.method() == "HEAD"),
Err(e) => {
warn!(error = %e, path = %path.display(), "failed to open file");
self.error_response(
HttpStatusCode::INTERNAL_SERVER_ERROR,
"500 Internal Server Error",
)
}
}
}
/// Returns `true` when a request can be served from the open-file cache
/// fast path (plain GET/HEAD, no Range, no conditional headers, and no
/// client-requested compression).
fn fast_path_eligible(&self, request: &Request) -> bool {
let m = request.method();
if m != "GET" && m != "HEAD" {
return false;
}
if request.header("Range").is_some()
|| request.header("If-None-Match").is_some()
|| request.header("If-Modified-Since").is_some()
{
return false;
}
if self.gzip {
if let Some(ae) = request.header("Accept-Encoding") {
if ae.contains("gzip") || ae.contains("deflate") {
return false;
}
}
}
true
}
/// Open `path`, fully serialize its `200 OK` response once, insert it
/// into the open-file cache, and return the shared cache entry.
fn open_and_cache(&self, path: &Path, metadata: &Metadata) -> std::io::Result<Arc<CachedFile>> {
let mut file = File::open(path)?;
let size = metadata.len();
let mime = Self::guess_mime_type(path);
let etag = Self::generate_etag(metadata);
let last_modified = Self::last_modified_str(metadata);
// Serialize the status line + headers exactly once.
let header_block = self.build_header_block(&mime, size, &etag, &last_modified);
// For small files, also serialize the body inline so the hot path is
// a single `write`. Large files are streamed with `sendfile`.
let full = if size <= SMALL_FILE_LIMIT {
let mut buf = Vec::with_capacity(header_block.len() + size as usize);
buf.extend_from_slice(&header_block);
file.read_to_end(&mut buf)?;
Some(Bytes::from(buf))
} else {
None
};
let response = Arc::new(CachedResponse {
full,
header_block: Bytes::from(header_block),
file: Arc::new(file),
size,
});
let cached = Arc::new(CachedFile {
size,
etag,
last_modified,
response,
});
self.cache.insert(path.to_path_buf(), cached.clone());
Ok(cached)
}
/// Serialize a `200 OK` status line and header block for a static file.
fn build_header_block(
&self,
mime: &str,
size: u64,
etag: &str,
last_modified: &str,
) -> Vec<u8> {
let date = current_http_date();
let mut b = Vec::with_capacity(256);
b.extend_from_slice(b"HTTP/1.1 200 OK\r\n");
b.extend_from_slice(b"Server: veld\r\n");
b.extend_from_slice(b"Date: ");
b.extend_from_slice(date.as_bytes());
b.extend_from_slice(b"\r\n");
b.extend_from_slice(b"Content-Type: ");
b.extend_from_slice(mime.as_bytes());
b.extend_from_slice(b"\r\n");
b.extend_from_slice(b"Content-Length: ");
b.extend_from_slice(size.to_string().as_bytes());
b.extend_from_slice(b"\r\n");
b.extend_from_slice(b"Accept-Ranges: bytes\r\n");
if self.etag {
b.extend_from_slice(b"ETag: ");
b.extend_from_slice(etag.as_bytes());
b.extend_from_slice(b"\r\n");
b.extend_from_slice(b"Last-Modified: ");
b.extend_from_slice(last_modified.as_bytes());
b.extend_from_slice(b"\r\n");
}
b.extend_from_slice(b"\r\n");
b
}
/// Build a `200 OK` response from a cached open file. No per-request
/// allocation: the body simply references the precomputed bytes.
fn build_cached_response(&self, cached: &Arc<CachedFile>, head: bool) -> Response {
let mut response = Response::new();
response.body = Body::Cached(cached.response.clone(), head);
response
}
/// Attempt to compress a file. Returns `Some((encoding, compressed_bytes))`
/// when compression is applicable and produces a smaller output.
fn try_compress(
&self,
path: &Path,
mime: &str,
accept_encoding: &str,
size: u64,
) -> Option<(&'static str, Vec<u8>)> {
if (size as usize) < self.gzip_min_length {
return None;
}
if !self.is_compressible_type(mime) {
return None;
}
let encoding = if accept_encoding.contains("gzip") {
"gzip"
} else if accept_encoding.contains("deflate") {
"deflate"
} else {
return None;
};
let raw = match fs::read(path) {
Ok(b) => b,
Err(e) => {
warn!(error = %e, path = %path.display(), "failed to read file for compression");
return None;
}
};
let compressed = match encoding {
"gzip" => {
let mut encoder =
GzEncoder::new(&raw[..], Compression::new(GZIP_COMPRESSION_LEVEL));
let mut out = Vec::new();
match encoder.read_to_end(&mut out) {
Ok(_) => out,
Err(e) => {
warn!(error = %e, "gzip compression failed");
return None;
}
}
}
"deflate" => {
let mut encoder =
DeflateEncoder::new(&raw[..], Compression::new(GZIP_COMPRESSION_LEVEL));
let mut out = Vec::new();
match encoder.read_to_end(&mut out) {
Ok(_) => out,
Err(e) => {
warn!(error = %e, "deflate compression failed");
return None;
}
}
}
_ => unreachable!(),
};
if compressed.len() < raw.len() {
Some((encoding, compressed))
} else {
None
}
}
// ------------------------------------------------------------------
// Directory serving
// ------------------------------------------------------------------
/// Serve a directory: try index files first, then fall back to autoindex.
fn serve_directory(&self, path: &Path, uri: &str) -> Response {
// Ensure the URI ends with a slash; redirect if not.
if !uri.ends_with('/') {
let redirect_uri = format!("{}/", uri);
return Response::new()
.status(HttpStatusCode::MOVED_PERMANENTLY)
.header("Location", &redirect_uri)
.header("Content-Length", "0");
}
// Try each index file.
for index_name in &self.index_files {
let index_path = path.join(index_name);
if let Ok(metadata) = fs::metadata(&index_path) {
if metadata.is_file() && !self.is_hidden(&index_path) {
// Build a synthetic request with the index URI so
// conditional checks work correctly.
let index_uri = format!("{}{}", uri, index_name);
let synthetic = Request::from_uri(&index_uri);
return self.serve_file_with_conditions(&synthetic, &index_path, &metadata);
}
}
}
// No index file found.
if self.autoindex {
self.serve_directory_listing(path, uri)
} else {
self.error_response(HttpStatusCode::FORBIDDEN, "403 Forbidden")
}
}
/// Generate an HTML directory listing.
fn serve_directory_listing(&self, path: &Path, uri: &str) -> Response {
let entries = match fs::read_dir(path) {
Ok(e) => e,
Err(e) => {
warn!(error = %e, path = %path.display(), "failed to read directory");
return self.error_response(
HttpStatusCode::INTERNAL_SERVER_ERROR,
"500 Internal Server Error",
);
}
};
let mut items: Vec<DirEntry> = Vec::new();
// Add parent directory link (unless we are at the root of the
// document tree).
if uri != "/" {
items.push(DirEntry {
name: "..".to_string(),
href: "../".to_string(),
is_dir: true,
size: 0,
modified: None,
});
}
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let name = entry.file_name().to_string_lossy().to_string();
// Skip hidden files in listings.
if name.starts_with('.') {
continue;
}
let file_type = match entry.file_type() {
Ok(ft) => ft,
Err(_) => continue,
};
let metadata = entry.metadata().ok();
let size = metadata.as_ref().map_or(0, |m| m.len());
let modified = metadata.as_ref().and_then(|m| m.modified().ok());
let href = if file_type.is_dir() {
format!("{}/", name)
} else {
name.clone()
};
items.push(DirEntry {
name,
href,
is_dir: file_type.is_dir(),
size,
modified,
});
}
// Sort: directories first, then alphabetically (case-insensitive).
items.sort_by(|a, b| match (a.is_dir, b.is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
});
let html = Self::render_directory_listing(uri, &items);
Response::new()
.status(HttpStatusCode::OK)
.header("Content-Type", CT_HTML)
.header("Content-Length", &html.len().to_string())
.body(html.into_bytes())
}
/// Render the directory listing HTML.
fn render_directory_listing(uri: &str, entries: &[DirEntry]) -> String {
let escaped_uri = html_escape(uri);
let mut html = String::with_capacity(4096);
html.push_str("<!DOCTYPE html>\n<html>\n<head>\n");
html.push_str("<meta charset=\"utf-8\">\n");
html.push_str(&format!("<title>Index of {}</title>\n", escaped_uri));
html.push_str(
"\
<style>
body { font-family: monospace; margin: 2em; }
h1 { font-size: 1.4em; border-bottom: 1px solid #ccc; padding-bottom: 0.3em; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align: left; padding: 0.25em 1em 0.25em 0; }
th { border-bottom: 1px solid #ccc; }
.size { text-align: right; white-space: nowrap; }
.date { white-space: nowrap; }
a { text-decoration: none; }
a:hover { text-decoration: underline; }
.dir a { font-weight: bold; }
</style>\n",
);
html.push_str("</head>\n<body>\n");
html.push_str(&format!("<h1>Index of {}</h1>\n", escaped_uri));
html.push_str("<table>\n<thead>\n<tr>");
html.push_str(
"<th>Name</th><th class=\"size\">Size</th><th class=\"date\">Last Modified</th>",
);
html.push_str("</tr>\n</thead>\n<tbody>\n");
for entry in entries {
let class = if entry.is_dir { " class=\"dir\"" } else { "" };
let name_escaped = html_escape(&entry.name);
let href_escaped = html_escape(&entry.href);
let size_str = if entry.is_dir {
"-".to_string()
} else {
crate::util::human_readable_size(entry.size)
};
let date_str = entry
.modified
.map(|t| {
let dt: DateTime<Utc> = t.into();
dt.format("%Y-%m-%d %H:%M:%S").to_string()
})
.unwrap_or_default();
html.push_str(&format!(
"<tr{}><td><a href=\"{}\">{}</a></td><td class=\"size\">{}</td><td class=\"date\">{}</td></tr>\n",
class, href_escaped, name_escaped, size_str, date_str,
));
}
html.push_str("</tbody>\n</table>\n</body>\n</html>\n");
html
}
// ------------------------------------------------------------------
// ETag / conditional requests
// ------------------------------------------------------------------
/// Generate an ETag value from file metadata.
///
/// Uses `MetadataExt` on Unix for a fast path that avoids the
/// `SystemTime -> Duration` conversion overhead. The tag is derived
/// from file size and mtime, which is sufficient for static files.
fn generate_etag(metadata: &Metadata) -> String {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
format!("{:x}-{:x}", metadata.size(), metadata.mtime())
}
#[cfg(not(unix))]
{
let size = metadata.len();
let mtime_secs = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
format!("{:x}-{:x}", size, mtime_secs)
}
}
/// Format the `Last-Modified` header value.
fn last_modified_str(metadata: &Metadata) -> String {
let datetime: DateTime<Utc> = metadata
.modified()
.map(|t| t.into())
.unwrap_or_else(|_| Utc::now());
datetime.format("%a, %d %b %Y %H:%M:%S GMT").to_string()
}
/// Check `If-None-Match` and `If-Modified-Since` headers.
///
/// Returns `Some(304)` when the client's cached copy is still valid.
fn check_etag(&self, request: &Request, path: &Path, metadata: &Metadata) -> Option<Response> {
let current_etag = Self::generate_etag(metadata);
// ---- If-None-Match (takes priority) ----
if let Some(inm) = request.header("If-None-Match") {
// The header value may contain a comma-separated list of ETags.
let matches = inm.split(',').any(|tag| {
let tag = tag.trim();
tag == current_etag || tag == "*"
});
if matches {
debug!(path = %path.display(), "ETag match -> 304");
return Some(
Response::new()
.status(HttpStatusCode::NOT_MODIFIED)
.header("ETag", ¤t_etag)
.header("Content-Length", "0"),
);
}
}
// ---- If-Modified-Since ----
if let Some(ims) = request.header("If-Modified-Since") {
let current_mtime = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
if let Some(client_mtime) = parse_http_date(ims) {
if current_mtime <= client_mtime {
debug!(path = %path.display(), "not modified since -> 304");
return Some(
Response::new()
.status(HttpStatusCode::NOT_MODIFIED)
.header("ETag", ¤t_etag)
.header("Last-Modified", &Self::last_modified_str(metadata))
.header("Content-Length", "0"),
);
}
}
}
None
}
// ------------------------------------------------------------------
// Range requests
// ------------------------------------------------------------------
/// Handle a single `Range` header (bytes=start-end).
///
/// Returns 206 Partial Content with the requested byte range, or 416
/// Range Not Satisfiable if the range is invalid.
fn check_range(&self, request: &Request, path: &Path, metadata: &Metadata) -> Response {
let total_size = metadata.len();
let range_header = match request.header("Range") {
Some(h) => h,
None => return self.serve_file(path, metadata, request),
};
// Parse "bytes=start-end"
let range_spec = range_header.trim();
if !range_spec.starts_with("bytes=") {
return self.serve_file(path, metadata, request);
}
let range_part = &range_spec[6..];
// Support only a single range (no multipart).
let range_part = range_part.split(',').next().unwrap_or("").trim();
let (start, end) = match parse_byte_range(range_part, total_size) {
Some(r) => r,
None => {
return Response::new()
.status(HttpStatusCode::RANGE_NOT_SATISFIABLE)
.header("Content-Range", &format!("bytes */{}", total_size))
.header("Content-Length", "0");
}
};
if start > end || start >= total_size {
return Response::new()
.status(HttpStatusCode::RANGE_NOT_SATISFIABLE)
.header("Content-Range", &format!("bytes */{}", total_size))
.header("Content-Length", "0");
}
let length = end - start + 1;
// Range requests must read the file to slice specific bytes.
let body = match fs::read(path) {
Ok(b) => b,
Err(e) => {
warn!(error = %e, path = %path.display(), "failed to read file for range");
return self.error_response(
HttpStatusCode::INTERNAL_SERVER_ERROR,
"500 Internal Server Error",
);
}
};
let range_body = body[start as usize..=end as usize].to_vec();
let mime = Self::guess_mime_type(path);
let etag = Self::generate_etag(metadata);
Response::new()
.status(HttpStatusCode::PARTIAL_CONTENT)
.header("Content-Type", &mime)
.header("Content-Length", &length.to_string())
.header(
"Content-Range",
&format!("bytes {}/{}", range_spec_display(start, end), total_size),
)
.header("Accept-Ranges", "bytes")
.header("ETag", &etag)
.header("Last-Modified", &Self::last_modified_str(metadata))
.body(range_body)
}
// ------------------------------------------------------------------
// MIME type detection
// ------------------------------------------------------------------
/// Guess the MIME type for a file path.
///
/// Uses the `mime_guess` crate. Falls back to
/// `application/octet-stream` when the type cannot be determined.
pub fn guess_mime_type(path: &Path) -> String {
mime_guess::from_path(path)
.first_or_octet_stream()
.to_string()
}
// ------------------------------------------------------------------
// Compression helpers
// ------------------------------------------------------------------
/// Returns `true` when the MIME type is in the `gzip_types` list.
fn is_compressible_type(&self, mime: &str) -> bool {
let mime_lower = mime.to_lowercase();
// Extract just the type/subtype portion (before any ';').
let base = mime_lower.split(';').next().unwrap_or(&mime_lower).trim();
self.gzip_types.iter().any(|t| t.to_lowercase() == base)
}
// ------------------------------------------------------------------
// Security helpers
// ------------------------------------------------------------------
/// Returns `true` if any component of the path is a hidden file or
/// directory (name starts with `.`).
fn is_hidden(&self, path: &Path) -> bool {
// Check only the components *relative* to the document root.
// We allow the root itself to contain dot-prefixed directories (e.g.
// `/var/www/.site/public`).
let root_components = self.root.components().count();
for component in path.components().skip(root_components) {
if let Some(name) = component.as_os_str().to_str() {
// `.well-known` (RFC 8615) must stay reachable — ACME
// http-01 challenges live under it.
if name.starts_with('.') && name != "." && name != ".." && name != ".well-known" {
return true;
}
}
}
false
}
// ------------------------------------------------------------------
// Error responses
// ------------------------------------------------------------------
/// Build the nginx-style, veld-branded default error page.
/// The `message` argument is retained for call-site readability; the page
/// body is derived from the status code's canonical reason phrase.
fn error_response(&self, status: HttpStatusCode, _message: &str) -> Response {
Response::error_page(status)
}
}
// ===========================================================================
// Supporting types
// ===========================================================================
/// A single entry in a directory listing.
struct DirEntry {
name: String,
href: String,
is_dir: bool,
size: u64,
modified: Option<std::time::SystemTime>,
}
// ===========================================================================
// Free functions (parsing helpers)
// ===========================================================================
/// Parse a byte range specifier of the form `start-end` into an inclusive
/// `(start, end)` pair, handling open-ended ranges.
///
/// Returns `None` when the specifier is syntactically invalid.
fn parse_byte_range(spec: &str, total_size: u64) -> Option<(u64, u64)> {
if spec.is_empty() {
return None;
}
if let Some(suffix) = spec.strip_prefix('-') {
// Suffix range: last N bytes (-500)
let suffix_len: u64 = suffix.parse().ok()?;
if suffix_len == 0 {
return None;
}
let start = total_size.saturating_sub(suffix_len);
Some((start, total_size - 1))
} else if let Some(start) = spec.strip_suffix('-') {
// Open-ended range: from start to end of file (100-)
let start: u64 = start.parse().ok()?;
if start >= total_size {
return None;
}
Some((start, total_size - 1))
} else {
// Explicit range: start-end (100-200)
let mut parts = spec.splitn(2, '-');
let start: u64 = parts.next()?.parse().ok()?;
let end: u64 = parts.next()?.parse().ok()?;
if start > end {
return None;
}
Some((start, end.min(total_size - 1)))
}
}
/// Format a byte range for the `Content-Range` header display.
fn range_spec_display(start: u64, end: u64) -> String {
format!("{}-{}", start, end)
}
/// Current time formatted as an HTTP-date (IMF-fixdate, always GMT).
fn current_http_date() -> String {
Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string()
}
/// Parse an HTTP-date in IMF-fixdate format (`Tue, 15 Nov 1994 08:12:31 GMT`)
/// and return the epoch seconds.
fn parse_http_date(s: &str) -> Option<u64> {
// RFC 7231 IMF-fixdate, e.g. "Tue, 15 Nov 1994 08:12:31 GMT". The zone is
// always the literal "GMT", which `%z` (numeric offset) cannot parse — match
// it literally and interpret the wall-clock time as UTC.
let naive =
chrono::NaiveDateTime::parse_from_str(s.trim(), "%a, %d %b %Y %H:%M:%S GMT").ok()?;
Some(naive.and_utc().timestamp() as u64)
}
/// Minimal HTML escaping for text that may contain `<`, `>`, `&`, or `"`.
fn html_escape(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for c in input.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
_ => out.push(c),
}
}
out
}
// ===========================================================================
// Tests
// ===========================================================================
#[cfg(test)]
mod tests {
use super::*;
// -- Byte-range parsing -------------------------------------------------
#[test]
fn parse_explicit_range() {
assert_eq!(parse_byte_range("0-499", 1000), Some((0, 499)));
assert_eq!(parse_byte_range("500-999", 1000), Some((500, 999)));
}
#[test]
fn parse_open_ended_range() {
assert_eq!(parse_byte_range("500-", 1000), Some((500, 999)));
assert_eq!(parse_byte_range("999-", 1000), Some((999, 999)));
// Start beyond file size.
assert_eq!(parse_byte_range("1000-", 1000), None);
}
#[test]
fn parse_suffix_range() {
assert_eq!(parse_byte_range("-500", 1000), Some((500, 999)));
assert_eq!(parse_byte_range("-1", 1000), Some((999, 999)));
// Suffix larger than file -- returns the whole file.
assert_eq!(parse_byte_range("-2000", 1000), Some((0, 999)));
assert_eq!(parse_byte_range("-0", 1000), None);
}
#[test]
fn parse_invalid_range() {
assert_eq!(parse_byte_range("", 1000), None);
assert_eq!(parse_byte_range("abc", 1000), None);
assert_eq!(parse_byte_range("500-100", 1000), None); // start > end
}
// -- MIME type guessing -------------------------------------------------
#[test]
fn guess_common_types() {
assert_eq!(
StaticFileHandler::guess_mime_type(Path::new("index.html")),
"text/html"
);
assert_eq!(
StaticFileHandler::guess_mime_type(Path::new("style.css")),
"text/css"
);
assert_eq!(
StaticFileHandler::guess_mime_type(Path::new("app.js")),
"text/javascript"
);
assert_eq!(
StaticFileHandler::guess_mime_type(Path::new("image.png")),
"image/png"
);
assert_eq!(
StaticFileHandler::guess_mime_type(Path::new("data.json")),
"application/json"
);
// Unknown extension.
assert_eq!(
StaticFileHandler::guess_mime_type(Path::new("file.unknownext")),
"application/octet-stream"
);
}
// -- HTML escaping ------------------------------------------------------
#[test]
fn html_escape_special_chars() {
assert_eq!(html_escape("a & b"), "a & b");
assert_eq!(html_escape("<script>"), "<script>");
assert_eq!(html_escape("\"hello\""), ""hello"");
}
// -- Hidden-file detection ----------------------------------------------
#[test]
fn hidden_file_detected() {
let handler = StaticFileHandler::with_defaults(PathBuf::from("/var/www"));
assert!(handler.is_hidden(Path::new("/var/www/.env")));
assert!(handler.is_hidden(Path::new("/var/www/sub/.htaccess")));
// The root itself is not considered hidden.
assert!(!handler.is_hidden(Path::new("/var/www/index.html")));
}
// -- Compressible type check -------------------------------------------
#[test]
fn compressible_type_match() {
let handler = StaticFileHandler::with_defaults(PathBuf::from("/tmp"));
assert!(handler.is_compressible_type("text/html"));
assert!(handler.is_compressible_type("text/html; charset=utf-8"));
assert!(handler.is_compressible_type("application/json"));
assert!(!handler.is_compressible_type("image/png"));
assert!(!handler.is_compressible_type("application/octet-stream"));
}
// -- Directory listing render -------------------------------------------
#[test]
fn directory_listing_html() {
let entries = vec![
DirEntry {
name: "subdir".into(),
href: "subdir/".into(),
is_dir: true,
size: 0,
modified: None,
},
DirEntry {
name: "file.txt".into(),
href: "file.txt".into(),
is_dir: false,
size: 1024,
modified: None,
},
];
let html = StaticFileHandler::render_directory_listing("/pub/", &entries);
assert!(html.contains("Index of /pub/"));
assert!(html.contains("subdir/"));
assert!(html.contains("file.txt"));
assert!(html.contains("1.0 KB"));
}
// -- ETag generation ----------------------------------------------------
#[test]
fn etag_format() {
// Create a temporary file to get real metadata.
let dir = std::env::temp_dir().join("veld_etag_test");
let _ = fs::create_dir_all(&dir);
let file_path = dir.join("test.txt");
fs::write(&file_path, "hello").unwrap();
let metadata = fs::metadata(&file_path).unwrap();
let etag = StaticFileHandler::generate_etag(&metadata);
// ETag should be hex-hex format (e.g. "5-a" for 5 bytes).
assert!(!etag.is_empty(), "ETag should not be empty");
assert!(
etag.contains('-'),
"ETag should contain a dash separator: {}",
etag
);
let _ = fs::remove_dir_all(&dir);
}
// -- HTTP date parsing --------------------------------------------------
#[test]
fn http_date_roundtrip() {
let ts = parse_http_date("Tue, 15 Nov 1994 08:12:31 GMT");
assert!(ts.is_some());
// The exact value depends on the timezone offset; just verify it
// parses without panicking.
}
#[test]
fn http_date_invalid() {
assert!(parse_http_date("not a date").is_none());
assert!(parse_http_date("").is_none());
}
}