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        let safe = sanitize(&rel).ok_or_else(|| Error::not_found("not found"))?;
72        let mut path = self.root.join(safe);
73
74        let meta = tokio::fs::metadata(&path)
75            .await
76            .map_err(|_| Error::not_found("not found"))?;
77        if meta.is_dir() {
78            match &self.index {
79                Some(index) => path = path.join(index),
80                None => return Err(Error::not_found("not found")),
81            }
82        }
83
84        // Symlink-escape guard: the canonical path must stay within root.
85        let canonical = tokio::fs::canonicalize(&path)
86            .await
87            .map_err(|_| Error::not_found("not found"))?;
88        let canonical_root = tokio::fs::canonicalize(&self.root)
89            .await
90            .map_err(|_| Error::internal("static root does not exist"))?;
91        if !canonical.starts_with(&canonical_root) {
92            return Err(Error::not_found("not found"));
93        }
94
95        let file = tokio::fs::File::open(&canonical)
96            .await
97            .map_err(|_| Error::not_found("not found"))?;
98        let content_type = content_type_for(&canonical);
99        Ok(Response::stream(
100            content_type,
101            Body::from_stream(file_stream(file)),
102        ))
103    }
104}
105
106/// Reject path traversal: no `..`, no root/prefix components. Returns a relative
107/// `PathBuf` of plain normal components, or `None` if unsafe.
108fn sanitize(rel: &str) -> Option<PathBuf> {
109    let mut out = PathBuf::new();
110    for component in Path::new(rel).components() {
111        match component {
112            Component::Normal(c) => out.push(c),
113            Component::CurDir => {}
114            // `..`, `/`, `C:\`, etc. are all rejected.
115            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
116        }
117    }
118    Some(out)
119}
120
121/// Stream a file in 64 KiB chunks (no `tokio-util` dependency).
122fn file_stream(
123    file: tokio::fs::File,
124) -> impl futures_util::stream::Stream<Item = std::result::Result<Bytes, std::io::Error>> {
125    futures_util::stream::unfold((file, false), |(mut file, done)| async move {
126        if done {
127            return None;
128        }
129        let mut buf = vec![0u8; 64 * 1024];
130        match file.read(&mut buf).await {
131            Ok(0) => None,
132            Ok(n) => {
133                buf.truncate(n);
134                Some((Ok(Bytes::from(buf)), (file, false)))
135            }
136            Err(e) => Some((Err(e), (file, true))),
137        }
138    })
139}
140
141/// Built-in extension → `Content-Type` map (not exhaustive). Falls back to
142/// `application/octet-stream`.
143fn content_type_for(path: &Path) -> &'static str {
144    let ext = path
145        .extension()
146        .and_then(|e| e.to_str())
147        .unwrap_or("")
148        .to_ascii_lowercase();
149    match ext.as_str() {
150        "html" | "htm" => "text/html; charset=utf-8",
151        "css" => "text/css; charset=utf-8",
152        "js" | "mjs" => "text/javascript; charset=utf-8",
153        "json" => "application/json",
154        "wasm" => "application/wasm",
155        "txt" => "text/plain; charset=utf-8",
156        "csv" => "text/csv; charset=utf-8",
157        "xml" => "application/xml",
158        "svg" => "image/svg+xml",
159        "png" => "image/png",
160        "jpg" | "jpeg" => "image/jpeg",
161        "gif" => "image/gif",
162        "webp" => "image/webp",
163        "ico" => "image/x-icon",
164        "woff" => "font/woff",
165        "woff2" => "font/woff2",
166        "ttf" => "font/ttf",
167        "pdf" => "application/pdf",
168        "mp4" => "video/mp4",
169        "mp3" => "audio/mpeg",
170        _ => "application/octet-stream",
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::{Churust, TestClient};
178    use http::StatusCode;
179
180    fn temp_dir_with_files() -> PathBuf {
181        // Unique-enough dir under the OS temp dir (no extra deps).
182        let base = std::env::temp_dir().join(format!("churust-fs-{}", std::process::id()));
183        let _ = std::fs::create_dir_all(base.join("sub"));
184        std::fs::write(base.join("hello.txt"), b"hello world").unwrap();
185        std::fs::write(base.join("page.html"), b"<h1>hi</h1>").unwrap();
186        std::fs::write(base.join("index.html"), b"INDEX").unwrap();
187        base
188    }
189
190    fn app(root: PathBuf) -> crate::App {
191        Churust::server()
192            .routing(move |r| {
193                r.get(
194                    "/files/{path...}",
195                    StaticFiles::dir(root.clone()).index("index.html").handler(),
196                );
197            })
198            .build()
199    }
200
201    #[tokio::test]
202    async fn serves_file_with_content_type() {
203        let root = temp_dir_with_files();
204        let res = TestClient::new(app(root))
205            .get("/files/hello.txt")
206            .send()
207            .await;
208        assert_eq!(res.status(), StatusCode::OK);
209        assert_eq!(
210            res.header("content-type"),
211            Some("text/plain; charset=utf-8")
212        );
213        assert_eq!(res.text(), "hello world");
214    }
215
216    #[tokio::test]
217    async fn html_content_type() {
218        let root = temp_dir_with_files();
219        let res = TestClient::new(app(root))
220            .get("/files/page.html")
221            .send()
222            .await;
223        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
224    }
225
226    #[tokio::test]
227    async fn missing_file_is_404() {
228        let root = temp_dir_with_files();
229        let res = TestClient::new(app(root))
230            .get("/files/nope.txt")
231            .send()
232            .await;
233        assert_eq!(res.status(), StatusCode::NOT_FOUND);
234    }
235
236    #[tokio::test]
237    async fn directory_serves_index() {
238        let root = temp_dir_with_files();
239        let res = TestClient::new(app(root)).get("/files/sub").send().await;
240        // sub/ has no index.html, so 404; root resolves index on "" path:
241        assert_eq!(res.status(), StatusCode::NOT_FOUND);
242    }
243
244    #[test]
245    fn sanitize_rejects_traversal() {
246        assert!(sanitize("../etc/passwd").is_none());
247        assert!(sanitize("/etc/passwd").is_none());
248        assert!(sanitize("a/../../b").is_none());
249        assert_eq!(sanitize("css/app.css"), Some(PathBuf::from("css/app.css")));
250        assert_eq!(sanitize("./x.txt"), Some(PathBuf::from("x.txt")));
251    }
252}