use std::path::{Path, PathBuf};
use std::time::SystemTime;
use crate::response::{Response, ResponseBody, StatusCode, mime_type_for_extension};
#[derive(Debug, Clone)]
#[allow(clippy::struct_excessive_bools)]
pub struct StaticFilesConfig {
pub directory: PathBuf,
pub prefix: String,
pub index_files: Vec<String>,
pub show_hidden: bool,
pub follow_symlinks: bool,
pub enable_etag: bool,
pub enable_last_modified: bool,
pub directory_listing: bool,
pub not_found_page: Option<String>,
pub extra_headers: Vec<(String, String)>,
}
impl Default for StaticFilesConfig {
fn default() -> Self {
Self {
directory: PathBuf::from("."),
prefix: String::new(),
index_files: vec!["index.html".to_string()],
show_hidden: false,
follow_symlinks: false,
enable_etag: true,
enable_last_modified: true,
directory_listing: false,
not_found_page: None,
extra_headers: Vec::new(),
}
}
}
impl StaticFilesConfig {
#[must_use]
pub fn new(directory: impl Into<PathBuf>) -> Self {
Self {
directory: directory.into(),
..Default::default()
}
}
#[must_use]
pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
self.prefix = prefix.into();
self
}
#[must_use]
pub fn index_files(mut self, files: Vec<String>) -> Self {
self.index_files = files;
self
}
#[must_use]
pub fn show_hidden(mut self, show: bool) -> Self {
self.show_hidden = show;
self
}
#[must_use]
pub fn follow_symlinks(mut self, follow: bool) -> Self {
self.follow_symlinks = follow;
self
}
#[must_use]
pub fn enable_etag(mut self, enable: bool) -> Self {
self.enable_etag = enable;
self
}
#[must_use]
pub fn enable_last_modified(mut self, enable: bool) -> Self {
self.enable_last_modified = enable;
self
}
#[must_use]
pub fn directory_listing(mut self, enable: bool) -> Self {
self.directory_listing = enable;
self
}
#[must_use]
pub fn not_found_page(mut self, page: impl Into<String>) -> Self {
self.not_found_page = Some(page.into());
self
}
#[must_use]
pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.extra_headers.push((name.into(), value.into()));
self
}
}
#[derive(Debug, Clone)]
pub struct StaticFiles {
config: StaticFilesConfig,
}
impl StaticFiles {
#[must_use]
pub fn new(directory: impl Into<PathBuf>) -> Self {
Self {
config: StaticFilesConfig::new(directory),
}
}
#[must_use]
pub fn with_config(config: StaticFilesConfig) -> Self {
Self { config }
}
#[must_use]
pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
self.config.prefix = prefix.into();
self
}
#[must_use]
pub fn index_file(mut self, file: impl Into<String>) -> Self {
self.config.index_files = vec![file.into()];
self
}
#[must_use]
pub fn enable_directory_listing(mut self) -> Self {
self.config.directory_listing = true;
self
}
#[must_use]
pub fn follow_symlinks(mut self) -> Self {
self.config.follow_symlinks = true;
self
}
pub fn serve(&self, request_path: &str) -> Response {
let path_without_prefix = self.strip_prefix(request_path);
if !is_safe_path(path_without_prefix) {
return Response::with_status(StatusCode::FORBIDDEN)
.header("content-type", b"text/plain".to_vec())
.body(ResponseBody::Bytes(b"Forbidden: Invalid path".to_vec()));
}
let file_path = self
.config
.directory
.join(path_without_prefix.trim_start_matches('/'));
let Some(canonical_path) = self.resolve_path(&file_path) else {
return self.not_found_response();
};
let Ok(canonical_dir) = self.config.directory.canonicalize() else {
return self.not_found_response();
};
if !canonical_path.starts_with(&canonical_dir) {
return Response::with_status(StatusCode::FORBIDDEN)
.header("content-type", b"text/plain".to_vec())
.body(ResponseBody::Bytes(
b"Forbidden: Path traversal detected".to_vec(),
));
}
if !self.config.show_hidden && has_hidden_component(&canonical_path) {
return self.not_found_response();
}
if canonical_path.is_dir() {
return self.serve_directory(&canonical_path, request_path);
}
self.serve_file(&canonical_path)
}
fn strip_prefix<'a>(&self, path: &'a str) -> &'a str {
if self.config.prefix.is_empty() {
return path;
}
path.strip_prefix(&self.config.prefix).unwrap_or(path)
}
fn resolve_path(&self, path: &Path) -> Option<PathBuf> {
if self.config.follow_symlinks {
path.canonicalize().ok()
} else {
if !path.exists() {
return None;
}
let canonical_dir = self.config.directory.canonicalize().ok()?;
let relative_path = path.strip_prefix(&self.config.directory).ok()?;
let mut current = canonical_dir.clone();
for component in relative_path.components() {
current.push(component);
let metadata = std::fs::symlink_metadata(¤t).ok()?;
if metadata.file_type().is_symlink() {
return None; }
}
Some(current)
}
}
fn serve_directory(&self, dir_path: &Path, request_path: &str) -> Response {
for index_file in &self.config.index_files {
if !is_safe_path(index_file) {
continue;
}
let index_path = dir_path.join(index_file);
if let Ok(canonical) = index_path.canonicalize() {
if !canonical.starts_with(dir_path) {
continue;
}
if canonical.is_file() {
return self.serve_file(&canonical);
}
}
}
if self.config.directory_listing {
return self.generate_directory_listing(dir_path, request_path);
}
self.not_found_response()
}
fn serve_file(&self, file_path: &Path) -> Response {
if !self.config.show_hidden && has_hidden_component(file_path) {
return self.not_found_response();
}
let Ok(contents) = std::fs::read(file_path) else {
return self.not_found_response();
};
let metadata = std::fs::metadata(file_path).ok();
let content_type = file_path
.extension()
.and_then(|ext| ext.to_str())
.map(mime_type_for_extension)
.unwrap_or("application/octet-stream");
let mut response = Response::ok()
.header("content-type", content_type.as_bytes().to_vec())
.header("accept-ranges", b"bytes".to_vec());
if self.config.enable_etag {
let etag = generate_etag(&contents);
response = response.header("etag", etag.into_bytes());
}
if self.config.enable_last_modified {
if let Some(ref meta) = metadata {
if let Ok(modified) = meta.modified() {
let http_date = format_http_date(modified);
response = response.header("last-modified", http_date.into_bytes());
}
}
}
for (name, value) in &self.config.extra_headers {
response = response.header(name.clone(), value.clone().into_bytes());
}
response.body(ResponseBody::Bytes(contents))
}
fn generate_directory_listing(&self, dir_path: &Path, request_path: &str) -> Response {
let mut entries = Vec::new();
if request_path != "/" && request_path != self.config.prefix {
entries.push(DirectoryEntry {
name: "..".to_string(),
is_dir: true,
size: 0,
modified: None,
});
}
if let Ok(read_dir) = std::fs::read_dir(dir_path) {
for entry in read_dir.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if !self.config.show_hidden && name.starts_with('.') {
continue;
}
if let Ok(metadata) = entry.metadata() {
entries.push(DirectoryEntry {
name,
is_dir: metadata.is_dir(),
size: metadata.len(),
modified: metadata.modified().ok(),
});
}
}
}
entries.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.cmp(&b.name),
});
let html = generate_listing_html(request_path, &entries);
Response::ok()
.header("content-type", b"text/html; charset=utf-8".to_vec())
.body(ResponseBody::Bytes(html.into_bytes()))
}
fn not_found_response(&self) -> Response {
if let Some(ref not_found_path) = self.config.not_found_page {
if is_safe_path(not_found_path) {
let path = self.config.directory.join(not_found_path);
if let Ok(canonical) = path.canonicalize() {
if let Ok(base_canonical) = self.config.directory.canonicalize() {
if canonical.starts_with(&base_canonical) {
if let Ok(contents) = std::fs::read(&canonical) {
let content_type = canonical
.extension()
.and_then(|ext| ext.to_str())
.map(mime_type_for_extension)
.unwrap_or("text/html; charset=utf-8");
return Response::with_status(StatusCode::NOT_FOUND)
.header("content-type", content_type.as_bytes().to_vec())
.body(ResponseBody::Bytes(contents));
}
}
}
}
}
}
Response::with_status(StatusCode::NOT_FOUND)
.header("content-type", b"text/plain".to_vec())
.body(ResponseBody::Bytes(b"Not Found".to_vec()))
}
}
fn has_hidden_component(path: &Path) -> bool {
path.components().any(|c| {
c.as_os_str()
.to_str()
.is_some_and(|s| s.starts_with('.') && s != "." && s != "..")
})
}
fn is_safe_path(path: &str) -> bool {
if path.contains('\0') {
return false;
}
let decoded = percent_decode(path);
if decoded.contains('\0') {
return false;
}
for component in decoded.split('/') {
if component == ".." {
return false;
}
}
true
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut decoded_bytes = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hi = hex_val(bytes[i + 1]);
let lo = hex_val(bytes[i + 2]);
if let (Some(h), Some(l)) = (hi, lo) {
decoded_bytes.push((h << 4) | l);
i += 3;
continue;
}
}
decoded_bytes.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&decoded_bytes).into_owned()
}
fn hex_val(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
fn generate_etag(contents: &[u8]) -> String {
const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
const FNV_PRIME: u64 = 0x100000001b3;
let mut hash = FNV_OFFSET_BASIS;
for &byte in contents {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
format!("\"{:016x}\"", hash)
}
fn format_http_date(time: SystemTime) -> String {
match time.duration_since(std::time::UNIX_EPOCH) {
Ok(duration) => {
let secs = duration.as_secs();
let days = secs / 86400;
let remaining_secs = secs % 86400;
let hours = remaining_secs / 3600;
let minutes = (remaining_secs % 3600) / 60;
let seconds = remaining_secs % 60;
let day_of_week = ((days + 4) % 7) as usize;
let day_names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
let (year, month, day) = days_to_date(days);
let month_names = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
format!(
"{}, {:02} {} {} {:02}:{:02}:{:02} GMT",
day_names[day_of_week],
day,
month_names[(month - 1) as usize],
year,
hours,
minutes,
seconds
)
}
Err(_) => "Thu, 01 Jan 1970 00:00:00 GMT".to_string(),
}
}
fn days_to_date(days: u64) -> (u64, u64, u64) {
let mut remaining_days = days;
let mut year = 1970u64;
loop {
let days_in_year = if is_leap_year(year) { 366 } else { 365 };
if remaining_days < days_in_year {
break;
}
remaining_days -= days_in_year;
year += 1;
}
let leap = is_leap_year(year);
let month_days: [u64; 12] = if leap {
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut month = 1u64;
for &days_in_month in &month_days {
if remaining_days < days_in_month {
break;
}
remaining_days -= days_in_month;
month += 1;
}
(year, month, remaining_days + 1)
}
fn is_leap_year(year: u64) -> bool {
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}
struct DirectoryEntry {
name: String,
is_dir: bool,
size: u64,
modified: Option<SystemTime>,
}
fn generate_listing_html(path: &str, entries: &[DirectoryEntry]) -> String {
let mut html = String::new();
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", escape_html(path)));
html.push_str("<style>\n");
html.push_str("body { font-family: monospace; margin: 20px; }\n");
html.push_str("h1 { border-bottom: 1px solid #ccc; padding-bottom: 10px; }\n");
html.push_str("table { border-collapse: collapse; width: 100%; }\n");
html.push_str("th, td { text-align: left; padding: 8px; border-bottom: 1px solid #eee; }\n");
html.push_str("th { background: #f5f5f5; }\n");
html.push_str("a { text-decoration: none; color: #0066cc; }\n");
html.push_str("a:hover { text-decoration: underline; }\n");
html.push_str(".dir { font-weight: bold; }\n");
html.push_str(".size { text-align: right; }\n");
html.push_str("</style>\n");
html.push_str("</head>\n<body>\n");
html.push_str(&format!("<h1>Index of {}</h1>\n", escape_html(path)));
html.push_str("<table>\n");
html.push_str("<tr><th>Name</th><th>Size</th><th>Modified</th></tr>\n");
for entry in entries {
let href = if entry.name == ".." {
parent_path(path)
} else if entry.is_dir {
format!("{}/{}/", path.trim_end_matches('/'), &entry.name)
} else {
format!("{}/{}", path.trim_end_matches('/'), &entry.name)
};
let class = if entry.is_dir { " class=\"dir\"" } else { "" };
let display_name = if entry.is_dir {
format!("{}/", &entry.name)
} else {
entry.name.clone()
};
let size_str = if entry.is_dir {
"-".to_string()
} else {
format_size(entry.size)
};
let modified_str = entry
.modified
.map(|t| format_http_date(t))
.unwrap_or_else(|| "-".to_string());
html.push_str(&format!(
"<tr><td{}><a href=\"{}\">{}</a></td><td class=\"size\">{}</td><td>{}</td></tr>\n",
class,
escape_html(&href),
escape_html(&display_name),
size_str,
modified_str
));
}
html.push_str("</table>\n");
html.push_str("<hr>\n<p>fastapi_rust static file server</p>\n");
html.push_str("</body>\n</html>");
html
}
fn parent_path(path: &str) -> String {
let trimmed = path.trim_end_matches('/');
match trimmed.rfind('/') {
Some(pos) if pos > 0 => format!("{}/", &trimmed[..pos]),
_ => "/".to_string(),
}
}
fn escape_html(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
#[allow(clippy::cast_precision_loss)]
fn format_size(size: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = 1024 * KB;
const GB: u64 = 1024 * MB;
if size >= GB {
format!("{:.1}G", size as f64 / GB as f64)
} else if size >= MB {
format!("{:.1}M", size as f64 / MB as f64)
} else if size >= KB {
format!("{:.1}K", size as f64 / KB as f64)
} else {
format!("{}", size)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_path_normal() {
assert!(is_safe_path("/static/css/style.css"));
assert!(is_safe_path("/images/logo.png"));
assert!(is_safe_path("/"));
assert!(is_safe_path(""));
}
#[test]
fn safe_path_traversal_blocked() {
assert!(!is_safe_path("/../etc/passwd"));
assert!(!is_safe_path("/static/../../../etc/passwd"));
assert!(!is_safe_path(".."));
assert!(!is_safe_path("/.."));
}
#[test]
fn safe_path_encoded_traversal_blocked() {
assert!(!is_safe_path("/%2e%2e/etc/passwd"));
assert!(!is_safe_path("/static/%2e%2e/%2e%2e/etc/passwd"));
}
#[test]
fn safe_path_allows_double_dots_in_filename() {
assert!(is_safe_path("/files/test..data.txt"));
assert!(is_safe_path("/files/archive..tar.gz"));
assert!(is_safe_path("/files/version..1.2.txt"));
}
#[test]
fn safe_path_null_byte_blocked() {
assert!(!is_safe_path("/static/file\0.txt"));
}
#[test]
fn percent_decode_works() {
assert_eq!(percent_decode("%2e%2e"), "..");
assert_eq!(percent_decode("%2F"), "/");
assert_eq!(percent_decode("hello%20world"), "hello world");
assert_eq!(percent_decode("normal"), "normal");
}
#[test]
fn etag_generation() {
let contents = b"Hello, World!";
let etag = generate_etag(contents);
assert!(etag.starts_with('"'));
assert!(etag.ends_with('"'));
assert_eq!(etag.len(), 18); }
#[test]
fn etag_deterministic() {
let contents = b"test data";
let etag1 = generate_etag(contents);
let etag2 = generate_etag(contents);
assert_eq!(etag1, etag2);
}
#[test]
fn etag_different_for_different_content() {
let etag1 = generate_etag(b"content 1");
let etag2 = generate_etag(b"content 2");
assert_ne!(etag1, etag2);
}
#[test]
fn format_size_bytes() {
assert_eq!(format_size(0), "0");
assert_eq!(format_size(100), "100");
assert_eq!(format_size(1023), "1023");
}
#[test]
fn format_size_kb() {
assert_eq!(format_size(1024), "1.0K");
assert_eq!(format_size(2048), "2.0K");
assert_eq!(format_size(1536), "1.5K");
}
#[test]
fn format_size_mb() {
assert_eq!(format_size(1024 * 1024), "1.0M");
assert_eq!(format_size(5 * 1024 * 1024), "5.0M");
}
#[test]
fn format_size_gb() {
assert_eq!(format_size(1024 * 1024 * 1024), "1.0G");
assert_eq!(format_size(2 * 1024 * 1024 * 1024), "2.0G");
}
#[test]
fn escape_html_special_chars() {
assert_eq!(escape_html("<script>"), "<script>");
assert_eq!(escape_html("a & b"), "a & b");
assert_eq!(escape_html("\"quoted\""), ""quoted"");
}
#[test]
fn parent_path_normal() {
assert_eq!(parent_path("/static/css/"), "/static/");
assert_eq!(parent_path("/static/"), "/");
assert_eq!(parent_path("/"), "/");
}
#[test]
fn config_builder() {
let config = StaticFilesConfig::new("./public")
.prefix("/static")
.show_hidden(false)
.directory_listing(true);
assert_eq!(config.directory, PathBuf::from("./public"));
assert_eq!(config.prefix, "/static");
assert!(!config.show_hidden);
assert!(config.directory_listing);
}
#[test]
fn static_files_builder() {
let handler = StaticFiles::new("./assets")
.prefix("/assets")
.index_file("index.htm")
.enable_directory_listing();
assert_eq!(handler.config.prefix, "/assets");
assert_eq!(handler.config.index_files, vec!["index.htm"]);
assert!(handler.config.directory_listing);
}
#[test]
fn leap_year_detection() {
assert!(!is_leap_year(1900)); assert!(is_leap_year(2000)); assert!(is_leap_year(2024)); assert!(!is_leap_year(2023)); }
#[test]
fn http_date_format() {
let date = format_http_date(std::time::UNIX_EPOCH);
assert_eq!(date, "Thu, 01 Jan 1970 00:00:00 GMT");
}
}