use std::fs;
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use zenith_api::CanonicalResponse;
use crate::error::WebError;
#[derive(Debug, Clone)]
pub struct StaticConfig {
pub root: PathBuf,
pub default_file: String,
pub directory_listing: bool,
pub cache_max_age: u32,
pub enable_range: bool,
pub enable_conditional: bool,
pub max_file_size: usize,
}
pub const DEFAULT_MAX_FILE_SIZE: usize = 16 * 1024 * 1024;
impl StaticConfig {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
default_file: "index.html".to_string(),
directory_listing: false,
cache_max_age: 3600,
enable_range: true,
enable_conditional: true,
max_file_size: DEFAULT_MAX_FILE_SIZE,
}
}
pub fn with_default_file(mut self, file: &str) -> Self {
self.default_file = file.to_string();
self
}
pub fn with_directory_listing(mut self, enabled: bool) -> Self {
self.directory_listing = enabled;
self
}
pub fn with_cache_max_age(mut self, seconds: u32) -> Self {
self.cache_max_age = seconds;
self
}
pub fn with_range(mut self, enabled: bool) -> Self {
self.enable_range = enabled;
self
}
pub fn with_conditional(mut self, enabled: bool) -> Self {
self.enable_conditional = enabled;
self
}
pub fn with_max_file_size(mut self, max: usize) -> Self {
self.max_file_size = max;
self
}
}
impl Default for StaticConfig {
fn default() -> Self {
Self::new(PathBuf::from("static"))
}
}
#[cfg(target_os = "linux")]
fn open_nofollow(path: &Path) -> std::io::Result<fs::File> {
use std::os::unix::fs::OpenOptionsExt;
fs::OpenOptions::new()
.read(true)
.custom_flags(0x20000) .open(path)
}
#[cfg(not(target_os = "linux"))]
fn open_nofollow(path: &Path) -> std::io::Result<fs::File> {
if fs::symlink_metadata(path)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"symlink rejected (non-Linux TOCTOU check)",
));
}
fs::File::open(path)
}
#[derive(Debug, Clone)]
pub struct StaticFileServer {
config: StaticConfig,
}
impl StaticFileServer {
pub fn new(config: StaticConfig) -> Self {
Self { config }
}
pub fn serve(&self, requested_path: &str) -> Result<CanonicalResponse, WebError> {
self.serve_with_headers(requested_path, &[])
}
pub fn serve_with_headers(
&self,
requested_path: &str,
headers: &[(&str, &str)],
) -> Result<CanonicalResponse, WebError> {
let file_path = self.resolve_path(requested_path)?;
let metadata = fs::metadata(&file_path)
.map_err(|_| WebError::NotFound(format!("File not found: {}", requested_path)))?;
if metadata.is_dir() {
return self.serve_directory(&file_path, headers);
}
self.serve_file_with_headers(&file_path, &metadata, headers)
}
fn resolve_path(&self, requested_path: &str) -> Result<PathBuf, WebError> {
let root = self.config.root.canonicalize().map_err(|e| {
WebError::InternalError(format!("Failed to resolve root: {}", e))
})?;
let clean_path = requested_path.trim_start_matches('/');
let full_path = root.join(clean_path);
let canonical = full_path.canonicalize().map_err(|_| {
WebError::NotFound(format!("File not found: {}", requested_path))
})?;
if !canonical.starts_with(&root) {
return Err(WebError::Forbidden(
"Path traversal detected".to_string(),
));
}
Ok(canonical)
}
fn serve_directory(
&self,
dir_path: &Path,
headers: &[(&str, &str)],
) -> Result<CanonicalResponse, WebError> {
let default_file = dir_path.join(&self.config.default_file);
if default_file.exists()
&& let Ok(metadata) = fs::metadata(&default_file) {
return self.serve_file_with_headers(&default_file, &metadata, headers);
}
if self.config.directory_listing {
self.generate_directory_listing(dir_path)
} else {
Err(WebError::Forbidden(
"Directory listing disabled".to_string(),
))
}
}
fn serve_file_with_headers(
&self,
file_path: &Path,
metadata: &fs::Metadata,
headers: &[(&str, &str)],
) -> Result<CanonicalResponse, WebError> {
let file_size = metadata.len();
let file_name = file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let etag = self.generate_etag(file_path, metadata)?;
let last_modified = self.format_last_modified(metadata)?;
let modified = metadata
.modified()
.map_err(|e| WebError::InternalError(format!("{}", e)))?;
let last_modified_secs = Self::time_to_epoch(modified);
let content_type = Self::guess_content_type(file_name);
let find_hdr = |name: &str| -> Option<&str> {
headers
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(name))
.map(|(_, v)| *v)
};
if self.config.enable_conditional {
let not_modified = if let Some(inm) = find_hdr("if-none-match") {
Self::etag_matches(inm, &etag)
} else if let Some(ims) = find_hdr("if-modified-since") {
Self::parse_http_date(ims)
.map(|ims_secs| last_modified_secs <= ims_secs)
.unwrap_or(false)
} else {
false
};
if not_modified {
return Self::build_not_modified(&etag, &last_modified, self.config.cache_max_age);
}
}
if self.config.enable_range {
if let Some(range_hdr) = find_hdr("range") {
match Self::parse_range(range_hdr, file_size) {
Some((start, end)) => {
let length = end
.checked_sub(start)
.and_then(|l| l.checked_add(1))
.ok_or_else(|| {
WebError::InternalError("Range length overflow".to_string())
})?;
if usize::try_from(length)
.map(|l| l > self.config.max_file_size)
.unwrap_or(true)
{
return Err(WebError::Custom {
status: 413,
message: "Payload Too Large".to_string(),
});
}
let content = self.read_range(file_path, start, length)?;
return Self::build_partial_content(
content,
&content_type,
start,
end,
file_size,
&etag,
&last_modified,
self.config.cache_max_age,
);
}
None => {
return Self::build_range_not_satisfiable(file_size);
}
}
}
}
if file_size > self.config.max_file_size as u64 {
return Err(WebError::Custom {
status: 413,
message: "Payload Too Large".to_string(),
});
}
let content = self.read_bounded(file_path)?;
let mut response = CanonicalResponse::new(200);
response
.add_header(b"content-type", content_type.as_bytes())
.map_err(WebError::from)?;
response
.add_header(b"content-length", content.len().to_string().as_bytes())
.map_err(WebError::from)?;
response
.add_header(b"etag", etag.as_bytes())
.map_err(WebError::from)?;
response
.add_header(b"last-modified", last_modified.as_bytes())
.map_err(WebError::from)?;
if self.config.enable_range {
response
.add_header(b"accept-ranges", b"bytes")
.map_err(WebError::from)?;
}
response
.add_header(
b"cache-control",
format!("public, max-age={}", self.config.cache_max_age).as_bytes(),
)
.map_err(WebError::from)?;
response.set_body(content);
Ok(response)
}
fn read_bounded(&self, file_path: &Path) -> Result<Vec<u8>, WebError> {
let mut file = open_nofollow(file_path)
.map_err(|e| WebError::InternalError(format!("Failed to open file: {}", e)))?;
let mut buf = Vec::new();
let mut tmp = [0u8; 8192];
loop {
let n = file
.read(&mut tmp)
.map_err(|e| WebError::InternalError(format!("Failed to read file: {}", e)))?;
if n == 0 {
break;
}
if buf.len().saturating_add(n) > self.config.max_file_size {
return Err(WebError::Custom {
status: 413,
message: "Payload Too Large".to_string(),
});
}
buf.extend_from_slice(&tmp[..n]);
}
Ok(buf)
}
fn read_range(&self, file_path: &Path, start: u64, length: u64) -> Result<Vec<u8>, WebError> {
let mut file = open_nofollow(file_path)
.map_err(|e| WebError::InternalError(format!("Failed to open file: {}", e)))?;
file.seek(SeekFrom::Start(start))
.map_err(|e| WebError::InternalError(format!("Failed to seek file: {}", e)))?;
let buf_len = usize::try_from(length)
.map_err(|_| WebError::InternalError("Range length exceeds usize".to_string()))?;
let mut buf = vec![0u8; buf_len];
let mut filled = 0usize;
while filled < buf.len() {
let n = file
.read(&mut buf[filled..])
.map_err(|e| WebError::InternalError(format!("Failed to read range: {}", e)))?;
if n == 0 {
buf.truncate(filled);
break;
}
filled += n;
}
Ok(buf)
}
fn build_not_modified(
etag: &str,
last_modified: &str,
cache_max_age: u32,
) -> Result<CanonicalResponse, WebError> {
let mut response = CanonicalResponse::new(304);
response
.add_header(b"etag", etag.as_bytes())
.map_err(WebError::from)?;
response
.add_header(b"last-modified", last_modified.as_bytes())
.map_err(WebError::from)?;
response
.add_header(
b"cache-control",
format!("public, max-age={}", cache_max_age).as_bytes(),
)
.map_err(WebError::from)?;
Ok(response)
}
fn build_partial_content(
content: Vec<u8>,
content_type: &str,
start: u64,
end: u64,
file_size: u64,
etag: &str,
last_modified: &str,
cache_max_age: u32,
) -> Result<CanonicalResponse, WebError> {
let mut response = CanonicalResponse::new(206);
response
.add_header(b"content-type", content_type.as_bytes())
.map_err(WebError::from)?;
response
.add_header(b"content-length", content.len().to_string().as_bytes())
.map_err(WebError::from)?;
response
.add_header(
b"content-range",
format!("bytes {}-{}/{}", start, end, file_size).as_bytes(),
)
.map_err(WebError::from)?;
response
.add_header(b"etag", etag.as_bytes())
.map_err(WebError::from)?;
response
.add_header(b"last-modified", last_modified.as_bytes())
.map_err(WebError::from)?;
response
.add_header(b"accept-ranges", b"bytes")
.map_err(WebError::from)?;
response
.add_header(
b"cache-control",
format!("public, max-age={}", cache_max_age).as_bytes(),
)
.map_err(WebError::from)?;
response.set_body(content);
Ok(response)
}
fn build_range_not_satisfiable(file_size: u64) -> Result<CanonicalResponse, WebError> {
let mut response = CanonicalResponse::new(416);
response
.add_header(b"content-range", format!("bytes */{}", file_size).as_bytes())
.map_err(WebError::from)?;
Ok(response)
}
fn etag_matches(inm: &str, etag: &str) -> bool {
let inm = inm.trim();
if inm == "*" {
return true;
}
let etag_norm = etag.strip_prefix("W/").unwrap_or(etag);
inm.split(',').any(|tag| {
let tag = tag.trim();
let tag = tag.strip_prefix("W/").unwrap_or(tag);
tag.eq_ignore_ascii_case(etag_norm)
})
}
fn parse_range(range_hdr: &str, file_size: u64) -> Option<(u64, u64)> {
if file_size == 0 {
return None;
}
let s = range_hdr.trim();
let s = s.strip_prefix("bytes=")?;
let s = s.trim();
let s = s.split(',').next()?;
let s = s.trim();
let (start_str, end_str) = s.split_once('-')?;
let start_str = start_str.trim();
let end_str = end_str.trim();
let last = file_size.checked_sub(1)?;
if start_str.is_empty() {
let n: u64 = end_str.parse().ok()?;
if n == 0 {
return None;
}
let start = if n >= file_size {
0
} else {
file_size.checked_sub(n)?
};
Some((start, last))
} else {
let start: u64 = start_str.parse().ok()?;
if start >= file_size {
return None;
}
let end = if end_str.is_empty() {
last
} else {
let end: u64 = end_str.parse().ok()?;
if end > last {
last
} else {
end
}
};
if start > end {
return None;
}
Some((start, end))
}
}
fn parse_http_date(date: &str) -> Option<u64> {
let s = date.trim();
let s = s
.split_once(',')
.map(|(_, rest)| rest.trim())
.unwrap_or(s);
let mut parts = s.split_whitespace();
let day_str = parts.next()?;
let mon_str = parts.next()?;
let year_str = parts.next()?;
let time_str = parts.next()?;
let day: u32 = day_str.parse().ok()?;
let month = Self::month_to_num(mon_str)?;
let year: u32 = year_str.parse().ok()?;
let (hour, min, sec) = {
let mut tp = time_str.split(':');
let h: u32 = tp.next()?.parse().ok()?;
let m: u32 = tp.next()?.parse().ok()?;
let s: u32 = tp.next()?.parse().ok()?;
(h, m, s)
};
Some(Self::datetime_to_epoch(year, month, day, hour, min, sec))
}
fn month_to_num(mon: &str) -> Option<u32> {
match mon {
"Jan" => Some(1),
"Feb" => Some(2),
"Mar" => Some(3),
"Apr" => Some(4),
"May" => Some(5),
"Jun" => Some(6),
"Jul" => Some(7),
"Aug" => Some(8),
"Sep" => Some(9),
"Oct" => Some(10),
"Nov" => Some(11),
"Dec" => Some(12),
_ => None,
}
}
fn datetime_to_epoch(year: u32, month: u32, day: u32, hour: u32, min: u32, sec: u32) -> u64 {
let mut secs = 0u64;
for y in 1970..year {
secs = secs.saturating_add(if Self::is_leap_year(y) {
366 * 86400
} else {
365 * 86400
});
}
let days_in_months: [u32; 12] = if Self::is_leap_year(year) {
[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 day_of_year: u64 = (day.saturating_sub(1)) as u64;
for &days in days_in_months.iter().take((month as usize).saturating_sub(1)) {
day_of_year = day_of_year.saturating_add(days as u64);
}
secs = secs.saturating_add(day_of_year.saturating_mul(86400));
secs = secs.saturating_add((hour as u64).saturating_mul(3600));
secs = secs.saturating_add((min as u64).saturating_mul(60));
secs = secs.saturating_add(sec as u64);
secs
}
fn generate_etag(&self, _file_path: &Path, metadata: &fs::Metadata) -> Result<String, WebError> {
let modified = metadata
.modified()
.map_err(|e| WebError::InternalError(format!("{}", e)))?;
let len = metadata.len();
Ok(format!(
"\"{:x}-{:x}\"",
Self::time_to_epoch(modified),
len
))
}
fn format_last_modified(&self, metadata: &fs::Metadata) -> Result<String, WebError> {
let modified = metadata
.modified()
.map_err(|e| WebError::InternalError(format!("{}", e)))?;
let duration = modified
.duration_since(SystemTime::UNIX_EPOCH)
.map_err(|_| WebError::InternalError("Time error".to_string()))?;
let secs = duration.as_secs();
let days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
let months = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
let (year, month, day, hour, min, sec, week_day) = Self::epoch_to_datetime(secs);
let week = days[week_day as usize];
let mon = months[month as usize];
Ok(format!(
"{}, {:02} {} {:04} {:02}:{:02}:{:02} GMT",
week, day, mon, year, hour, min, sec
))
}
fn time_to_epoch(time: SystemTime) -> u64 {
time.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_else(|_| 0)
}
fn epoch_to_datetime(secs: u64) -> (u32, u32, u32, u32, u32, u32, u32) {
let mut year = 1970u32;
let mut remaining = secs;
loop {
let year_secs = if Self::is_leap_year(year) { 366 * 86400 } else { 365 * 86400 };
if remaining < year_secs {
break;
}
remaining -= year_secs;
year += 1;
}
let days_in_months: [u32; 12] = if Self::is_leap_year(year) {
[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 day_of_year = (remaining / 86400) as u32;
let time_of_day = remaining % 86400;
let mut month = 0u32;
let mut day = day_of_year + 1;
for (i, &days) in days_in_months.iter().enumerate() {
if day <= days {
month = i as u32 + 1;
break;
}
day -= days;
}
let hour = (time_of_day / 3600) as u32;
let min = ((time_of_day % 3600) / 60) as u32;
let sec = (time_of_day % 60) as u32;
let week_day = (day_of_year + Self::days_since_epoch(year)) % 7;
(year, month, day, hour, min, sec, week_day)
}
fn is_leap_year(year: u32) -> bool {
(year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
}
fn days_since_epoch(year: u32) -> u32 {
let mut days = 0u32;
for y in 1970..year {
days += if Self::is_leap_year(y) { 366 } else { 365 };
}
days
}
fn guess_content_type(filename: &str) -> String {
let ext = Path::new(filename)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
match ext.as_str() {
"html" | "htm" => "text/html".to_string(),
"css" => "text/css".to_string(),
"js" => "application/javascript".to_string(),
"json" => "application/json".to_string(),
"svg" => "image/svg+xml".to_string(),
"png" => "image/png".to_string(),
"jpg" | "jpeg" => "image/jpeg".to_string(),
"gif" => "image/gif".to_string(),
"ico" => "image/x-icon".to_string(),
"webp" => "image/webp".to_string(),
"woff" => "font/woff".to_string(),
"woff2" => "font/woff2".to_string(),
"ttf" => "font/ttf".to_string(),
"pdf" => "application/pdf".to_string(),
"zip" => "application/zip".to_string(),
"xml" => "application/xml".to_string(),
"txt" => "text/plain".to_string(),
_ => "application/octet-stream".to_string(),
}
}
fn generate_directory_listing(&self, dir_path: &Path) -> Result<CanonicalResponse, WebError> {
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
let mut entries = Vec::new();
if let Ok(read_dir) = fs::read_dir(dir_path) {
for entry in read_dir.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy().to_string();
let is_dir = entry.path().is_dir();
entries.push((name_str, is_dir));
}
}
entries.sort();
let mut html = String::from("<!DOCTYPE html><html><head><title>Directory listing</title></head><body>");
html.push_str("<h1>Directory listing</h1><ul>");
for (name, is_dir) in &entries {
let suffix = if *is_dir { "/" } else { "" };
let esc = html_escape(name);
html.push_str(&format!(
"<li><a href=\"{}{}\">{}{}</a></li>",
esc, suffix, esc, suffix
));
}
html.push_str("</ul></body></html>");
let mut response = CanonicalResponse::new(200);
response
.add_header(b"content-type", b"text/html")
.map_err(WebError::from)?;
response.set_body(html.into_bytes());
Ok(response)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
fn create_test_dir() -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let id = COUNTER.fetch_add(1, Ordering::SeqCst);
let mut dir = std::env::temp_dir();
dir.push(format!("zenith_static_test_{}_{}", std::process::id(), id));
if dir.exists() {
fs::remove_dir_all(&dir).ok();
}
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("index.html"), b"<html>hello</html>").unwrap();
fs::write(dir.join("style.css"), b"body { color: red; }").unwrap();
fs::write(dir.join("app.js"), b"console.log('hi')").unwrap();
fs::write(dir.join("data.json"), b"{}").unwrap();
fs::write(dir.join("image.png"), b"\x89PNG\r\n\x1a\n").unwrap();
let subdir = dir.join("subdir");
fs::create_dir_all(&subdir).unwrap();
fs::write(subdir.join("nested.txt"), b"nested content").unwrap();
dir
}
fn cleanup_test_dir(dir: &PathBuf) {
if dir.exists() {
fs::remove_dir_all(dir).ok();
}
}
#[cfg(target_os = "linux")]
#[test]
fn open_nofollow_rejects_symlink_and_opens_regular() {
use std::os::unix::fs::symlink;
let dir = create_test_dir();
let target = dir.join("index.html");
let link = dir.join("evil_link");
symlink(&target, &link).expect("create symlink");
let err = open_nofollow(&link).unwrap_err();
assert!(
err.raw_os_error().is_some(),
"symlink open must fail with os error: {err}"
);
let escape_link = dir.join("escape");
symlink("/etc/passwd", &escape_link).expect("create escape symlink");
assert!(
open_nofollow(&escape_link).is_err(),
"escape symlink must be rejected"
);
assert!(
open_nofollow(&target).is_ok(),
"regular file must open normally"
);
cleanup_test_dir(&dir);
}
#[test]
fn test_static_config_defaults() {
let config = StaticConfig::new("/tmp");
assert_eq!(config.default_file, "index.html");
assert!(!config.directory_listing);
assert_eq!(config.cache_max_age, 3600);
assert!(config.enable_range);
assert!(config.enable_conditional);
}
#[test]
fn test_static_config_builders() {
let config = StaticConfig::new("/tmp")
.with_default_file("default.htm")
.with_directory_listing(true)
.with_cache_max_age(7200)
.with_range(false)
.with_conditional(false);
assert_eq!(config.default_file, "default.htm");
assert!(config.directory_listing);
assert_eq!(config.cache_max_age, 7200);
assert!(!config.enable_range);
assert!(!config.enable_conditional);
}
#[test]
fn test_static_config_default_trait() {
let config = StaticConfig::default();
assert_eq!(config.root, PathBuf::from("static"));
assert_eq!(config.default_file, "index.html");
}
#[test]
fn test_guess_content_type_all_types() {
assert_eq!(StaticFileServer::guess_content_type("index.html"), "text/html");
assert_eq!(StaticFileServer::guess_content_type("page.htm"), "text/html");
assert_eq!(StaticFileServer::guess_content_type("style.css"), "text/css");
assert_eq!(StaticFileServer::guess_content_type("app.js"), "application/javascript");
assert_eq!(StaticFileServer::guess_content_type("data.json"), "application/json");
assert_eq!(StaticFileServer::guess_content_type("image.svg"), "image/svg+xml");
assert_eq!(StaticFileServer::guess_content_type("image.png"), "image/png");
assert_eq!(StaticFileServer::guess_content_type("photo.jpg"), "image/jpeg");
assert_eq!(StaticFileServer::guess_content_type("photo.jpeg"), "image/jpeg");
assert_eq!(StaticFileServer::guess_content_type("anim.gif"), "image/gif");
assert_eq!(StaticFileServer::guess_content_type("favicon.ico"), "image/x-icon");
assert_eq!(StaticFileServer::guess_content_type("img.webp"), "image/webp");
assert_eq!(StaticFileServer::guess_content_type("font.woff"), "font/woff");
assert_eq!(StaticFileServer::guess_content_type("font.woff2"), "font/woff2");
assert_eq!(StaticFileServer::guess_content_type("font.ttf"), "font/ttf");
assert_eq!(StaticFileServer::guess_content_type("doc.pdf"), "application/pdf");
assert_eq!(StaticFileServer::guess_content_type("archive.zip"), "application/zip");
assert_eq!(StaticFileServer::guess_content_type("data.xml"), "application/xml");
assert_eq!(StaticFileServer::guess_content_type("notes.txt"), "text/plain");
assert_eq!(StaticFileServer::guess_content_type("unknown.xyz"), "application/octet-stream");
assert_eq!(StaticFileServer::guess_content_type("noextension"), "application/octet-stream");
}
#[test]
fn test_guess_content_type_case_insensitive() {
assert_eq!(StaticFileServer::guess_content_type("FILE.HTML"), "text/html");
assert_eq!(StaticFileServer::guess_content_type("Image.PNG"), "image/png");
assert_eq!(StaticFileServer::guess_content_type("data.JSON"), "application/json");
}
fn with_canonical_root(config: StaticConfig) -> (StaticConfig, PathBuf) {
let canonical = config.root.canonicalize().unwrap();
let mut config = config;
config.root = canonical.clone();
(config, canonical)
}
#[test]
fn test_resolve_path_traversal_relative() {
let dir = create_test_dir();
let (config, _) = with_canonical_root(StaticConfig::new(&dir));
let server = StaticFileServer::new(config);
let result = server.resolve_path("../../../etc/passwd");
assert!(result.is_err());
cleanup_test_dir(&dir);
}
#[test]
fn test_resolve_path_traversal_encoded() {
let dir = create_test_dir();
let (config, _) = with_canonical_root(StaticConfig::new(&dir));
let server = StaticFileServer::new(config);
let result = server.resolve_path("..%2F..%2Fetc%2Fpasswd");
assert!(result.is_err());
cleanup_test_dir(&dir);
}
#[test]
fn test_resolve_path_normal() {
let dir = create_test_dir();
let (config, _) = with_canonical_root(StaticConfig::new(&dir));
let server = StaticFileServer::new(config);
let result = server.resolve_path("index.html");
assert!(result.is_ok());
let path = result.unwrap();
assert!(path.ends_with("index.html"));
cleanup_test_dir(&dir);
}
#[test]
fn test_resolve_path_nested() {
let dir = create_test_dir();
let (config, _) = with_canonical_root(StaticConfig::new(&dir));
let server = StaticFileServer::new(config);
let result = server.resolve_path("subdir/nested.txt");
assert!(result.is_ok());
let path = result.unwrap();
assert!(path.ends_with("nested.txt"));
cleanup_test_dir(&dir);
}
#[test]
fn test_resolve_path_with_leading_slash() {
let dir = create_test_dir();
let (config, _) = with_canonical_root(StaticConfig::new(&dir));
let server = StaticFileServer::new(config);
let result = server.resolve_path("/index.html");
assert!(result.is_ok());
cleanup_test_dir(&dir);
}
#[test]
fn test_serve_file_exists() {
let dir = create_test_dir();
let (config, _) = with_canonical_root(StaticConfig::new(&dir));
let server = StaticFileServer::new(config);
let result = server.serve("index.html");
assert!(result.is_ok());
let response = result.unwrap();
assert_eq!(response.status_code, 200);
cleanup_test_dir(&dir);
}
#[test]
fn test_serve_file_not_found() {
let dir = create_test_dir();
let (config, _) = with_canonical_root(StaticConfig::new(&dir));
let server = StaticFileServer::new(config);
let result = server.serve("nonexistent.txt");
assert!(result.is_err());
cleanup_test_dir(&dir);
}
#[test]
fn test_serve_file_content_type() {
let dir = create_test_dir();
let (config, _) = with_canonical_root(StaticConfig::new(&dir));
let server = StaticFileServer::new(config);
let result = server.serve("style.css");
assert!(result.is_ok());
let response = result.unwrap();
let ct = response.find_header("content-type");
assert!(ct.is_some());
assert_eq!(ct.unwrap().value_str(), "text/css");
cleanup_test_dir(&dir);
}
#[test]
fn test_serve_file_cache_headers() {
let dir = create_test_dir();
let (config, _) = with_canonical_root(StaticConfig::new(&dir).with_cache_max_age(86400));
let server = StaticFileServer::new(config);
let result = server.serve("index.html");
assert!(result.is_ok());
let response = result.unwrap();
let cc = response.find_header("cache-control");
assert!(cc.is_some());
assert!(cc.unwrap().value_str().contains("max-age=86400"));
cleanup_test_dir(&dir);
}
#[test]
fn test_serve_file_etag_and_last_modified() {
let dir = create_test_dir();
let (config, _) = with_canonical_root(StaticConfig::new(&dir));
let server = StaticFileServer::new(config);
let result = server.serve("index.html");
assert!(result.is_ok());
let response = result.unwrap();
assert!(response.find_header("etag").is_some());
assert!(response.find_header("last-modified").is_some());
cleanup_test_dir(&dir);
}
#[test]
fn test_serve_directory_default_file() {
let dir = create_test_dir();
let (config, _) = with_canonical_root(StaticConfig::new(&dir));
let server = StaticFileServer::new(config);
let result = server.serve("");
assert!(result.is_ok());
let response = result.unwrap();
assert_eq!(response.status_code, 200);
assert_eq!(response.body(), b"<html>hello</html>");
cleanup_test_dir(&dir);
}
#[test]
fn test_serve_directory_listing_disabled() {
let dir = create_test_dir();
let subdir = dir.join("subdir");
let default_file = subdir.join("index.html");
if default_file.exists() {
fs::remove_file(&default_file).ok();
}
let (config, _) = with_canonical_root(StaticConfig::new(&dir).with_directory_listing(false));
let server = StaticFileServer::new(config);
let result = server.serve("subdir/");
assert!(result.is_err());
match result.unwrap_err() {
WebError::Forbidden(_) => {},
_ => panic!("Expected Forbidden error"),
}
cleanup_test_dir(&dir);
}
#[test]
fn test_serve_directory_listing_enabled() {
let dir = create_test_dir();
let subdir = dir.join("subdir");
let default_file = subdir.join("index.html");
if default_file.exists() {
fs::remove_file(&default_file).ok();
}
let (config, _) = with_canonical_root(StaticConfig::new(&dir).with_directory_listing(true));
let server = StaticFileServer::new(config);
let result = server.serve("subdir/");
assert!(result.is_ok());
let response = result.unwrap();
assert_eq!(response.status_code, 200);
let body = String::from_utf8_lossy(response.body());
assert!(body.contains("Directory listing"));
assert!(body.contains("nested.txt"));
cleanup_test_dir(&dir);
}
#[test]
fn test_generate_directory_listing_sorting() {
let dir = create_test_dir();
let subdir = dir.join("list_test");
fs::create_dir_all(&subdir).unwrap();
fs::write(subdir.join("b.txt"), b"b").unwrap();
fs::write(subdir.join("a.txt"), b"a").unwrap();
fs::create_dir_all(subdir.join("cdir")).unwrap();
let (config, _) = with_canonical_root(StaticConfig::new(&dir).with_directory_listing(true));
let server = StaticFileServer::new(config);
let result = server.serve("list_test/");
assert!(result.is_ok());
let response = result.unwrap();
let body = String::from_utf8_lossy(response.body());
assert!(body.contains("a.txt"));
assert!(body.contains("b.txt"));
assert!(body.contains("cdir/"));
cleanup_test_dir(&dir);
}
#[test]
fn test_epoch_to_datetime_epoch_start() {
let (year, month, day, hour, min, sec, _week) =
StaticFileServer::epoch_to_datetime(0);
assert_eq!(year, 1970);
assert_eq!(month, 1);
assert_eq!(day, 1);
assert_eq!(hour, 0);
assert_eq!(min, 0);
assert_eq!(sec, 0);
}
#[test]
fn test_epoch_to_datetime_known_date() {
let (year, month, day, hour, min, sec, _week) =
StaticFileServer::epoch_to_datetime(1703507445);
assert_eq!(year, 2023);
assert_eq!(month, 12);
assert_eq!(day, 25);
assert_eq!(hour, 12);
assert_eq!(min, 30);
assert_eq!(sec, 45);
}
#[test]
fn test_is_leap_year_edge_cases() {
assert!(StaticFileServer::is_leap_year(2000));
assert!(!StaticFileServer::is_leap_year(1900));
assert!(StaticFileServer::is_leap_year(2024));
assert!(!StaticFileServer::is_leap_year(2023));
assert!(!StaticFileServer::is_leap_year(2022));
assert!(!StaticFileServer::is_leap_year(2021));
assert!(StaticFileServer::is_leap_year(2020));
assert!(StaticFileServer::is_leap_year(2400));
assert!(!StaticFileServer::is_leap_year(2100));
}
#[test]
fn test_time_to_epoch() {
let t = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1000);
assert_eq!(StaticFileServer::time_to_epoch(t), 1000);
}
#[test]
fn test_days_since_epoch() {
assert_eq!(StaticFileServer::days_since_epoch(1970), 0);
assert_eq!(StaticFileServer::days_since_epoch(1971), 365);
assert_eq!(StaticFileServer::days_since_epoch(1972), 730); }
#[test]
fn test_server_creation() {
let config = StaticConfig::new("/tmp");
let server = StaticFileServer::new(config);
assert_eq!(server.config.cache_max_age, 3600);
}
#[test]
fn test_static_config_max_file_size_default() {
let config = StaticConfig::new("/tmp");
assert_eq!(config.max_file_size, DEFAULT_MAX_FILE_SIZE);
assert_eq!(config.max_file_size, 16 * 1024 * 1024);
let config = StaticConfig::new("/tmp").with_max_file_size(1024);
assert_eq!(config.max_file_size, 1024);
}
#[test]
fn test_serve_large_file_rejected() {
let dir = create_test_dir();
let (config, _) = with_canonical_root(StaticConfig::new(&dir).with_max_file_size(1024));
let server = StaticFileServer::new(config);
fs::write(dir.join("big.bin"), vec![0u8; 2048]).unwrap();
let result = server.serve("big.bin");
assert!(result.is_err());
match result.unwrap_err() {
WebError::Custom { status, .. } => assert_eq!(status, 413),
other => panic!("Expected 413 Payload Too Large, got {:?}", other),
}
cleanup_test_dir(&dir);
}
#[test]
fn test_serve_if_none_match_304() {
let dir = create_test_dir();
let (config, _) = with_canonical_root(StaticConfig::new(&dir));
let server = StaticFileServer::new(config);
let resp = server.serve("index.html").unwrap();
assert_eq!(resp.status_code, 200);
let etag = resp.find_header("etag").unwrap().value_str().to_string();
let result = server.serve_with_headers("index.html", &[("if-none-match", etag.as_str())]);
assert!(result.is_ok());
let resp = result.unwrap();
assert_eq!(resp.status_code, 304);
assert!(resp.body().is_empty());
assert!(resp.find_header("etag").is_some());
cleanup_test_dir(&dir);
}
#[test]
fn test_serve_if_none_match_star_304() {
let dir = create_test_dir();
let (config, _) = with_canonical_root(StaticConfig::new(&dir));
let server = StaticFileServer::new(config);
let result = server.serve_with_headers("index.html", &[("if-none-match", "*")]);
assert!(result.is_ok());
let resp = result.unwrap();
assert_eq!(resp.status_code, 304);
cleanup_test_dir(&dir);
}
#[test]
fn test_serve_if_none_match_no_match_200() {
let dir = create_test_dir();
let (config, _) = with_canonical_root(StaticConfig::new(&dir));
let server = StaticFileServer::new(config);
let result =
server.serve_with_headers("index.html", &[("if-none-match", "\"deadbeef\"")]);
assert!(result.is_ok());
let resp = result.unwrap();
assert_eq!(resp.status_code, 200);
cleanup_test_dir(&dir);
}
#[test]
fn test_serve_if_modified_since_304() {
let dir = create_test_dir();
let (config, _) = with_canonical_root(StaticConfig::new(&dir));
let server = StaticFileServer::new(config);
let resp = server.serve("index.html").unwrap();
let lm = resp.find_header("last-modified").unwrap().value_str().to_string();
let result =
server.serve_with_headers("index.html", &[("if-modified-since", lm.as_str())]);
assert!(result.is_ok());
let resp = result.unwrap();
assert_eq!(resp.status_code, 304);
assert!(resp.body().is_empty());
cleanup_test_dir(&dir);
}
#[test]
fn test_serve_if_modified_since_future_304() {
let dir = create_test_dir();
let (config, _) = with_canonical_root(StaticConfig::new(&dir));
let server = StaticFileServer::new(config);
let result = server.serve_with_headers(
"index.html",
&[("if-modified-since", "Wed, 01 Jan 2099 00:00:00 GMT")],
);
assert!(result.is_ok());
let resp = result.unwrap();
assert_eq!(resp.status_code, 304);
cleanup_test_dir(&dir);
}
#[test]
fn test_serve_if_modified_since_past_200() {
let dir = create_test_dir();
let (config, _) = with_canonical_root(StaticConfig::new(&dir));
let server = StaticFileServer::new(config);
let result = server.serve_with_headers(
"index.html",
&[("if-modified-since", "Thu, 01 Jan 1970 00:00:00 GMT")],
);
assert!(result.is_ok());
let resp = result.unwrap();
assert_eq!(resp.status_code, 200);
cleanup_test_dir(&dir);
}
#[test]
fn test_serve_range_206() {
let dir = create_test_dir();
let (config, _) = with_canonical_root(StaticConfig::new(&dir));
let server = StaticFileServer::new(config);
fs::write(dir.join("range.txt"), b"0123456789ABCDEF").unwrap();
let result = server.serve_with_headers("range.txt", &[("range", "bytes=2-5")]);
assert!(result.is_ok());
let resp = result.unwrap();
assert_eq!(resp.status_code, 206);
assert_eq!(resp.body(), b"2345");
let cr = resp.find_header("content-range").unwrap().value_str();
assert_eq!(cr, "bytes 2-5/16");
let cl = resp.find_header("content-length").unwrap().value_str();
assert_eq!(cl, "4");
cleanup_test_dir(&dir);
}
#[test]
fn test_serve_range_open_ended_206() {
let dir = create_test_dir();
let (config, _) = with_canonical_root(StaticConfig::new(&dir));
let server = StaticFileServer::new(config);
fs::write(dir.join("range.txt"), b"0123456789ABCDEF").unwrap();
let result = server.serve_with_headers("range.txt", &[("range", "bytes=4-")]);
assert!(result.is_ok());
let resp = result.unwrap();
assert_eq!(resp.status_code, 206);
assert_eq!(resp.body(), b"456789ABCDEF");
cleanup_test_dir(&dir);
}
#[test]
fn test_serve_range_suffix_206() {
let dir = create_test_dir();
let (config, _) = with_canonical_root(StaticConfig::new(&dir));
let server = StaticFileServer::new(config);
fs::write(dir.join("range.txt"), b"0123456789ABCDEF").unwrap();
let result = server.serve_with_headers("range.txt", &[("range", "bytes=-4")]);
assert!(result.is_ok());
let resp = result.unwrap();
assert_eq!(resp.status_code, 206);
assert_eq!(resp.body(), b"CDEF");
cleanup_test_dir(&dir);
}
#[test]
fn test_serve_invalid_range_416() {
let dir = create_test_dir();
let (config, _) = with_canonical_root(StaticConfig::new(&dir));
let server = StaticFileServer::new(config);
fs::write(dir.join("range.txt"), b"0123456789ABCDEF").unwrap();
let result = server.serve_with_headers("range.txt", &[("range", "bytes=100-200")]);
assert!(result.is_ok());
let resp = result.unwrap();
assert_eq!(resp.status_code, 416);
let cr = resp.find_header("content-range").unwrap().value_str();
assert_eq!(cr, "bytes */16");
cleanup_test_dir(&dir);
}
#[test]
fn test_serve_range_disabled_ignored() {
let dir = create_test_dir();
let (config, _) = with_canonical_root(StaticConfig::new(&dir).with_range(false));
let server = StaticFileServer::new(config);
fs::write(dir.join("range.txt"), b"0123456789ABCDEF").unwrap();
let result = server.serve_with_headers("range.txt", &[("range", "bytes=2-5")]);
assert!(result.is_ok());
let resp = result.unwrap();
assert_eq!(resp.status_code, 200);
assert_eq!(resp.body(), b"0123456789ABCDEF");
cleanup_test_dir(&dir);
}
#[test]
fn test_serve_conditional_disabled_ignored() {
let dir = create_test_dir();
let (config, _) =
with_canonical_root(StaticConfig::new(&dir).with_conditional(false));
let server = StaticFileServer::new(config);
let resp = server.serve("index.html").unwrap();
let etag = resp.find_header("etag").unwrap().value_str().to_string();
let result =
server.serve_with_headers("index.html", &[("if-none-match", etag.as_str())]);
assert!(result.is_ok());
let resp = result.unwrap();
assert_eq!(resp.status_code, 200);
cleanup_test_dir(&dir);
}
#[test]
fn test_parse_range_basic() {
assert_eq!(
StaticFileServer::parse_range("bytes=0-499", 1000),
Some((0, 499))
);
assert_eq!(
StaticFileServer::parse_range("bytes=500-", 1000),
Some((500, 999))
);
assert_eq!(
StaticFileServer::parse_range("bytes=-500", 1000),
Some((500, 999))
);
assert_eq!(
StaticFileServer::parse_range("bytes=-2000", 1000),
Some((0, 999))
);
}
#[test]
fn test_parse_range_invalid() {
assert_eq!(StaticFileServer::parse_range("bytes=1000-2000", 1000), None);
assert_eq!(StaticFileServer::parse_range("bytes=500-100", 1000), None);
assert_eq!(StaticFileServer::parse_range("bytes=0-10", 0), None);
assert_eq!(StaticFileServer::parse_range("bytes=-0", 1000), None);
assert_eq!(StaticFileServer::parse_range("items=0-10", 1000), None);
}
#[test]
fn test_etag_matches() {
assert!(StaticFileServer::etag_matches("*", "\"abc\""));
assert!(StaticFileServer::etag_matches("\"abc\"", "\"abc\""));
assert!(StaticFileServer::etag_matches("\"ABC\"", "\"abc\""));
assert!(StaticFileServer::etag_matches("\"x\", \"abc\", \"y\"", "\"abc\""));
assert!(StaticFileServer::etag_matches("W/\"abc\"", "\"abc\""));
assert!(StaticFileServer::etag_matches("\"abc\"", "W/\"abc\""));
assert!(!StaticFileServer::etag_matches("\"def\"", "\"abc\""));
}
#[test]
fn test_parse_http_date_and_inverse() {
let secs = StaticFileServer::parse_http_date("Sun, 06 Nov 1994 08:49:37 GMT");
assert_eq!(secs, Some(784111777));
let (y, m, d, h, mi, s, _) = StaticFileServer::epoch_to_datetime(secs.unwrap());
assert_eq!((y, m, d, h, mi, s), (1994, 11, 6, 8, 49, 37));
}
#[test]
fn test_datetime_to_epoch_known() {
assert_eq!(
StaticFileServer::datetime_to_epoch(2023, 12, 25, 12, 30, 45),
1703507445
);
assert_eq!(StaticFileServer::datetime_to_epoch(1970, 1, 1, 0, 0, 0), 0);
}
}