1use crate::body::Body;
17use crate::call::Call;
18use crate::error::{Error, Result};
19use crate::handler::{Handler, IntoHandler};
20use crate::response::Response;
21use bytes::Bytes;
22use std::path::{Component, Path, PathBuf};
23use tokio::io::AsyncReadExt;
24
25#[derive(Debug, Clone)]
28pub struct StaticFiles {
29 root: PathBuf,
30 index: Option<String>,
31}
32
33impl StaticFiles {
34 pub fn dir(root: impl Into<PathBuf>) -> Self {
36 Self {
37 root: root.into(),
38 index: None,
39 }
40 }
41
42 pub fn index(mut self, file: impl Into<String>) -> Self {
44 self.index = Some(file.into());
45 self
46 }
47
48 pub fn handler(self) -> impl Handler {
51 let cfg = self;
52 let closure = move |call: Call| {
57 let cfg = cfg.clone();
58 async move { cfg.serve(&call).await }
59 };
60 closure.into_handler()
61 }
62
63 async fn serve(&self, call: &Call) -> Result<Response> {
64 let rel = call
66 .params_iter()
67 .map(|(_, v)| v.to_string())
68 .next()
69 .unwrap_or_default();
70
71 let raw = call.path().to_ascii_lowercase();
86 if raw.contains("%2f") || raw.contains("%5c") {
87 return Err(Error::not_found("not found"));
88 }
89
90 let safe = sanitize(&rel).ok_or_else(|| Error::not_found("not found"))?;
91 let mut path = self.root.join(safe);
92
93 let meta = tokio::fs::metadata(&path)
94 .await
95 .map_err(|_| Error::not_found("not found"))?;
96 if meta.is_dir() {
97 match &self.index {
98 Some(index) => path = path.join(index),
99 None => return Err(Error::not_found("not found")),
100 }
101 }
102
103 let canonical = tokio::fs::canonicalize(&path)
105 .await
106 .map_err(|_| Error::not_found("not found"))?;
107 let canonical_root = tokio::fs::canonicalize(&self.root)
108 .await
109 .map_err(|_| Error::internal("static root does not exist"))?;
110 if !canonical.starts_with(&canonical_root) {
111 return Err(Error::not_found("not found"));
112 }
113
114 let file = tokio::fs::File::open(&canonical)
115 .await
116 .map_err(|_| Error::not_found("not found"))?;
117 let content_type = content_type_for(&canonical);
118 Ok(Response::stream(
119 content_type,
120 Body::from_stream(file_stream(file)),
121 ))
122 }
123}
124
125fn sanitize(rel: &str) -> Option<PathBuf> {
128 let mut out = PathBuf::new();
129 for component in Path::new(rel).components() {
130 match component {
131 Component::Normal(c) => out.push(c),
132 Component::CurDir => {}
133 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
135 }
136 }
137 Some(out)
138}
139
140fn file_stream(
142 file: tokio::fs::File,
143) -> impl futures_util::stream::Stream<Item = std::result::Result<Bytes, std::io::Error>> {
144 futures_util::stream::unfold((file, false), |(mut file, done)| async move {
145 if done {
146 return None;
147 }
148 let mut buf = vec![0u8; 64 * 1024];
149 match file.read(&mut buf).await {
150 Ok(0) => None,
151 Ok(n) => {
152 buf.truncate(n);
153 Some((Ok(Bytes::from(buf)), (file, false)))
154 }
155 Err(e) => Some((Err(e), (file, true))),
156 }
157 })
158}
159
160fn content_type_for(path: &Path) -> &'static str {
163 let ext = path
164 .extension()
165 .and_then(|e| e.to_str())
166 .unwrap_or("")
167 .to_ascii_lowercase();
168 match ext.as_str() {
169 "html" | "htm" => "text/html; charset=utf-8",
170 "css" => "text/css; charset=utf-8",
171 "js" | "mjs" => "text/javascript; charset=utf-8",
172 "json" => "application/json",
173 "wasm" => "application/wasm",
174 "txt" => "text/plain; charset=utf-8",
175 "csv" => "text/csv; charset=utf-8",
176 "xml" => "application/xml",
177 "svg" => "image/svg+xml",
178 "png" => "image/png",
179 "jpg" | "jpeg" => "image/jpeg",
180 "gif" => "image/gif",
181 "webp" => "image/webp",
182 "ico" => "image/x-icon",
183 "woff" => "font/woff",
184 "woff2" => "font/woff2",
185 "ttf" => "font/ttf",
186 "pdf" => "application/pdf",
187 "mp4" => "video/mp4",
188 "mp3" => "audio/mpeg",
189 _ => "application/octet-stream",
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use crate::{Churust, TestClient};
197 use http::StatusCode;
198
199 #[test]
200 fn sanitize_rejects_decoded_parent_segments() {
201 assert!(sanitize("../secret").is_none());
204 assert!(sanitize("a/../../secret").is_none());
205 assert!(sanitize("/etc/passwd").is_none(), "absolute paths rejected");
206 assert!(sanitize("ok/file.txt").is_some());
207 assert!(sanitize("./ok.txt").is_some(), "a bare `.` is harmless");
208 }
209
210 fn temp_dir_with_files() -> PathBuf {
211 let base = std::env::temp_dir().join(format!("churust-fs-{}", std::process::id()));
213 let _ = std::fs::create_dir_all(base.join("sub"));
214 std::fs::write(base.join("hello.txt"), b"hello world").unwrap();
215 std::fs::write(base.join("page.html"), b"<h1>hi</h1>").unwrap();
216 std::fs::write(base.join("index.html"), b"INDEX").unwrap();
217 base
218 }
219
220 fn app(root: PathBuf) -> crate::App {
221 Churust::server()
222 .routing(move |r| {
223 r.get(
224 "/files/{path...}",
225 StaticFiles::dir(root.clone()).index("index.html").handler(),
226 );
227 })
228 .build()
229 }
230
231 #[tokio::test]
232 async fn serves_file_with_content_type() {
233 let root = temp_dir_with_files();
234 let res = TestClient::new(app(root))
235 .get("/files/hello.txt")
236 .send()
237 .await;
238 assert_eq!(res.status(), StatusCode::OK);
239 assert_eq!(
240 res.header("content-type"),
241 Some("text/plain; charset=utf-8")
242 );
243 assert_eq!(res.text(), "hello world");
244 }
245
246 #[tokio::test]
247 async fn html_content_type() {
248 let root = temp_dir_with_files();
249 let res = TestClient::new(app(root))
250 .get("/files/page.html")
251 .send()
252 .await;
253 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
254 }
255
256 #[tokio::test]
257 async fn missing_file_is_404() {
258 let root = temp_dir_with_files();
259 let res = TestClient::new(app(root))
260 .get("/files/nope.txt")
261 .send()
262 .await;
263 assert_eq!(res.status(), StatusCode::NOT_FOUND);
264 }
265
266 #[tokio::test]
267 async fn directory_serves_index() {
268 let root = temp_dir_with_files();
269 let res = TestClient::new(app(root)).get("/files/sub").send().await;
270 assert_eq!(res.status(), StatusCode::NOT_FOUND);
272 }
273
274 #[test]
275 fn sanitize_rejects_traversal() {
276 assert!(sanitize("../etc/passwd").is_none());
277 assert!(sanitize("/etc/passwd").is_none());
278 assert!(sanitize("a/../../b").is_none());
279 assert_eq!(sanitize("css/app.css"), Some(PathBuf::from("css/app.css")));
280 assert_eq!(sanitize("./x.txt"), Some(PathBuf::from("x.txt")));
281 }
282}