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) => {
52                    let mut response = Response::ok()
53                        .with_header("content-type", content_type(&path))
54                        .with_header("cache-control", "public, max-age=3600");
55                    // The modification time is what lets a browser's
56                    // `If-Modified-Since` be answered with a 304 by the ETag
57                    // middleware, instead of the file being sent again.
58                    if let Some(modified) = modified_at(&path).await {
59                        response.headers.set("last-modified", crate::date::http_date(modified));
60                    }
61                    response.with_body(bytes)
62                }
63                Err(_) => Response::new(Status::NOT_FOUND).with_text("Not Found"),
64            }
65        })
66    }
67}
68
69/// The file's modification time as a unix timestamp, when the filesystem has one.
70async fn modified_at(path: &Path) -> Option<i64> {
71    let modified = tokio::fs::metadata(path).await.ok()?.modified().ok()?;
72    let since_epoch = modified.duration_since(std::time::UNIX_EPOCH).ok()?;
73    i64::try_from(since_epoch.as_secs()).ok()
74}
75
76/// Guess a content type from the file extension.
77pub fn content_type(path: &Path) -> &'static str {
78    match path.extension().and_then(|e| e.to_str()).unwrap_or_default() {
79        "html" | "htm" => "text/html; charset=utf-8",
80        "css" => "text/css; charset=utf-8",
81        "js" | "mjs" => "text/javascript; charset=utf-8",
82        "json" => "application/json",
83        "svg" => "image/svg+xml",
84        "png" => "image/png",
85        "jpg" | "jpeg" => "image/jpeg",
86        "gif" => "image/gif",
87        "webp" => "image/webp",
88        "avif" => "image/avif",
89        "ico" => "image/x-icon",
90        "woff2" => "font/woff2",
91        "woff" => "font/woff",
92        "ttf" => "font/ttf",
93        "pdf" => "application/pdf",
94        "txt" | "md" => "text/plain; charset=utf-8",
95        "wasm" => "application/wasm",
96        "xml" => "application/xml",
97        _ => "application/octet-stream",
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::method::Method;
105
106    /// Each test gets its own directory: tests run concurrently, and a shared
107    /// fixture would be re-written underneath a test that is reading it.
108    fn fixture_dir(name: &str) -> PathBuf {
109        let dir = std::env::temp_dir().join(format!("rustlavel-files-{name}"));
110        std::fs::create_dir_all(dir.join("css")).unwrap();
111        std::fs::write(dir.join("index.html"), "<h1>home</h1>").unwrap();
112        std::fs::write(dir.join("css/app.css"), "body{}").unwrap();
113        dir
114    }
115
116    #[tokio::test]
117    async fn serves_a_file_with_its_content_type() {
118        let files = Files::new(fixture_dir("content-type"));
119        let response = files.call(Request::new(Method::Get, "/css/app.css")).await;
120
121        assert_eq!(response.status, Status::OK);
122        assert_eq!(response.body_string(), "body{}");
123        assert_eq!(response.headers.content_type(), Some("text/css"));
124    }
125
126    #[tokio::test]
127    async fn serves_the_index_for_a_directory() {
128        let files = Files::new(fixture_dir("index"));
129        let response = files.call(Request::new(Method::Get, "/")).await;
130
131        assert_eq!(response.body_string(), "<h1>home</h1>");
132    }
133
134    #[tokio::test]
135    async fn refuses_to_escape_the_root() {
136        let files = Files::new(fixture_dir("traversal"));
137
138        for attempt in ["/../../../etc/passwd", "/css/../../etc/passwd", "/%2e%2e/%2e%2e/etc/passwd"] {
139            let response = files.call(Request::new(Method::Get, attempt)).await;
140            assert_eq!(response.status, Status::NOT_FOUND, "{attempt} should not resolve");
141        }
142    }
143}