1use crate::handler::{BoxFuture, Handler};
4use crate::request::Request;
5use crate::response::Response;
6use crate::status::Status;
7use crate::url;
8use std::path::{Path, PathBuf};
9
10pub struct Files {
12 root: PathBuf,
13 index: Option<String>,
15}
16
17impl Files {
18 pub fn new(root: impl Into<PathBuf>) -> Self {
19 Files { root: root.into(), index: Some("index.html".to_string()) }
20 }
21
22 pub fn without_index(mut self) -> Self {
23 self.index = None;
24 self
25 }
26
27 fn resolve(&self, path: &str) -> Option<PathBuf> {
28 let normalized = url::normalize_path(&url::decode(path))?;
29 let mut candidate = self.root.join(normalized.trim_start_matches('/'));
30
31 if candidate.is_dir() {
32 candidate = candidate.join(self.index.as_deref()?);
33 }
34
35 let real_root = self.root.canonicalize().ok()?;
38 let real = candidate.canonicalize().ok()?;
39 real.starts_with(&real_root).then_some(real)
40 }
41}
42
43impl Handler for Files {
44 fn call(&self, request: Request) -> BoxFuture<Response> {
45 let resolved = self.resolve(request.path());
46 Box::pin(async move {
47 let Some(path) = resolved else {
48 return Response::not_found();
49 };
50 match tokio::fs::read(&path).await {
51 Ok(bytes) => Response::ok()
52 .with_header("content-type", content_type(&path))
53 .with_header("cache-control", "public, max-age=3600")
54 .with_body(bytes),
55 Err(_) => Response::new(Status::NOT_FOUND).with_text("Not Found"),
56 }
57 })
58 }
59}
60
61pub fn content_type(path: &Path) -> &'static str {
63 match path.extension().and_then(|e| e.to_str()).unwrap_or_default() {
64 "html" | "htm" => "text/html; charset=utf-8",
65 "css" => "text/css; charset=utf-8",
66 "js" | "mjs" => "text/javascript; charset=utf-8",
67 "json" => "application/json",
68 "svg" => "image/svg+xml",
69 "png" => "image/png",
70 "jpg" | "jpeg" => "image/jpeg",
71 "gif" => "image/gif",
72 "webp" => "image/webp",
73 "avif" => "image/avif",
74 "ico" => "image/x-icon",
75 "woff2" => "font/woff2",
76 "woff" => "font/woff",
77 "ttf" => "font/ttf",
78 "pdf" => "application/pdf",
79 "txt" | "md" => "text/plain; charset=utf-8",
80 "wasm" => "application/wasm",
81 "xml" => "application/xml",
82 _ => "application/octet-stream",
83 }
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89 use crate::method::Method;
90
91 fn fixture_dir(name: &str) -> PathBuf {
94 let dir = std::env::temp_dir().join(format!("rustlavel-files-{name}"));
95 std::fs::create_dir_all(dir.join("css")).unwrap();
96 std::fs::write(dir.join("index.html"), "<h1>home</h1>").unwrap();
97 std::fs::write(dir.join("css/app.css"), "body{}").unwrap();
98 dir
99 }
100
101 #[tokio::test]
102 async fn serves_a_file_with_its_content_type() {
103 let files = Files::new(fixture_dir("content-type"));
104 let response = files.call(Request::new(Method::Get, "/css/app.css")).await;
105
106 assert_eq!(response.status, Status::OK);
107 assert_eq!(response.body_string(), "body{}");
108 assert_eq!(response.headers.content_type(), Some("text/css"));
109 }
110
111 #[tokio::test]
112 async fn serves_the_index_for_a_directory() {
113 let files = Files::new(fixture_dir("index"));
114 let response = files.call(Request::new(Method::Get, "/")).await;
115
116 assert_eq!(response.body_string(), "<h1>home</h1>");
117 }
118
119 #[tokio::test]
120 async fn refuses_to_escape_the_root() {
121 let files = Files::new(fixture_dir("traversal"));
122
123 for attempt in ["/../../../etc/passwd", "/css/../../etc/passwd", "/%2e%2e/%2e%2e/etc/passwd"] {
124 let response = files.call(Request::new(Method::Get, attempt)).await;
125 assert_eq!(response.status, Status::NOT_FOUND, "{attempt} should not resolve");
126 }
127 }
128}