use axum::Router;
use std::path::{Path, PathBuf};
use tower_http::services::{ServeDir, ServeFile};
pub fn static_dir(path: impl AsRef<Path>) -> ServeDir {
ServeDir::new(path)
}
pub fn static_dir_with_index(path: impl AsRef<Path>) -> ServeDir {
ServeDir::new(path).append_index_html_on_directories(true)
}
pub fn static_dir_spa(path: impl AsRef<Path>) -> ServeDir<ServeFile> {
let index = path.as_ref().join("index.html");
ServeDir::new(path).fallback(ServeFile::new(index))
}
pub fn static_router(prefix: &str, path: impl AsRef<Path>) -> Router {
Router::new().nest_service(prefix, ServeDir::new(path))
}
pub fn static_router_with_index(prefix: &str, path: impl AsRef<Path>) -> Router {
Router::new().nest_service(prefix, static_dir_with_index(path))
}
pub fn static_router_spa(path: impl AsRef<Path>) -> Router {
Router::new().fallback_service(static_dir_spa(path))
}
pub fn static_file(path: impl AsRef<Path>) -> ServeFile {
ServeFile::new(path)
}
const MIME_TYPES: &[(&str, &str)] = &[
("html", "text/html"),
("htm", "text/html"),
("shtml", "text/html"),
("css", "text/css"),
("xml", "text/xml"),
("txt", "text/plain"),
("md", "text/markdown"),
("csv", "text/csv"),
("js", "application/javascript"),
("mjs", "application/javascript"),
("json", "application/json"),
("png", "image/png"),
("jpg", "image/jpeg"),
("jpeg", "image/jpeg"),
("gif", "image/gif"),
("bmp", "image/bmp"),
("ico", "image/x-icon"),
("svg", "image/svg+xml"),
("webp", "image/webp"),
("avif", "image/avif"),
("mp3", "audio/mpeg"),
("wav", "audio/wav"),
("ogg", "audio/ogg"),
("mp4", "video/mp4"),
("webm", "video/webm"),
("m3u8", "application/vnd.apple.mpegurl"),
("ts", "video/mp2t"),
("woff", "font/woff"),
("woff2", "font/woff2"),
("ttf", "font/ttf"),
("otf", "font/otf"),
("eot", "application/vnd.ms-fontobject"),
("pdf", "application/pdf"),
("zip", "application/zip"),
("gz", "application/gzip"),
("tar", "application/x-tar"),
("rar", "application/vnd.rar"),
("7z", "application/x-7z-compressed"),
("wasm", "application/wasm"),
("swf", "application/x-shockwave-flash"),
("doc", "application/msword"),
(
"docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
),
("xls", "application/vnd.ms-excel"),
(
"xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
),
("ppt", "application/vnd.ms-powerpoint"),
(
"pptx",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
),
];
pub fn mime_type_for_extension(ext: &str) -> Option<&'static str> {
let ext_lower = ext.to_lowercase();
MIME_TYPES
.iter()
.find(|(k, _)| *k == ext_lower)
.map(|(_, v)| *v)
}
pub fn mime_type_for_path(path: &Path) -> Option<String> {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if let Some(mime) = mime_type_for_extension(ext) {
return Some(mime.to_string());
}
}
mime_guess::from_path(path).first().map(|m| m.to_string())
}
#[derive(Debug, Clone, PartialEq)]
pub struct RangeSpec {
pub start: u64,
pub end: u64,
}
#[derive(Debug, Clone, PartialEq)]
pub enum RangeError {
InvalidFormat,
InvalidRange,
Unsatisfiable,
}
pub fn parse_range_header(range: &str, file_size: u64) -> Result<RangeSpec, RangeError> {
let range = range.trim();
let range_value = range
.strip_prefix("bytes=")
.ok_or(RangeError::InvalidFormat)?;
let (start_str, end_str) = range_value
.split_once('-')
.ok_or(RangeError::InvalidFormat)?;
let (start, end) = match (start_str.is_empty(), end_str.is_empty()) {
(true, false) => {
let suffix: u64 = end_str.parse().map_err(|_| RangeError::InvalidRange)?;
if suffix == 0 {
return Err(RangeError::InvalidRange);
}
let start = file_size.saturating_sub(suffix);
(start, file_size.saturating_sub(1))
}
(false, true) => {
let start: u64 = start_str.parse().map_err(|_| RangeError::InvalidRange)?;
if start >= file_size {
return Err(RangeError::Unsatisfiable);
}
(start, file_size.saturating_sub(1))
}
(false, false) => {
let start: u64 = start_str.parse().map_err(|_| RangeError::InvalidRange)?;
let end: u64 = end_str.parse().map_err(|_| RangeError::InvalidRange)?;
if start > end {
return Err(RangeError::InvalidRange);
}
if start >= file_size {
return Err(RangeError::Unsatisfiable);
}
let end = end.min(file_size.saturating_sub(1));
(start, end)
}
(true, true) => return Err(RangeError::InvalidRange),
};
Ok(RangeSpec { start, end })
}
pub fn is_path_safe(path: &Path, root: &Path) -> bool {
let canonical_root = match root.canonicalize() {
Ok(p) => p,
Err(_) => return false,
};
let canonical_path = match path.canonicalize() {
Ok(p) => p,
Err(_) => return false,
};
canonical_path.starts_with(&canonical_root)
}
fn format_http_date(timestamp: std::time::SystemTime) -> String {
use std::time::UNIX_EPOCH;
let secs = timestamp
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let (year, month, day, hour, minute, second, weekday) = secs_to_date_time(secs);
let weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
let months = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
format!(
"{}, {:02} {} {:04} {:02}:{:02}:{:02} GMT",
weekdays[weekday as usize],
day,
months[(month - 1) as usize],
year,
hour,
minute,
second,
)
}
fn secs_to_date_time(secs: u64) -> (u64, u64, u64, u64, u64, u64, u64) {
let secs_in_day = 86400u64;
let mut days = secs / secs_in_day;
let remainder = secs % secs_in_day;
let hour = remainder / 3600;
let minute = (remainder % 3600) / 60;
let second = remainder % 60;
let weekday = (days + 4) % 7;
days += 719468; let era = days / 146097;
let doe = days - era * 146097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let year = if m <= 2 { y + 1 } else { y };
(year, m, d, hour, minute, second, weekday)
}
pub fn serve_file(path: &Path, headers: &axum::http::HeaderMap) -> axum::response::Response {
use axum::body::Body;
use axum::http::{header, StatusCode};
use axum::response::IntoResponse;
if !path.is_file() {
return (StatusCode::NOT_FOUND, "File not found").into_response();
}
let metadata = match std::fs::metadata(path) {
Ok(m) => m,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to read file metadata",
)
.into_response()
}
};
let file_size = metadata.len();
let modified = metadata.modified().ok();
if let Some(modified_time) = modified {
let last_modified = format_http_date(modified_time);
if let Some(if_modified_since) = headers.get(header::IF_MODIFIED_SINCE) {
if let Ok(ims_str) = if_modified_since.to_str() {
if ims_str.trim() == last_modified {
return (
StatusCode::NOT_MODIFIED,
[(header::LAST_MODIFIED, last_modified.as_str())],
Body::empty(),
)
.into_response();
}
}
}
}
let mime = mime_type_for_path(path);
let content_type = mime
.clone()
.unwrap_or_else(|| "application/octet-stream".to_string());
let content = match std::fs::read(path) {
Ok(c) => c,
Err(_) => {
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to read file").into_response()
}
};
if let Some(range_header) = headers.get(header::RANGE) {
if let Ok(range_str) = range_header.to_str() {
match parse_range_header(range_str, file_size) {
Ok(range) => {
let content_length = range.end - range.start + 1;
let bytes = content
.get(range.start as usize..=(range.end as usize))
.unwrap_or(&[]);
let content_range =
format!("bytes {}-{}/{}", range.start, range.end, file_size);
let content_length_str = content_length.to_string();
let mut response = (
StatusCode::PARTIAL_CONTENT,
[
(header::CONTENT_TYPE, content_type.as_str()),
(header::CONTENT_LENGTH, content_length_str.as_str()),
(header::CONTENT_RANGE, content_range.as_str()),
(header::ACCEPT_RANGES, "bytes"),
],
Body::from(bytes.to_vec()),
)
.into_response();
if let Some(modified_time) = modified {
let last_modified = format_http_date(modified_time);
if let Ok(val) = axum::http::HeaderValue::from_str(&last_modified) {
response.headers_mut().insert(header::LAST_MODIFIED, val);
}
}
return response;
}
Err(RangeError::Unsatisfiable) => {
let content_range = format!("bytes */{}", file_size);
return (
StatusCode::RANGE_NOT_SATISFIABLE,
[(header::CONTENT_RANGE, content_range.as_str())],
Body::empty(),
)
.into_response();
}
Err(_) => {
}
}
}
}
let content_length_str = file_size.to_string();
let mut response = (
StatusCode::OK,
[
(header::CONTENT_TYPE, content_type.as_str()),
(header::CONTENT_LENGTH, content_length_str.as_str()),
(header::ACCEPT_RANGES, "bytes"),
],
Body::from(content),
)
.into_response();
if let Some(modified_time) = modified {
let last_modified = format_http_date(modified_time);
if let Ok(val) = axum::http::HeaderValue::from_str(&last_modified) {
response.headers_mut().insert(header::LAST_MODIFIED, val);
}
}
if mime.is_none() {
if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
let disposition = format!("attachment; filename=\"{}\"", filename);
if let Ok(val) = axum::http::HeaderValue::from_str(&disposition) {
response
.headers_mut()
.insert(header::CONTENT_DISPOSITION, val);
}
}
}
response
}
pub fn static_handler(
root: &Path,
uri_path: &str,
headers: &axum::http::HeaderMap,
) -> axum::response::Response {
use axum::http::StatusCode;
use axum::response::IntoResponse;
let path_only = uri_path.split('?').next().unwrap_or(uri_path);
let decoded = percent_decode(path_only);
let relative = decoded.trim_start_matches('/');
let file_path: PathBuf = root.join(relative);
if !is_path_safe(&file_path, root) {
return (StatusCode::NOT_FOUND, "Not found").into_response();
}
serve_file(&file_path, headers)
}
fn percent_decode(input: &str) -> String {
let bytes = input.as_bytes();
let mut result = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(h), Some(l)) = (hex_digit(bytes[i + 1]), hex_digit(bytes[i + 2])) {
result.push(h * 16 + l);
i += 3;
continue;
}
}
result.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&result).into_owned()
}
fn hex_digit(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,
}
}
#[derive(Debug, Clone, Default)]
pub struct CacheControlConfig {
pub max_age: Option<u64>,
pub visibility: Option<CacheVisibility>,
pub no_cache: bool,
pub no_store: bool,
pub must_revalidate: bool,
pub immutable: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CacheVisibility {
Public,
Private,
}
impl CacheControlConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_max_age(mut self, seconds: u64) -> Self {
self.max_age = Some(seconds);
self
}
pub fn with_public(mut self) -> Self {
self.visibility = Some(CacheVisibility::Public);
self
}
pub fn with_private(mut self) -> Self {
self.visibility = Some(CacheVisibility::Private);
self
}
pub fn with_no_cache(mut self) -> Self {
self.no_cache = true;
self
}
pub fn with_no_store(mut self) -> Self {
self.no_store = true;
self
}
pub fn with_must_revalidate(mut self) -> Self {
self.must_revalidate = true;
self
}
pub fn with_immutable(mut self) -> Self {
self.immutable = true;
self
}
pub fn to_header_value(&self) -> Option<String> {
let mut directives = Vec::new();
if self.no_store {
directives.push("no-store".to_string());
}
if self.no_cache {
directives.push("no-cache".to_string());
}
if let Some(v) = self.visibility {
match v {
CacheVisibility::Public => directives.push("public".to_string()),
CacheVisibility::Private => directives.push("private".to_string()),
}
}
if let Some(max_age) = self.max_age {
directives.push(format!("max-age={}", max_age));
}
if self.must_revalidate {
directives.push("must-revalidate".to_string());
}
if self.immutable {
directives.push("immutable".to_string());
}
if directives.is_empty() {
None
} else {
Some(directives.join(", "))
}
}
}
pub fn compute_etag(metadata: &std::fs::Metadata) -> Option<String> {
let modified = metadata.modified().ok()?;
let secs = modified
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let size = metadata.len();
Some(format!("W/\"{}-{}\"", secs, size))
}
pub fn fingerprint_file(path: &Path) -> std::io::Result<String> {
let content = std::fs::read(path)?;
Ok(fingerprint_bytes(&content))
}
pub fn fingerprint_bytes(content: &[u8]) -> String {
use md5::{Digest, Md5};
let mut hasher = Md5::new();
hasher.update(content);
let result = hasher.finalize();
let mut hex = String::with_capacity(32);
for byte in result.iter() {
hex.push_str(&format!("{:02x}", byte));
}
hex
}
pub fn extract_version_hash(path: &str) -> Option<(String, String)> {
let last_dot = path.rfind('.')?;
let ext = &path[last_dot + 1..];
if ext.is_empty() {
return None;
}
let stem_with_hash = &path[..last_dot];
let second_last_dot = stem_with_hash.rfind('.')?;
let stem = &stem_with_hash[..second_last_dot];
let hash = &stem_with_hash[second_last_dot + 1..];
if hash.len() < 8 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
Some((format!("{}.{}", stem, ext), hash.to_string()))
}
pub fn serve_file_with_cache(
path: &Path,
headers: &axum::http::HeaderMap,
cache_config: Option<&CacheControlConfig>,
) -> axum::response::Response {
use axum::body::Body;
use axum::http::{header, StatusCode};
use axum::response::IntoResponse;
if !path.is_file() {
return (StatusCode::NOT_FOUND, "File not found").into_response();
}
let metadata = match std::fs::metadata(path) {
Ok(m) => m,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to read file metadata",
)
.into_response();
}
};
let file_size = metadata.len();
let modified = metadata.modified().ok();
let etag = compute_etag(&metadata);
let mut if_none_match_present = false;
if let Some(ref etag_value) = etag {
if let Some(if_none_match) = headers.get(header::IF_NONE_MATCH) {
if_none_match_present = true;
if let Ok(inm_str) = if_none_match.to_str() {
if inm_str.trim() == "*" || inm_str.trim() == etag_value.as_str() {
let mut response = (
StatusCode::NOT_MODIFIED,
[(header::ETAG, etag_value.as_str())],
Body::empty(),
)
.into_response();
if let Some(modified_time) = modified {
let last_modified = format_http_date(modified_time);
if let Ok(val) = axum::http::HeaderValue::from_str(&last_modified) {
response.headers_mut().insert(header::LAST_MODIFIED, val);
}
}
if let Some(cc) = cache_config {
if let Some(cc_value) = cc.to_header_value() {
if let Ok(val) = axum::http::HeaderValue::from_str(&cc_value) {
response.headers_mut().insert(header::CACHE_CONTROL, val);
}
}
}
return response;
}
}
}
}
if !if_none_match_present {
if let Some(modified_time) = modified {
let last_modified = format_http_date(modified_time);
if let Some(if_modified_since) = headers.get(header::IF_MODIFIED_SINCE) {
if let Ok(ims_str) = if_modified_since.to_str() {
if ims_str.trim() == last_modified {
let mut response = (
StatusCode::NOT_MODIFIED,
[(header::LAST_MODIFIED, last_modified.as_str())],
Body::empty(),
)
.into_response();
if let Some(ref etag_value) = etag {
if let Ok(val) = axum::http::HeaderValue::from_str(etag_value) {
response.headers_mut().insert(header::ETAG, val);
}
}
if let Some(cc) = cache_config {
if let Some(cc_value) = cc.to_header_value() {
if let Ok(val) = axum::http::HeaderValue::from_str(&cc_value) {
response.headers_mut().insert(header::CACHE_CONTROL, val);
}
}
}
return response;
}
}
}
}
}
let mime = mime_type_for_path(path);
let content_type = mime
.clone()
.unwrap_or_else(|| "application/octet-stream".to_string());
let content = match std::fs::read(path) {
Ok(c) => c,
Err(_) => {
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to read file").into_response()
}
};
if let Some(range_header) = headers.get(header::RANGE) {
if let Ok(range_str) = range_header.to_str() {
match parse_range_header(range_str, file_size) {
Ok(range) => {
let content_length = range.end - range.start + 1;
let bytes = content
.get(range.start as usize..=(range.end as usize))
.unwrap_or(&[]);
let content_range =
format!("bytes {}-{}/{}", range.start, range.end, file_size);
let content_length_str = content_length.to_string();
let mut response = (
StatusCode::PARTIAL_CONTENT,
[
(header::CONTENT_TYPE, content_type.as_str()),
(header::CONTENT_LENGTH, content_length_str.as_str()),
(header::CONTENT_RANGE, content_range.as_str()),
(header::ACCEPT_RANGES, "bytes"),
],
Body::from(bytes.to_vec()),
)
.into_response();
if let Some(modified_time) = modified {
let last_modified = format_http_date(modified_time);
if let Ok(val) = axum::http::HeaderValue::from_str(&last_modified) {
response.headers_mut().insert(header::LAST_MODIFIED, val);
}
}
if let Some(ref etag_value) = etag {
if let Ok(val) = axum::http::HeaderValue::from_str(etag_value) {
response.headers_mut().insert(header::ETAG, val);
}
}
if let Some(cc) = cache_config {
if let Some(cc_value) = cc.to_header_value() {
if let Ok(val) = axum::http::HeaderValue::from_str(&cc_value) {
response.headers_mut().insert(header::CACHE_CONTROL, val);
}
}
}
return response;
}
Err(RangeError::Unsatisfiable) => {
let content_range = format!("bytes */{}", file_size);
return (
StatusCode::RANGE_NOT_SATISFIABLE,
[(header::CONTENT_RANGE, content_range.as_str())],
Body::empty(),
)
.into_response();
}
Err(_) => {
}
}
}
}
let content_length_str = file_size.to_string();
let mut response = (
StatusCode::OK,
[
(header::CONTENT_TYPE, content_type.as_str()),
(header::CONTENT_LENGTH, content_length_str.as_str()),
(header::ACCEPT_RANGES, "bytes"),
],
Body::from(content),
)
.into_response();
if let Some(modified_time) = modified {
let last_modified = format_http_date(modified_time);
if let Ok(val) = axum::http::HeaderValue::from_str(&last_modified) {
response.headers_mut().insert(header::LAST_MODIFIED, val);
}
}
if let Some(ref etag_value) = etag {
if let Ok(val) = axum::http::HeaderValue::from_str(etag_value) {
response.headers_mut().insert(header::ETAG, val);
}
}
if let Some(cc) = cache_config {
if let Some(cc_value) = cc.to_header_value() {
if let Ok(val) = axum::http::HeaderValue::from_str(&cc_value) {
response.headers_mut().insert(header::CACHE_CONTROL, val);
}
}
}
if mime.is_none() {
if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
let disposition = format!("attachment; filename=\"{}\"", filename);
if let Ok(val) = axum::http::HeaderValue::from_str(&disposition) {
response
.headers_mut()
.insert(header::CONTENT_DISPOSITION, val);
}
}
}
response
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use http_body_util::BodyExt;
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
use tower::ServiceExt;
fn create_test_dir() -> TempDir {
let dir = tempfile::tempdir().expect("failed to create temp dir");
let root = dir.path();
fs::write(root.join("index.html"), "<html>index</html>").unwrap();
fs::write(root.join("style.css"), "body { color: red; }").unwrap();
fs::create_dir_all(root.join("js")).unwrap();
fs::write(root.join("js").join("app.js"), "console.log('hello');").unwrap();
dir
}
async fn send_get(router: Router, uri: &str) -> (StatusCode, Vec<u8>) {
let req = Request::builder()
.method(Method::GET)
.uri(uri)
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
(status, bytes.to_vec())
}
async fn send_get_with_headers(
router: Router,
uri: &str,
) -> (StatusCode, axum::http::HeaderMap, Vec<u8>) {
let req = Request::builder()
.method(Method::GET)
.uri(uri)
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
let status = resp.status();
let headers = resp.headers().clone();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
(status, headers, bytes.to_vec())
}
#[tokio::test]
async fn test_static_router_serves_existing_file() {
let dir = create_test_dir();
let router = static_router("/s", dir.path());
let (status, body) = send_get(router, "/s/style.css").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(&body[..], b"body { color: red; }");
}
#[tokio::test]
async fn test_static_router_serves_file_in_subdir() {
let dir = create_test_dir();
let router = static_router("/s", dir.path());
let (status, body) = send_get(router, "/s/js/app.js").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(&body[..], b"console.log('hello');");
}
#[tokio::test]
async fn test_static_router_returns_404_for_missing_file() {
let dir = create_test_dir();
let router = static_router("/s", dir.path());
let (status, _) = send_get(router, "/s/nonexistent.txt").await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_static_router_with_index_serves_index_on_dir() {
let dir = create_test_dir();
let router = static_router_with_index("/s", dir.path());
let (status, body) = send_get(router, "/s/").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(&body[..], b"<html>index</html>");
}
#[tokio::test]
async fn test_static_router_with_index_serves_other_files() {
let dir = create_test_dir();
let router = static_router_with_index("/s", dir.path());
let (status, body) = send_get(router, "/s/style.css").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(&body[..], b"body { color: red; }");
}
#[tokio::test]
async fn test_static_router_spa_fallback_to_index() {
let dir = create_test_dir();
let router = static_router_spa(dir.path());
let (status, body) = send_get(router, "/some/spa/route").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(&body[..], b"<html>index</html>");
}
#[tokio::test]
async fn test_static_router_spa_serves_existing_file() {
let dir = create_test_dir();
let router = static_router_spa(dir.path());
let (status, body) = send_get(router, "/style.css").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(&body[..], b"body { color: red; }");
}
#[tokio::test]
async fn test_static_file_serves_single_file() {
let dir = create_test_dir();
let file_path: PathBuf = dir.path().join("style.css");
let router: Router = Router::new().route_service("/style.css", static_file(file_path));
let (status, body) = send_get(router, "/style.css").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(&body[..], b"body { color: red; }");
}
#[tokio::test]
async fn test_static_file_unknown_path_404() {
let dir = create_test_dir();
let file_path: PathBuf = dir.path().join("style.css");
let router: Router = Router::new().route_service("/style.css", static_file(file_path));
let (status, _) = send_get(router, "/nonexistent.css").await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_path_traversal_blocked() {
let dir = create_test_dir();
let parent = dir.path().parent().unwrap();
let sensitive = parent.join("sensitive.txt");
fs::write(&sensitive, "secret").unwrap();
let router = static_router("/s", dir.path());
let (status, _) = send_get(router, "/s/../sensitive.txt").await;
assert!(
status == StatusCode::NOT_FOUND || status == StatusCode::BAD_REQUEST,
"expected 404 or 400, got {status}"
);
let _ = fs::remove_file(&sensitive);
}
#[tokio::test]
async fn test_static_router_sets_content_type_css() {
let dir = create_test_dir();
let router = static_router("/s", dir.path());
let (_, headers, _) = send_get_with_headers(router, "/s/style.css").await;
let ct = headers.get("content-type").unwrap().to_str().unwrap();
assert!(ct.contains("css"), "expected css, got {ct}");
}
#[tokio::test]
async fn test_static_router_sets_content_type_js() {
let dir = create_test_dir();
let router = static_router("/s", dir.path());
let (_, headers, _) = send_get_with_headers(router, "/s/js/app.js").await;
let ct = headers.get("content-type").unwrap().to_str().unwrap();
assert!(
ct.contains("javascript") || ct.contains("js"),
"expected js, got {ct}"
);
}
#[tokio::test]
async fn test_static_router_spa_sets_content_type_html() {
let dir = create_test_dir();
let router = static_router_spa(dir.path());
let (_, headers, _) = send_get_with_headers(router, "/unknown/route").await;
let ct = headers.get("content-type").unwrap().to_str().unwrap();
assert!(ct.contains("html"), "expected html, got {ct}");
}
#[tokio::test]
async fn test_static_router_handles_head_request() {
let dir = create_test_dir();
let router = static_router("/s", dir.path());
let req = Request::builder()
.method(Method::HEAD)
.uri("/s/style.css")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
assert!(bytes.is_empty() || bytes.len() < 100);
}
#[tokio::test]
async fn test_static_dir_with_nest_service() {
let dir = create_test_dir();
let router: Router = Router::new().nest_service("/s", static_dir(dir.path()));
let (status, body) = send_get(router, "/s/style.css").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(&body[..], b"body { color: red; }");
}
#[tokio::test]
async fn test_static_dir_with_index_with_nest_service() {
let dir = create_test_dir();
let router: Router = Router::new().nest_service("/s", static_dir_with_index(dir.path()));
let (status, body) = send_get(router, "/s/").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(&body[..], b"<html>index</html>");
}
#[tokio::test]
async fn test_static_dir_spa_with_fallback_service() {
let dir = create_test_dir();
let router: Router = Router::new().fallback_service(static_dir_spa(dir.path()));
let (status, body) = send_get(router, "/unknown/route").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(&body[..], b"<html>index</html>");
}
#[tokio::test]
async fn test_static_router_merge_with_api_routes() {
let dir = create_test_dir();
let api_router: Router = Router::new().route(
"/api/hello",
axum::routing::get(|| async { "hello from api" }),
);
let static_router = static_router("/static", dir.path());
let app: Router = api_router.merge(static_router);
let (status, body) = send_get(app.clone(), "/api/hello").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(&body[..], b"hello from api");
let (status, body) = send_get(app, "/static/style.css").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(&body[..], b"body { color: red; }");
}
#[test]
fn test_mime_type_for_extension_html() {
assert_eq!(mime_type_for_extension("html"), Some("text/html"));
assert_eq!(mime_type_for_extension("HTML"), Some("text/html"));
assert_eq!(mime_type_for_extension("Htm"), Some("text/html"));
}
#[test]
fn test_mime_type_for_extension_css() {
assert_eq!(mime_type_for_extension("css"), Some("text/css"));
}
#[test]
fn test_mime_type_for_extension_js() {
assert_eq!(
mime_type_for_extension("js"),
Some("application/javascript")
);
assert_eq!(
mime_type_for_extension("mjs"),
Some("application/javascript")
);
}
#[test]
fn test_mime_type_for_extension_json() {
assert_eq!(mime_type_for_extension("json"), Some("application/json"));
}
#[test]
fn test_mime_type_for_extension_images() {
assert_eq!(mime_type_for_extension("png"), Some("image/png"));
assert_eq!(mime_type_for_extension("jpg"), Some("image/jpeg"));
assert_eq!(mime_type_for_extension("jpeg"), Some("image/jpeg"));
assert_eq!(mime_type_for_extension("gif"), Some("image/gif"));
assert_eq!(mime_type_for_extension("svg"), Some("image/svg+xml"));
assert_eq!(mime_type_for_extension("ico"), Some("image/x-icon"));
assert_eq!(mime_type_for_extension("webp"), Some("image/webp"));
}
#[test]
fn test_mime_type_for_extension_fonts() {
assert_eq!(mime_type_for_extension("woff"), Some("font/woff"));
assert_eq!(mime_type_for_extension("woff2"), Some("font/woff2"));
assert_eq!(mime_type_for_extension("ttf"), Some("font/ttf"));
}
#[test]
fn test_mime_type_for_extension_unknown() {
assert_eq!(mime_type_for_extension("xyz123"), None);
assert_eq!(mime_type_for_extension(""), None);
}
#[test]
fn test_mime_type_for_path() {
assert_eq!(
mime_type_for_path(Path::new("style.css")),
Some("text/css".to_string())
);
assert_eq!(
mime_type_for_path(Path::new("/var/www/index.html")),
Some("text/html".to_string())
);
let result = mime_type_for_path(Path::new("file.unknownext123"));
let _ = result;
}
#[test]
fn test_parse_range_start_end() {
let range = parse_range_header("bytes=0-499", 1000).unwrap();
assert_eq!(range, RangeSpec { start: 0, end: 499 });
}
#[test]
fn test_parse_range_start_open() {
let range = parse_range_header("bytes=500-", 1000).unwrap();
assert_eq!(
range,
RangeSpec {
start: 500,
end: 999
}
);
}
#[test]
fn test_parse_range_suffix() {
let range = parse_range_header("bytes=-500", 1000).unwrap();
assert_eq!(
range,
RangeSpec {
start: 500,
end: 999
}
);
}
#[test]
fn test_parse_range_suffix_larger_than_file() {
let range = parse_range_header("bytes=-2000", 1000).unwrap();
assert_eq!(range, RangeSpec { start: 0, end: 999 });
}
#[test]
fn test_parse_range_end_exceeds_file_size() {
let range = parse_range_header("bytes=900-2000", 1000).unwrap();
assert_eq!(
range,
RangeSpec {
start: 900,
end: 999
}
);
}
#[test]
fn test_parse_range_start_equals_file_size() {
let result = parse_range_header("bytes=1000-", 1000);
assert_eq!(result, Err(RangeError::Unsatisfiable));
}
#[test]
fn test_parse_range_start_greater_than_end() {
let result = parse_range_header("bytes=500-100", 1000);
assert_eq!(result, Err(RangeError::InvalidRange));
}
#[test]
fn test_parse_range_invalid_format_no_bytes_prefix() {
let result = parse_range_header("0-499", 1000);
assert_eq!(result, Err(RangeError::InvalidFormat));
}
#[test]
fn test_parse_range_invalid_format_no_dash() {
let result = parse_range_header("bytes=500", 1000);
assert_eq!(result, Err(RangeError::InvalidFormat));
}
#[test]
fn test_parse_range_empty_range() {
let result = parse_range_header("bytes=-", 1000);
assert_eq!(result, Err(RangeError::InvalidRange));
}
#[test]
fn test_parse_range_non_numeric() {
let result = parse_range_header("bytes=abc-500", 1000);
assert_eq!(result, Err(RangeError::InvalidRange));
}
#[test]
fn test_parse_range_with_whitespace() {
let range = parse_range_header(" bytes=0-499 ", 1000).unwrap();
assert_eq!(range, RangeSpec { start: 0, end: 499 });
}
#[test]
fn test_is_path_safe_valid() {
let dir = create_test_dir();
let root = dir.path();
let file = root.join("style.css");
assert!(is_path_safe(&file, root));
}
#[test]
fn test_is_path_safe_subdir() {
let dir = create_test_dir();
let root = dir.path();
let file = root.join("js").join("app.js");
assert!(is_path_safe(&file, root));
}
#[test]
fn test_is_path_safe_traversal_blocked() {
let dir = create_test_dir();
let root = dir.path();
let parent = root.parent().unwrap();
let sensitive = parent.join("sensitive.txt");
fs::write(&sensitive, "secret").unwrap();
let file = root.join("..").join("sensitive.txt");
assert!(!is_path_safe(&file, root));
let _ = fs::remove_file(&sensitive);
}
#[test]
fn test_is_path_safe_nonexistent() {
let dir = create_test_dir();
let root = dir.path();
let file = root.join("nonexistent.txt");
assert!(!is_path_safe(&file, root));
}
#[test]
fn test_percent_decode_plain() {
assert_eq!(percent_decode("/style.css"), "/style.css");
}
#[test]
fn test_percent_decode_encoded() {
assert_eq!(percent_decode("/my%20file.css"), "/my file.css");
}
#[test]
fn test_percent_decode_unicode() {
assert_eq!(percent_decode("/%E4%B8%AD.html"), "/中.html");
}
#[test]
fn test_percent_decode_no_plus_conversion() {
assert_eq!(percent_decode("/my+file.css"), "/my+file.css");
}
#[test]
fn test_percent_decode_incomplete() {
assert_eq!(percent_decode("/file%2.css"), "/file%2.css");
}
#[tokio::test]
async fn test_serve_file_basic() {
let dir = create_test_dir();
let file_path = dir.path().join("style.css");
let headers = axum::http::HeaderMap::new();
let resp = serve_file(&file_path, &headers);
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&bytes[..], b"body { color: red; }");
}
#[tokio::test]
async fn test_serve_file_not_found() {
let dir = create_test_dir();
let file_path = dir.path().join("nonexistent.txt");
let headers = axum::http::HeaderMap::new();
let resp = serve_file(&file_path, &headers);
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_serve_file_sets_content_type() {
let dir = create_test_dir();
let file_path = dir.path().join("style.css");
let headers = axum::http::HeaderMap::new();
let resp = serve_file(&file_path, &headers);
let ct = resp
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap();
assert!(ct.contains("css"), "expected css, got {ct}");
}
#[tokio::test]
async fn test_serve_file_sets_last_modified() {
let dir = create_test_dir();
let file_path = dir.path().join("style.css");
let headers = axum::http::HeaderMap::new();
let resp = serve_file(&file_path, &headers);
let lm = resp.headers().get("last-modified");
assert!(lm.is_some(), "Last-Modified header should be set");
let lm_str = lm.unwrap().to_str().unwrap();
assert!(lm_str.ends_with("GMT"), "Last-Modified should end with GMT");
}
#[tokio::test]
async fn test_serve_file_sets_accept_ranges() {
let dir = create_test_dir();
let file_path = dir.path().join("style.css");
let headers = axum::http::HeaderMap::new();
let resp = serve_file(&file_path, &headers);
let ar = resp
.headers()
.get("accept-ranges")
.unwrap()
.to_str()
.unwrap();
assert_eq!(ar, "bytes");
}
#[tokio::test]
async fn test_serve_file_304_if_modified_since_match() {
let dir = create_test_dir();
let file_path = dir.path().join("style.css");
let headers1 = axum::http::HeaderMap::new();
let resp1 = serve_file(&file_path, &headers1);
let last_modified = resp1
.headers()
.get("last-modified")
.unwrap()
.to_str()
.unwrap()
.to_string();
let mut headers2 = axum::http::HeaderMap::new();
headers2.insert(
axum::http::header::IF_MODIFIED_SINCE,
axum::http::HeaderValue::from_str(&last_modified).unwrap(),
);
let resp2 = serve_file(&file_path, &headers2);
assert_eq!(resp2.status(), StatusCode::NOT_MODIFIED);
let bytes = resp2.into_body().collect().await.unwrap().to_bytes();
assert!(bytes.is_empty(), "304 response should have empty body");
}
#[tokio::test]
async fn test_serve_file_304_if_modified_since_mismatch() {
let dir = create_test_dir();
let file_path = dir.path().join("style.css");
let mut headers = axum::http::HeaderMap::new();
headers.insert(
axum::http::header::IF_MODIFIED_SINCE,
axum::http::HeaderValue::from_static("Mon, 01 Jan 2000 00:00:00 GMT"),
);
let resp = serve_file(&file_path, &headers);
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_serve_file_range_partial_content() {
let dir = create_test_dir();
let file_path = dir.path().join("data.bin");
fs::write(&file_path, b"0123456789ABCDEFGHIJ").unwrap();
let mut headers = axum::http::HeaderMap::new();
headers.insert(
axum::http::header::RANGE,
axum::http::HeaderValue::from_static("bytes=5-9"),
);
let resp = serve_file(&file_path, &headers);
assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
let cr = resp
.headers()
.get("content-range")
.unwrap()
.to_str()
.unwrap();
assert_eq!(cr, "bytes 5-9/20");
let cl = resp
.headers()
.get("content-length")
.unwrap()
.to_str()
.unwrap();
assert_eq!(cl, "5");
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&bytes[..], b"56789");
}
#[tokio::test]
async fn test_serve_file_range_open_end() {
let dir = create_test_dir();
let file_path = dir.path().join("data.bin");
fs::write(&file_path, b"0123456789ABCDEFGHIJ").unwrap();
let mut headers = axum::http::HeaderMap::new();
headers.insert(
axum::http::header::RANGE,
axum::http::HeaderValue::from_static("bytes=10-"),
);
let resp = serve_file(&file_path, &headers);
assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
let cr = resp
.headers()
.get("content-range")
.unwrap()
.to_str()
.unwrap();
assert_eq!(cr, "bytes 10-19/20");
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&bytes[..], b"ABCDEFGHIJ");
}
#[tokio::test]
async fn test_serve_file_range_suffix() {
let dir = create_test_dir();
let file_path = dir.path().join("data.bin");
fs::write(&file_path, b"0123456789ABCDEFGHIJ").unwrap();
let mut headers = axum::http::HeaderMap::new();
headers.insert(
axum::http::header::RANGE,
axum::http::HeaderValue::from_static("bytes=-5"),
);
let resp = serve_file(&file_path, &headers);
assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
let cr = resp
.headers()
.get("content-range")
.unwrap()
.to_str()
.unwrap();
assert_eq!(cr, "bytes 15-19/20");
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&bytes[..], b"FGHIJ");
}
#[tokio::test]
async fn test_serve_file_range_unsatisfiable() {
let dir = create_test_dir();
let file_path = dir.path().join("data.bin");
fs::write(&file_path, b"0123456789").unwrap();
let mut headers = axum::http::HeaderMap::new();
headers.insert(
axum::http::header::RANGE,
axum::http::HeaderValue::from_static("bytes=100-200"),
);
let resp = serve_file(&file_path, &headers);
assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
let cr = resp
.headers()
.get("content-range")
.unwrap()
.to_str()
.unwrap();
assert_eq!(cr, "bytes */10");
}
#[tokio::test]
async fn test_serve_file_range_invalid_fallback_to_full() {
let dir = create_test_dir();
let file_path = dir.path().join("data.bin");
fs::write(&file_path, b"0123456789").unwrap();
let mut headers = axum::http::HeaderMap::new();
headers.insert(
axum::http::header::RANGE,
axum::http::HeaderValue::from_static("0-499"),
);
let resp = serve_file(&file_path, &headers);
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&bytes[..], b"0123456789");
}
#[tokio::test]
async fn test_static_handler_serves_file() {
let dir = create_test_dir();
let headers = axum::http::HeaderMap::new();
let resp = static_handler(dir.path(), "/style.css", &headers);
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&bytes[..], b"body { color: red; }");
}
#[tokio::test]
async fn test_static_handler_serves_subdir_file() {
let dir = create_test_dir();
let headers = axum::http::HeaderMap::new();
let resp = static_handler(dir.path(), "/js/app.js", &headers);
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&bytes[..], b"console.log('hello');");
}
#[tokio::test]
async fn test_static_handler_404_for_missing() {
let dir = create_test_dir();
let headers = axum::http::HeaderMap::new();
let resp = static_handler(dir.path(), "/nonexistent.txt", &headers);
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_static_handler_blocks_traversal() {
let dir = create_test_dir();
let root = dir.path();
let parent = root.parent().unwrap();
let sensitive = parent.join("secret.txt");
fs::write(&sensitive, "secret").unwrap();
let headers = axum::http::HeaderMap::new();
let resp = static_handler(root, "/../secret.txt", &headers);
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let _ = fs::remove_file(&sensitive);
}
#[tokio::test]
async fn test_static_handler_with_query_string() {
let dir = create_test_dir();
let headers = axum::http::HeaderMap::new();
let resp = static_handler(dir.path(), "/style.css?v=123", &headers);
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&bytes[..], b"body { color: red; }");
}
#[tokio::test]
async fn test_static_handler_url_encoded_path() {
let dir = create_test_dir();
fs::write(dir.path().join("my file.css"), "encoded content").unwrap();
let headers = axum::http::HeaderMap::new();
let resp = static_handler(dir.path(), "/my%20file.css", &headers);
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&bytes[..], b"encoded content");
}
#[test]
fn test_format_http_date_epoch() {
let time = std::time::UNIX_EPOCH;
let date_str = format_http_date(time);
assert!(
date_str.contains("Thu"),
"expected Thursday, got {date_str}"
);
assert!(date_str.contains("01"), "expected day 01, got {date_str}");
assert!(date_str.contains("Jan"), "expected January, got {date_str}");
assert!(
date_str.contains("1970"),
"expected year 1970, got {date_str}"
);
assert!(
date_str.ends_with("GMT"),
"expected GMT suffix, got {date_str}"
);
}
#[test]
fn test_format_http_date_known_timestamp() {
let time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1768569045);
let date_str = format_http_date(time);
assert!(
date_str.contains("2026"),
"expected year 2026, got {date_str}"
);
assert!(
date_str.ends_with("GMT"),
"expected GMT suffix, got {date_str}"
);
}
#[tokio::test]
async fn test_serve_file_unknown_mime_sets_content_disposition() {
let dir = create_test_dir();
let file_path = dir.path().join("data.xyz123");
fs::write(&file_path, "unknown content").unwrap();
let headers = axum::http::HeaderMap::new();
let resp = serve_file(&file_path, &headers);
assert_eq!(resp.status(), StatusCode::OK);
let cd = resp.headers().get("content-disposition");
assert!(
cd.is_some(),
"Content-Disposition should be set for unknown MIME"
);
let cd_str = cd.unwrap().to_str().unwrap();
assert!(
cd_str.contains("attachment"),
"expected attachment, got {cd_str}"
);
assert!(
cd_str.contains("data.xyz123"),
"expected filename, got {cd_str}"
);
}
#[tokio::test]
async fn test_serve_file_known_mime_no_content_disposition() {
let dir = create_test_dir();
let file_path = dir.path().join("style.css");
let headers = axum::http::HeaderMap::new();
let resp = serve_file(&file_path, &headers);
assert_eq!(resp.status(), StatusCode::OK);
let cd = resp.headers().get("content-disposition");
assert!(
cd.is_none(),
"Content-Disposition should not be set for known MIME"
);
}
#[test]
fn test_cache_control_default_empty() {
let config = CacheControlConfig::new();
assert_eq!(config.to_header_value(), None);
}
#[test]
fn test_cache_control_max_age_only() {
let config = CacheControlConfig::new().with_max_age(3600);
assert_eq!(config.to_header_value().as_deref(), Some("max-age=3600"));
}
#[test]
fn test_cache_control_public_max_age() {
let config = CacheControlConfig::new().with_public().with_max_age(3600);
assert_eq!(
config.to_header_value().as_deref(),
Some("public, max-age=3600")
);
}
#[test]
fn test_cache_control_private_max_age() {
let config = CacheControlConfig::new().with_private().with_max_age(600);
assert_eq!(
config.to_header_value().as_deref(),
Some("private, max-age=600")
);
}
#[test]
fn test_cache_control_no_cache() {
let config = CacheControlConfig::new().with_no_cache();
assert_eq!(config.to_header_value().as_deref(), Some("no-cache"));
}
#[test]
fn test_cache_control_no_store() {
let config = CacheControlConfig::new().with_no_store();
assert_eq!(config.to_header_value().as_deref(), Some("no-store"));
}
#[test]
fn test_cache_control_no_store_no_cache_order() {
let config = CacheControlConfig::new().with_no_cache().with_no_store();
assert_eq!(
config.to_header_value().as_deref(),
Some("no-store, no-cache")
);
}
#[test]
fn test_cache_control_must_revalidate() {
let config = CacheControlConfig::new()
.with_no_cache()
.with_must_revalidate();
assert_eq!(
config.to_header_value().as_deref(),
Some("no-cache, must-revalidate")
);
}
#[test]
fn test_cache_control_immutable_long_max_age() {
let config = CacheControlConfig::new()
.with_public()
.with_max_age(31536000)
.with_immutable();
assert_eq!(
config.to_header_value().as_deref(),
Some("public, max-age=31536000, immutable")
);
}
#[test]
fn test_cache_control_full_directive_order() {
let config = CacheControlConfig::new()
.with_no_store()
.with_no_cache()
.with_public()
.with_max_age(60)
.with_must_revalidate()
.with_immutable();
assert_eq!(
config.to_header_value().as_deref(),
Some("no-store, no-cache, public, max-age=60, must-revalidate, immutable")
);
}
#[test]
fn test_compute_etag_format() {
let dir = create_test_dir();
let file_path = dir.path().join("style.css");
let metadata = std::fs::metadata(&file_path).unwrap();
let etag = compute_etag(&metadata).expect("ETag should be computed");
assert!(
etag.starts_with("W/\"") && etag.ends_with('"'),
"ETag should be weak format W/\"...\", got: {etag}"
);
let inner = &etag[3..etag.len() - 1];
let parts: Vec<&str> = inner.splitn(2, '-').collect();
assert_eq!(parts.len(), 2, "ETag inner should be <mtime>-<size>");
assert!(
parts[0].chars().all(|c| c.is_ascii_digit()),
"mtime should be numeric"
);
assert!(
parts[1].chars().all(|c| c.is_ascii_digit()),
"size should be numeric"
);
}
#[test]
fn test_compute_etag_size_in_header() {
let dir = create_test_dir();
let file_path = dir.path().join("data.bin");
fs::write(&file_path, b"0123456789ABCDEFGHIJ").unwrap(); let metadata = std::fs::metadata(&file_path).unwrap();
let etag = compute_etag(&metadata).unwrap();
assert!(
etag.contains("-20\""),
"ETag should contain file size 20, got: {etag}"
);
}
#[test]
fn test_compute_etag_different_sizes_differ() {
let dir = create_test_dir();
let small_path = dir.path().join("small.bin");
let large_path = dir.path().join("large.bin");
fs::write(&small_path, b"short").unwrap();
fs::write(&large_path, b"this is a much longer file content").unwrap();
let small_etag = compute_etag(&std::fs::metadata(&small_path).unwrap()).unwrap();
let large_etag = compute_etag(&std::fs::metadata(&large_path).unwrap()).unwrap();
assert_ne!(
small_etag, large_etag,
"Different file sizes should produce different ETags"
);
}
#[test]
fn test_fingerprint_bytes_empty() {
let hash = fingerprint_bytes(b"");
assert_eq!(hash, "d41d8cd98f00b204e9800998ecf8427e");
assert_eq!(hash.len(), 32, "MD5 hash should be 32 hex chars");
}
#[test]
fn test_fingerprint_bytes_hello() {
let hash = fingerprint_bytes(b"hello");
assert_eq!(hash, "5d41402abc4b2a76b9719d911017c592");
}
#[test]
fn test_fingerprint_bytes_known_php_value() {
let hash = fingerprint_bytes(b"The quick brown fox jumps over the lazy dog");
assert_eq!(hash, "9e107d9d372bb6826bd81d3542a419d6");
}
#[test]
fn test_fingerprint_bytes_lowercase_hex() {
let hash = fingerprint_bytes(b"test");
assert!(
hash.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"MD5 hash should be lowercase hex: {hash}"
);
}
#[test]
fn test_fingerprint_file_reads_content() {
let dir = create_test_dir();
let file_path = dir.path().join("content.txt");
fs::write(&file_path, b"hello").unwrap();
let file_hash = fingerprint_file(&file_path).unwrap();
let bytes_hash = fingerprint_bytes(b"hello");
assert_eq!(file_hash, bytes_hash);
assert_eq!(file_hash, "5d41402abc4b2a76b9719d911017c592");
}
#[test]
fn test_fingerprint_file_missing_returns_err() {
let dir = create_test_dir();
let missing = dir.path().join("nonexistent.txt");
let result = fingerprint_file(&missing);
assert!(result.is_err(), "Missing file should return Err");
}
#[test]
fn test_extract_version_hash_valid() {
let result = extract_version_hash("style.abc123def456.css");
assert_eq!(
result,
Some(("style.css".to_string(), "abc123def456".to_string()))
);
}
#[test]
fn test_extract_version_hash_path_with_dir() {
let result = extract_version_hash("js/app.abc123def456.js");
assert_eq!(
result,
Some(("js/app.js".to_string(), "abc123def456".to_string()))
);
}
#[test]
fn test_extract_version_hash_multi_dot_stem() {
let result = extract_version_hash("foo.bar.abc123def456.css");
assert_eq!(
result,
Some(("foo.bar.css".to_string(), "abc123def456".to_string()))
);
}
#[test]
fn test_extract_version_hash_min_8_chars() {
let result = extract_version_hash("style.abc12345.css");
assert_eq!(
result,
Some(("style.css".to_string(), "abc12345".to_string()))
);
}
#[test]
fn test_extract_version_hash_no_hash() {
let result = extract_version_hash("style.css");
assert_eq!(result, None);
}
#[test]
fn test_extract_version_hash_short_hash() {
let result = extract_version_hash("style.abc123.css");
assert_eq!(result, None);
}
#[test]
fn test_extract_version_hash_non_hex() {
let result = extract_version_hash("style.xyzghijk.css");
assert_eq!(result, None);
}
#[test]
fn test_extract_version_hash_uppercase_hex() {
let result = extract_version_hash("style.ABCDEF12.css");
assert_eq!(
result,
Some(("style.css".to_string(), "ABCDEF12".to_string()))
);
}
#[test]
fn test_extract_version_hash_no_extension() {
let result = extract_version_hash("noextension");
assert_eq!(result, None);
}
#[test]
fn test_extract_version_hash_empty_extension() {
let result = extract_version_hash("style.abc123def456.");
assert_eq!(result, None);
}
#[tokio::test]
async fn test_serve_file_with_cache_200_no_config() {
let dir = create_test_dir();
let file_path = dir.path().join("style.css");
let headers = axum::http::HeaderMap::new();
let resp = serve_file_with_cache(&file_path, &headers, None);
assert_eq!(resp.status(), StatusCode::OK);
let cc = resp.headers().get("cache-control");
assert!(
cc.is_none(),
"Cache-Control should not be set without config"
);
let etag = resp.headers().get("etag");
assert!(etag.is_some(), "ETag should be set");
}
#[tokio::test]
async fn test_serve_file_with_cache_200_with_cache_control() {
let dir = create_test_dir();
let file_path = dir.path().join("style.css");
let headers = axum::http::HeaderMap::new();
let config = CacheControlConfig::new().with_public().with_max_age(3600);
let resp = serve_file_with_cache(&file_path, &headers, Some(&config));
assert_eq!(resp.status(), StatusCode::OK);
let cc = resp
.headers()
.get("cache-control")
.unwrap()
.to_str()
.unwrap();
assert_eq!(cc, "public, max-age=3600");
}
#[tokio::test]
async fn test_serve_file_with_cache_etag_header_set() {
let dir = create_test_dir();
let file_path = dir.path().join("style.css");
let headers = axum::http::HeaderMap::new();
let resp = serve_file_with_cache(&file_path, &headers, None);
assert_eq!(resp.status(), StatusCode::OK);
let etag = resp.headers().get("etag").unwrap().to_str().unwrap();
assert!(
etag.starts_with("W/\""),
"ETag should be weak format, got: {etag}"
);
}
#[tokio::test]
async fn test_serve_file_with_cache_304_if_none_match_match() {
let dir = create_test_dir();
let file_path = dir.path().join("style.css");
let headers1 = axum::http::HeaderMap::new();
let resp1 = serve_file_with_cache(&file_path, &headers1, None);
let etag = resp1
.headers()
.get("etag")
.unwrap()
.to_str()
.unwrap()
.to_string();
let mut headers2 = axum::http::HeaderMap::new();
headers2.insert(
axum::http::header::IF_NONE_MATCH,
axum::http::HeaderValue::from_str(&etag).unwrap(),
);
let resp2 = serve_file_with_cache(&file_path, &headers2, None);
assert_eq!(resp2.status(), StatusCode::NOT_MODIFIED);
let resp2_etag = resp2.headers().get("etag").unwrap().to_str().unwrap();
assert_eq!(resp2_etag, etag);
assert!(
resp2.headers().get("last-modified").is_some(),
"304 should include Last-Modified"
);
let bytes = resp2.into_body().collect().await.unwrap().to_bytes();
assert!(bytes.is_empty(), "304 body should be empty");
}
#[tokio::test]
async fn test_serve_file_with_cache_304_if_none_match_star() {
let dir = create_test_dir();
let file_path = dir.path().join("style.css");
let mut headers = axum::http::HeaderMap::new();
headers.insert(
axum::http::header::IF_NONE_MATCH,
axum::http::HeaderValue::from_static("*"),
);
let resp = serve_file_with_cache(&file_path, &headers, None);
assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
}
#[tokio::test]
async fn test_serve_file_with_cache_200_if_none_match_mismatch() {
let dir = create_test_dir();
let file_path = dir.path().join("style.css");
let mut headers = axum::http::HeaderMap::new();
headers.insert(
axum::http::header::IF_NONE_MATCH,
axum::http::HeaderValue::from_static("W/\"0-0\""),
);
let resp = serve_file_with_cache(&file_path, &headers, None);
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_serve_file_with_cache_304_includes_cache_control() {
let dir = create_test_dir();
let file_path = dir.path().join("style.css");
let headers1 = axum::http::HeaderMap::new();
let config = CacheControlConfig::new().with_public().with_max_age(3600);
let resp1 = serve_file_with_cache(&file_path, &headers1, Some(&config));
let etag = resp1
.headers()
.get("etag")
.unwrap()
.to_str()
.unwrap()
.to_string();
let mut headers2 = axum::http::HeaderMap::new();
headers2.insert(
axum::http::header::IF_NONE_MATCH,
axum::http::HeaderValue::from_str(&etag).unwrap(),
);
let resp2 = serve_file_with_cache(&file_path, &headers2, Some(&config));
assert_eq!(resp2.status(), StatusCode::NOT_MODIFIED);
let cc = resp2
.headers()
.get("cache-control")
.expect("304 should include Cache-Control")
.to_str()
.unwrap();
assert_eq!(cc, "public, max-age=3600");
}
#[tokio::test]
async fn test_serve_file_with_cache_304_if_modified_since_match() {
let dir = create_test_dir();
let file_path = dir.path().join("style.css");
let headers1 = axum::http::HeaderMap::new();
let resp1 = serve_file_with_cache(&file_path, &headers1, None);
let last_modified = resp1
.headers()
.get("last-modified")
.unwrap()
.to_str()
.unwrap()
.to_string();
let mut headers2 = axum::http::HeaderMap::new();
headers2.insert(
axum::http::header::IF_MODIFIED_SINCE,
axum::http::HeaderValue::from_str(&last_modified).unwrap(),
);
let resp2 = serve_file_with_cache(&file_path, &headers2, None);
assert_eq!(resp2.status(), StatusCode::NOT_MODIFIED);
}
#[tokio::test]
async fn test_serve_file_with_cache_if_none_match_takes_priority() {
let dir = create_test_dir();
let file_path = dir.path().join("style.css");
let headers1 = axum::http::HeaderMap::new();
let resp1 = serve_file_with_cache(&file_path, &headers1, None);
let last_modified = resp1
.headers()
.get("last-modified")
.unwrap()
.to_str()
.unwrap()
.to_string();
let mut headers2 = axum::http::HeaderMap::new();
headers2.insert(
axum::http::header::IF_NONE_MATCH,
axum::http::HeaderValue::from_static("W/\"0-0\""),
);
headers2.insert(
axum::http::header::IF_MODIFIED_SINCE,
axum::http::HeaderValue::from_str(&last_modified).unwrap(),
);
let resp2 = serve_file_with_cache(&file_path, &headers2, None);
assert_eq!(
resp2.status(),
StatusCode::OK,
"If-None-Match should take priority over If-Modified-Since"
);
}
#[tokio::test]
async fn test_serve_file_with_cache_404() {
let dir = create_test_dir();
let file_path = dir.path().join("nonexistent.txt");
let headers = axum::http::HeaderMap::new();
let resp = serve_file_with_cache(&file_path, &headers, None);
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_serve_file_with_cache_range_206_includes_etag() {
let dir = create_test_dir();
let file_path = dir.path().join("data.bin");
fs::write(&file_path, b"0123456789ABCDEFGHIJ").unwrap();
let mut headers = axum::http::HeaderMap::new();
headers.insert(
axum::http::header::RANGE,
axum::http::HeaderValue::from_static("bytes=5-9"),
);
let config = CacheControlConfig::new().with_max_age(3600);
let resp = serve_file_with_cache(&file_path, &headers, Some(&config));
assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
let cr = resp
.headers()
.get("content-range")
.unwrap()
.to_str()
.unwrap();
assert_eq!(cr, "bytes 5-9/20");
assert!(
resp.headers().get("etag").is_some(),
"206 should include ETag"
);
let cc = resp
.headers()
.get("cache-control")
.unwrap()
.to_str()
.unwrap();
assert_eq!(cc, "max-age=3600");
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&bytes[..], b"56789");
}
#[tokio::test]
async fn test_serve_file_with_cache_range_416() {
let dir = create_test_dir();
let file_path = dir.path().join("data.bin");
fs::write(&file_path, b"0123456789").unwrap();
let mut headers = axum::http::HeaderMap::new();
headers.insert(
axum::http::header::RANGE,
axum::http::HeaderValue::from_static("bytes=100-200"),
);
let resp = serve_file_with_cache(&file_path, &headers, None);
assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
let cr = resp
.headers()
.get("content-range")
.unwrap()
.to_str()
.unwrap();
assert_eq!(cr, "bytes */10");
}
#[tokio::test]
async fn test_serve_file_with_cache_unknown_mime_content_disposition() {
let dir = create_test_dir();
let file_path = dir.path().join("unknown.xyzunknown");
fs::write(&file_path, b"unknown content").unwrap();
let headers = axum::http::HeaderMap::new();
let resp = serve_file_with_cache(&file_path, &headers, None);
assert_eq!(resp.status(), StatusCode::OK);
let cd = resp
.headers()
.get("content-disposition")
.expect("Content-Disposition should be set for unknown MIME");
let cd_str = cd.to_str().unwrap();
assert!(
cd_str.contains("unknown.xyzunknown"),
"Content-Disposition should contain filename, got: {cd_str}"
);
}
#[test]
fn test_r5_php_no_etag_but_rust_extends_with_etag() {
let dir = create_test_dir();
let file_path = dir.path().join("style.css");
let metadata = std::fs::metadata(&file_path).unwrap();
let etag = compute_etag(&metadata);
assert!(etag.is_some(), "Rust should generate ETag (PHP doesn't)");
let etag_str = etag.unwrap();
assert!(
etag_str.starts_with("W/\"") && etag_str.ends_with('"'),
"ETag should be nginx weak format"
);
}
#[test]
fn test_r5_php_md5_alignment() {
let rust_hash = fingerprint_bytes(b"hello");
let php_hash = "5d41402abc4b2a76b9719d911017c592";
assert_eq!(rust_hash, php_hash, "Rust MD5 should match PHP md5()");
}
#[test]
fn test_r5_nginx_etag_format_alignment() {
let dir = create_test_dir();
let file_path = dir.path().join("data.bin");
fs::write(&file_path, b"0123456789ABCDEFGHIJ").unwrap(); let metadata = std::fs::metadata(&file_path).unwrap();
let mtime = metadata
.modified()
.unwrap()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let size = metadata.len();
let expected_etag = format!("W/\"{}-{}\"", mtime, size);
let actual_etag = compute_etag(&metadata).unwrap();
assert_eq!(
actual_etag, expected_etag,
"Rust ETag should match nginx format exactly"
);
}
}