Skip to main content

churust_core/
fs.rs

1//! Static file serving (feature `fs`).
2//!
3//! Register [`StaticFiles`] on a wildcard route to stream files from a
4//! directory:
5//!
6//! ```no_run
7//! use churust_core::{Churust, fs::StaticFiles};
8//!
9//! # fn build() {
10//! Churust::server().routing(|r| {
11//!     r.get("/assets/{path...}", StaticFiles::dir("./public").index("index.html").handler());
12//! });
13//! # }
14//! ```
15
16use 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/// Serves files from a directory. Build with [`StaticFiles::dir`], then mount
26/// its [`handler`](StaticFiles::handler) on a `{path...}` wildcard route.
27#[derive(Debug, Clone)]
28pub struct StaticFiles {
29    root: PathBuf,
30    index: Option<String>,
31}
32
33impl StaticFiles {
34    /// Serve files rooted at `root`.
35    pub fn dir(root: impl Into<PathBuf>) -> Self {
36        Self {
37            root: root.into(),
38            index: None,
39        }
40    }
41
42    /// Serve `<dir>/<index>` when a request resolves to a directory.
43    pub fn index(mut self, file: impl Into<String>) -> Self {
44        self.index = Some(file.into());
45        self
46    }
47
48    /// Turn this into a [`Handler`] for a wildcard route. The handler reads the
49    /// route's single captured path parameter as the relative path.
50    pub fn handler(self) -> impl Handler {
51        let cfg = self;
52        // Closures implement `Handler` indirectly through `HandlerFn` +
53        // `IntoHandler` (see the implementation note in `handler.rs`); adapt the
54        // closure into a concrete `Handler` here so the returned `impl Handler`
55        // is satisfied.
56        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        // The relative path is the route's (single) captured param value.
65        let rel = call
66            .params_iter()
67            .map(|(_, v)| v.to_string())
68            .next()
69            .unwrap_or_default();
70
71        // Refuse an encoded separator before anything interprets the path.
72        //
73        // Path parameters arrive percent-decoded, so `%2F` would otherwise
74        // reappear as a real separator once the wildcard's segments are
75        // rejoined, and `%5C` is a separator on Windows. `sanitize` still
76        // rejects the `..` that makes traversal work, so this is not the thing
77        // standing between a request and the filesystem — it is what makes the
78        // rejoined value unambiguous by construction, so the safety argument
79        // does not depend on reasoning about how segments were rejoined.
80        //
81        // Deliberately stricter than necessary: a file whose name contains a
82        // literal encoded slash is not servable. That is an accepted
83        // limitation, and 404 rather than 400 so the response does not reveal
84        // whether the path would have resolved.
85        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        // Symlink-escape guard: the canonical path must stay within root.
104        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
125/// Reject path traversal: no `..`, no root/prefix components. Returns a relative
126/// `PathBuf` of plain normal components, or `None` if unsafe.
127fn 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            // `..`, `/`, `C:\`, etc. are all rejected.
134            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
135        }
136    }
137    Some(out)
138}
139
140/// Stream a file in 64 KiB chunks (no `tokio-util` dependency).
141fn 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
160/// Built-in extension → `Content-Type` map (not exhaustive). Falls back to
161/// `application/octet-stream`.
162fn 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        // Path parameters now arrive decoded, so `..` reaches sanitize as a
202        // literal parent component rather than as `%2e%2e`.
203        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        // Unique-enough dir under the OS temp dir (no extra deps).
212        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        // sub/ has no index.html, so 404; root resolves index on "" path:
271        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}