Skip to main content

rustlavel_http/
files.rs

1//! Serving static files out of a directory, for `public/`.
2
3use 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
10/// Serves files from a directory, refusing anything that escapes it.
11pub struct Files {
12    root: PathBuf,
13    /// Served when the request maps to a directory.
14    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        // Resolving symlinks last is what actually proves the file is inside
36        // the root — normalization alone cannot see through a link.
37        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
61/// Guess a content type from the file extension.
62pub 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    /// Each test gets its own directory: tests run concurrently, and a shared
92    /// fixture would be re-written underneath a test that is reading it.
93    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}