Ferrit Explore
中文·繁體·EN·日本語 Sign in Register
cielxl / veld / src / handler / static_file.rs
//! 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", &current_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", &current_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("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            _ => 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 &amp; b");
        assert_eq!(html_escape("<script>"), "&lt;script&gt;");
        assert_eq!(html_escape("\"hello\""), "&quot;hello&quot;");
    }

    // -- 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());
    }
}