//! Index file handler.
//!
//! Resolves directory URIs to their index files (e.g. `index.html`,
//! `index.htm`). Works as a focused helper alongside [`StaticFileHandler`]:
//! when a request maps to a directory, `IndexHandler` tries each configured
//! index file name in order and returns the first one that exists on disk.
use std::fs;
use std::path::{Path, PathBuf};
use tracing::debug;
use crate::http::request::Request;
// ---------------------------------------------------------------------------
// Defaults
// ---------------------------------------------------------------------------
const DEFAULT_INDEX_FILES: &[&str] = &["index.html", "index.htm"];
// ---------------------------------------------------------------------------
// IndexHandler
// ---------------------------------------------------------------------------
/// Resolves directory requests to index files.
#[derive(Debug, Clone)]
pub struct IndexHandler {
/// Document root -- all index paths are resolved under this tree.
root: PathBuf,
/// Index file names to try, in priority order.
index_files: Vec<String>,
}
impl IndexHandler {
// ------------------------------------------------------------------
// Construction
// ------------------------------------------------------------------
/// Create a new handler with an explicit root and index file list.
pub fn new(root: PathBuf, index_files: Vec<String>) -> Self {
Self { root, index_files }
}
/// 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(),
}
}
// ------------------------------------------------------------------
// Index resolution
// ------------------------------------------------------------------
/// Walk the configured index file names and return the path to the first
/// regular file found inside `directory`.
///
/// Returns `None` when no index file exists or when `directory` itself
/// is not a directory.
pub fn find_index(&self, directory: &Path) -> Option<PathBuf> {
// Quick check: the caller should already know this is a directory,
// but guard against misuse.
if !directory.is_dir() {
return None;
}
for name in &self.index_files {
let candidate = directory.join(name);
// Must be a regular file (not a symlink-to-directory, etc.).
match fs::metadata(&candidate) {
Ok(meta) if meta.is_file() => {
debug!(path = %candidate.display(), "index file matched");
return Some(candidate);
}
_ => continue,
}
}
debug!(dir = %directory.display(), "no index file found");
None
}
// ------------------------------------------------------------------
// Request handling
// ------------------------------------------------------------------
/// Handle a request whose URI maps to a directory.
///
/// If an index file is found the caller (typically [`StaticFileHandler`])
/// should serve that file. When no index file exists this method returns
/// a `403 Forbidden` response.
///
/// `uri` is the original request URI (used only for error messages and
/// for building a redirect when the URI lacks a trailing slash).
pub fn handle(&self, request: &Request) -> Option<PathBuf> {
let uri = request.uri();
// Ensure the URI ends with a slash -- directories without a trailing
// slash should be redirected by the caller before reaching here.
if !uri.ends_with('/') {
return None;
}
// Map the URI to a filesystem path.
let directory = self.root.join(uri.trim_start_matches('/'));
let canonical_root = self.root.canonicalize().ok()?;
let canonical_dir = match directory.canonicalize() {
Ok(c) => c,
Err(_) => return None,
};
// Security: refuse to serve anything outside the document root.
if !canonical_dir.starts_with(&canonical_root) {
return None;
}
self.find_index(&canonical_dir)
}
// ------------------------------------------------------------------
// Accessors
// ------------------------------------------------------------------
/// The document root.
pub fn root(&self) -> &Path {
&self.root
}
/// The configured index file names.
pub fn index_files(&self) -> &[String] {
&self.index_files
}
}
// ===========================================================================
// Tests
// ===========================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn find_index_returns_first_match() {
let dir = std::env::temp_dir().join("veld_idx_test_1");
let _ = fs::create_dir_all(&dir);
// Only create index.htm, not index.html -- so htm wins.
fs::write(dir.join("index.htm"), "<h1>hi</h1>").unwrap();
let handler = IndexHandler::new(dir.clone(), vec!["index.html".into(), "index.htm".into()]);
let result = handler.find_index(&dir);
assert!(result.is_some());
assert_eq!(result.unwrap().file_name().unwrap(), "index.htm");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn find_index_returns_none_for_empty_dir() {
let dir = std::env::temp_dir().join("veld_idx_test_2");
let _ = fs::create_dir_all(&dir);
let handler = IndexHandler::with_defaults(dir.clone());
assert!(handler.find_index(&dir).is_none());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn find_index_skips_subdirectories() {
let dir = std::env::temp_dir().join("veld_idx_test_3");
let _ = fs::create_dir_all(&dir);
// Create a *directory* named index.html -- should be skipped.
fs::create_dir_all(dir.join("index.html")).unwrap();
let handler = IndexHandler::with_defaults(dir.clone());
assert!(handler.find_index(&dir).is_none());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn find_index_respects_priority_order() {
let dir = std::env::temp_dir().join("veld_idx_test_4");
let _ = fs::create_dir_all(&dir);
fs::write(dir.join("index.html"), "html").unwrap();
fs::write(dir.join("index.htm"), "htm").unwrap();
let handler = IndexHandler::new(dir.clone(), vec!["index.htm".into(), "index.html".into()]);
let result = handler.find_index(&dir).unwrap();
assert_eq!(result.file_name().unwrap(), "index.htm");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn find_index_returns_none_for_non_directory() {
let handler = IndexHandler::with_defaults(PathBuf::from("/tmp"));
assert!(handler.find_index(Path::new("/nonexistent/path")).is_none());
}
}