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 http::header::{ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, ETAG, LAST_MODIFIED};
23use http::{HeaderValue, StatusCode};
24use std::path::{Component, Path, PathBuf};
25use tokio::io::AsyncReadExt;
26
27/// Serves files from a directory. Build with [`StaticFiles::dir`], then mount
28/// its [`handler`](StaticFiles::handler) on a `{path...}` wildcard route.
29#[derive(Debug, Clone)]
30pub struct StaticFiles {
31    root: PathBuf,
32    index: Option<String>,
33    list_directories: bool,
34}
35
36impl StaticFiles {
37    /// Serve files rooted at `root`.
38    pub fn dir(root: impl Into<PathBuf>) -> Self {
39        Self {
40            root: root.into(),
41            index: None,
42            list_directories: false,
43        }
44    }
45
46    /// List a directory's contents when it has no index file.
47    ///
48    /// Off by default, and deliberately so: a listing discloses every filename
49    /// under the served root, which is how backup files, editor swap files and
50    /// forgotten exports get found. Turn it on for a file mirror, not for an
51    /// application's assets.
52    pub fn list_directories(mut self, yes: bool) -> Self {
53        self.list_directories = yes;
54        self
55    }
56
57    /// Serve `<dir>/<index>` when a request resolves to a directory.
58    pub fn index(mut self, file: impl Into<String>) -> Self {
59        self.index = Some(file.into());
60        self
61    }
62
63    /// Turn this into a [`Handler`] for a wildcard route. The handler reads the
64    /// route's single captured path parameter as the relative path.
65    ///
66    /// # Panics
67    ///
68    /// Panics if the root does not exist or is not a directory. This is a
69    /// configuration mistake, and the alternative is worse: the previous
70    /// behaviour returned `500` on every request, forever, for a typo that
71    /// could have been caught at startup.
72    ///
73    /// Use [`try_handler`](StaticFiles::try_handler) to handle it yourself.
74    pub fn handler(self) -> impl Handler {
75        let root = self.root.clone();
76        self.try_handler().unwrap_or_else(|| {
77            panic!(
78                "static root {} does not exist or is not a directory",
79                root.display()
80            )
81        })
82    }
83
84    /// Like [`handler`](StaticFiles::handler) but returns `None` instead of
85    /// panicking when the root is unusable.
86    pub fn try_handler(self) -> Option<impl Handler> {
87        if !self.root.is_dir() {
88            return None;
89        }
90        Some(self.into_handler_unchecked())
91    }
92
93    fn into_handler_unchecked(self) -> impl Handler {
94        let cfg = self;
95        // Closures implement `Handler` indirectly through `HandlerFn` +
96        // `IntoHandler` (see the implementation note in `handler.rs`); adapt the
97        // closure into a concrete `Handler` here so the returned `impl Handler`
98        // is satisfied.
99        let closure = move |call: Call| {
100            let cfg = cfg.clone();
101            async move { cfg.serve(&call).await }
102        };
103        closure.into_handler()
104    }
105
106    async fn serve(&self, call: &Call) -> Result<Response> {
107        // The relative path is the route's *wildcard* capture, which is the
108        // last one: `Router::add` requires a `{name...}` to be the final
109        // segment, so nothing can be captured after it.
110        //
111        // This used to take the first capture. While captures were an unordered
112        // `HashMap` that was a coin flip; ordering them made it reliably wrong
113        // under a parameterised prefix — mounted at `/{tenant}/assets/{p...}`,
114        // the first capture is `tenant`, so `/acme/assets/a.txt` served
115        // `<root>/acme/...`. With an index file configured that is a `200`
116        // carrying the wrong file, which is worse than a miss.
117        let rel = call
118            .params()
119            .nth(call.params().len().saturating_sub(1))
120            .unwrap_or_default()
121            .to_string();
122
123        // Refuse an encoded separator before anything interprets the path.
124        //
125        // Path parameters arrive percent-decoded, so `%2F` would otherwise
126        // reappear as a real separator once the wildcard's segments are
127        // rejoined, and `%5C` is a separator on Windows. `sanitize` still
128        // rejects the `..` that makes traversal work, so this is not the thing
129        // standing between a request and the filesystem — it is what makes the
130        // rejoined value unambiguous by construction, so the safety argument
131        // does not depend on reasoning about how segments were rejoined.
132        //
133        // Deliberately stricter than necessary: a file whose name contains a
134        // literal encoded slash is not servable. That is an accepted
135        // limitation, and 404 rather than 400 so the response does not reveal
136        // whether the path would have resolved.
137        let raw = call.path().to_ascii_lowercase();
138        if raw.contains("%2f") || raw.contains("%5c") {
139            return Err(Error::not_found("not found"));
140        }
141
142        let safe = sanitize(&rel).ok_or_else(|| Error::not_found("not found"))?;
143        let mut path = self.root.join(safe);
144
145        let canonical_root = tokio::fs::canonicalize(&self.root)
146            .await
147            .map_err(|_| Error::internal("static root does not exist"))?;
148
149        // Symlink-escape guard, applied *before* anything decides what to do
150        // with the target. It used to sit after the directory dispatch, so a
151        // symlink to a directory outside the root reached the listing renderer
152        // unchecked and disclosed every filename under whatever it pointed at.
153        // `metadata` follows symlinks, so `is_dir()` was true and the listing
154        // arm returned before the guard ever ran.
155        let resolved = tokio::fs::canonicalize(&path)
156            .await
157            .map_err(|_| Error::not_found("not found"))?;
158        if !resolved.starts_with(&canonical_root) {
159            return Err(Error::not_found("not found"));
160        }
161        path = resolved;
162
163        let meta = tokio::fs::metadata(&path)
164            .await
165            .map_err(|_| Error::not_found("not found"))?;
166        if meta.is_dir() {
167            // Probe for the index file with the same async stat as every other
168            // lookup in this function. The guard used to be
169            // `path.join(index).is_file()`, and `Path::is_file` is a
170            // synchronous `stat(2)`: it runs on whichever runtime worker is
171            // polling this future rather than on the blocking pool, so for as
172            // long as the syscall takes, that worker polls nothing else. On
173            // local disk the parent's dentry was warmed by the `metadata` call
174            // just above and the cost is negligible; on a wedged NFS or SMB
175            // mount it is however long the mount takes to answer, and the
176            // connections that happen to be scheduled on that worker wait it
177            // out for a request they have nothing to do with. Everything else
178            // here already awaits `tokio::fs`, so this was the one call in the
179            // path that could block the reactor.
180            //
181            // Hoisted out of the match guard because a guard cannot await, and
182            // that also spares the second identical `join` the old form did on
183            // the hit.
184            let index_file = match &self.index {
185                Some(index) => {
186                    let candidate = path.join(index);
187                    // `unwrap_or(false)` reproduces `Path::is_file` exactly:
188                    // both follow symlinks, and both read any stat error — not
189                    // just "missing" — as "no index here", which falls through
190                    // to the listing or the 404 below.
191                    let is_file = tokio::fs::metadata(&candidate)
192                        .await
193                        .map(|m| m.is_file())
194                        .unwrap_or(false);
195                    is_file.then_some(candidate)
196                }
197                None => None,
198            };
199            match index_file {
200                Some(candidate) => path = candidate,
201                _ if self.list_directories => {
202                    // A directory has one URL, and it ends in `/`. The listing's
203                    // links are relative and bare (`href="a.txt"`), so a browser
204                    // resolves them against that slash; served at `/files` they
205                    // would resolve one level too high. Redirect rather than
206                    // render, so there is a single canonical URL per directory.
207                    if !call.path().ends_with('/') {
208                        let target = match call.uri().query() {
209                            Some(q) if !q.is_empty() => format!("{}/?{}", call.path(), q),
210                            _ => format!("{}/", call.path()),
211                        };
212                        return Ok(Response::new(http::StatusCode::PERMANENT_REDIRECT)
213                            .with_header(
214                                http::header::LOCATION,
215                                http::HeaderValue::from_str(&target)
216                                    .map_err(|_| Error::not_found("not found"))?,
217                            ));
218                    }
219                    return self.render_listing(&path).await;
220                }
221                _ => return Err(Error::not_found("not found")),
222            }
223        }
224
225        // Re-check after the index join: the index file may itself be a symlink
226        // pointing out of the root, and the guard above resolved the directory.
227        let canonical = tokio::fs::canonicalize(&path)
228            .await
229            .map_err(|_| Error::not_found("not found"))?;
230        if !canonical.starts_with(&canonical_root) {
231            return Err(Error::not_found("not found"));
232        }
233
234        // Stat the resolved file for the validators. The earlier `metadata`
235        // call answered "is this a directory"; this one is about the entity
236        // actually being served, which may be the index file instead.
237        let file_meta = tokio::fs::metadata(&canonical)
238            .await
239            .map_err(|_| Error::not_found("not found"))?;
240        let len = file_meta.len();
241        let modified = file_meta.modified().ok();
242
243        let etag = entity_tag(&file_meta);
244        let last_modified = modified.map(httpdate::fmt_http_date);
245        let content_type = content_type_for(&canonical);
246
247        // Precondition evaluation, RFC 9110 §13.2.2 order. If-Match and
248        // If-Unmodified-Since reject; If-None-Match and If-Modified-Since
249        // short-circuit to 304.
250        if let Some(if_match) = call.header("if-match") {
251            if !etag_matches(if_match, etag.as_deref()) {
252                return Ok(Response::new(StatusCode::PRECONDITION_FAILED));
253            }
254        } else if let Some(raw) = call.header("if-unmodified-since") {
255            if let (Some(since), Some(m)) = (parse_http_date(raw), modified) {
256                if is_modified_after(m, since) {
257                    return Ok(Response::new(StatusCode::PRECONDITION_FAILED));
258                }
259            }
260        }
261
262        // §13.1.3: when If-None-Match is present, If-Modified-Since is ignored
263        // entirely — not consulted as a tie-breaker.
264        let not_modified = if let Some(if_none_match) = call.header("if-none-match") {
265            etag_matches(if_none_match, etag.as_deref())
266        } else if let Some(raw) = call.header("if-modified-since") {
267            match (parse_http_date(raw), modified) {
268                (Some(since), Some(m)) => !is_modified_after(m, since),
269                _ => false,
270            }
271        } else {
272            false
273        };
274
275        if not_modified {
276            let mut res = Response::new(StatusCode::NOT_MODIFIED);
277            apply_validators(&mut res, etag.as_deref(), last_modified.as_deref());
278            return Ok(res);
279        }
280
281        // A Range only applies if If-Range agrees the client's copy is current.
282        //
283        // The retraction is unconditional on purpose. It used to be gated on
284        // the range having come out `Satisfiable`, which quietly excluded the
285        // one case If-Range is written for: a client resuming a download it
286        // remembers as larger than the entity now is sends an offset past the
287        // new end *and* the validator of the copy it remembers. The range then
288        // parsed as `Unsatisfiable`, the gate skipped the validator check
289        // entirely, and the reply was `416 Content-Range: bytes */<len>` — a
290        // dead end for a resume that RFC 9110 §14.2 says must succeed: a
291        // validator that does not match means the Range header field is
292        // ignored, and a field that has been ignored cannot then be judged
293        // unsatisfiable. Answering `200` with the whole current representation
294        // is both what the spec requires and the entire point of offering
295        // If-Range in the first place.
296        //
297        // Applying it to `Absent` too is a no-op, which is why the gate is gone
298        // rather than merely widened.
299        let mut spec = match call.header("range") {
300            Some(raw) => parse_range(raw, len),
301            None => RangeSpec::Absent,
302        };
303        if let Some(if_range) = call.header("if-range") {
304            if !if_range_matches(if_range, etag.as_deref(), last_modified.as_deref()) {
305                spec = RangeSpec::Absent;
306            }
307        }
308
309        if matches!(spec, RangeSpec::Unsatisfiable) {
310            let mut res = Response::new(StatusCode::RANGE_NOT_SATISFIABLE)
311                .with_header(ACCEPT_RANGES, HeaderValue::from_static("bytes"));
312            if let Ok(v) = HeaderValue::from_str(&format!("bytes */{len}")) {
313                res = res.with_header(CONTENT_RANGE, v);
314            }
315            return Ok(res);
316        }
317
318        let file = tokio::fs::File::open(&canonical)
319            .await
320            .map_err(|_| Error::not_found("not found"))?;
321
322        let range = match spec {
323            RangeSpec::Satisfiable(s, e) => Some((s, e)),
324            _ => None,
325        };
326        let (status, start, count) = match range {
327            Some((s, e)) => (StatusCode::PARTIAL_CONTENT, s, e - s + 1),
328            None => (StatusCode::OK, 0, len),
329        };
330
331        let mut res = Response::stream(
332            content_type,
333            Body::from_stream(file_stream(file, start, count)),
334        )
335        .with_status(status)
336        .with_header(ACCEPT_RANGES, HeaderValue::from_static("bytes"));
337        apply_validators(&mut res, etag.as_deref(), last_modified.as_deref());
338
339        // The length is known before a byte is read, so it is reported rather
340        // than left to chunked framing. This is also what lets a synthesized
341        // HEAD on a static file answer with the real size.
342        if let Ok(v) = HeaderValue::from_str(&count.to_string()) {
343            res.headers.insert(CONTENT_LENGTH, v);
344        }
345        if let Some((s, e)) = range {
346            if let Ok(v) = HeaderValue::from_str(&format!("bytes {s}-{e}/{len}")) {
347                res = res.with_header(CONTENT_RANGE, v);
348            }
349        }
350
351        Ok(res)
352    }
353}
354
355impl StaticFiles {
356    /// Render a directory listing as minimal HTML.
357    ///
358    /// Names are HTML-escaped: a file called `<script>.txt` is a stored-XSS
359    /// vector otherwise, and an attacker who can write to the served directory
360    /// is exactly who would try it.
361    /// Render a directory listing with **bare** relative links.
362    ///
363    /// `href="a.txt"` rather than `href="sub/a.txt"`: the caller guarantees the
364    /// request URL ends in `/`, so a browser resolves each link against the
365    /// directory itself and the same markup is correct at any depth. The
366    /// previous form prefixed the request-relative path, which was right at a
367    /// subdirectory without a trailing slash and wrong everywhere else.
368    async fn render_listing(&self, path: &Path) -> Result<Response> {
369        let mut entries = tokio::fs::read_dir(path)
370            .await
371            .map_err(|_| Error::not_found("not found"))?;
372
373        let mut names: Vec<(String, bool)> = Vec::new();
374        while let Ok(Some(entry)) = entries.next_entry().await {
375            let name = entry.file_name().to_string_lossy().to_string();
376            let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
377            names.push((name, is_dir));
378        }
379        // Directories first, then alphabetically — stable output matters for
380        // anything scraping this.
381        names.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
382
383        let mut html =
384            String::from("<!doctype html><meta charset=\"utf-8\"><title>Index</title><ul>");
385        for (name, is_dir) in names {
386            let slash = if is_dir { "/" } else { "" };
387            html.push_str(&format!(
388                "<li><a href=\"{}{}\">{}{}</a></li>",
389                // The href is a URL, so it needs URL escaping — HTML escaping
390                // alone left `:` intact, and a file named
391                // `javascript:alert(document.cookie)` is then a syntactically
392                // valid absolute URL that a browser will not resolve
393                // relatively. It also fixes ordinary names: `a#b.txt` linked to
394                // `a` with a fragment, `q?x=1.txt` to `q` with a query.
395                escape_html(&percent_encode_segment(&name)),
396                slash,
397                // The label is text, so it stays HTML-escaped and undecorated.
398                escape_html(&name),
399                slash
400            ));
401        }
402        html.push_str("</ul>");
403
404        Ok(Response::bytes(
405            "text/html; charset=utf-8",
406            html.into_bytes(),
407        ))
408    }
409}
410
411/// Escape the five characters that can break out of HTML text or an attribute.
412/// Percent-encode everything outside the unreserved set, so a filename cannot
413/// be read as anything but one path segment.
414///
415/// Deliberately aggressive: the threat model here is a filename chosen by
416/// whoever can write to the served directory, and the cost of over-encoding is
417/// a longer URL that resolves identically.
418fn percent_encode_segment(name: &str) -> String {
419    let mut out = String::with_capacity(name.len());
420    for byte in name.as_bytes() {
421        match byte {
422            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
423                out.push(*byte as char)
424            }
425            other => out.push_str(&format!("%{other:02X}")),
426        }
427    }
428    out
429}
430
431fn escape_html(s: &str) -> String {
432    let mut out = String::with_capacity(s.len());
433    for c in s.chars() {
434        match c {
435            '&' => out.push_str("&amp;"),
436            '<' => out.push_str("&lt;"),
437            '>' => out.push_str("&gt;"),
438            '"' => out.push_str("&quot;"),
439            '\'' => out.push_str("&#x27;"),
440            _ => out.push(c),
441        }
442    }
443    out
444}
445
446/// A weak entity tag built from `(mtime, len)`.
447///
448/// Weak (`W/`) on purpose: the bytes are never hashed, so this compares
449/// metadata rather than content. Labelling it strong would claim a guarantee
450/// that is not being made — two different files sharing a size and timestamp
451/// would collide.
452fn entity_tag(meta: &std::fs::Metadata) -> Option<String> {
453    let modified = meta.modified().ok()?;
454    let secs = modified
455        .duration_since(std::time::UNIX_EPOCH)
456        .ok()?
457        .as_secs();
458    Some(format!("W/\"{:x}-{:x}\"", secs, meta.len()))
459}
460
461fn apply_validators(res: &mut Response, etag: Option<&str>, last_modified: Option<&str>) {
462    if let Some(t) = etag {
463        if let Ok(v) = HeaderValue::from_str(t) {
464            res.headers.insert(ETAG, v);
465        }
466    }
467    if let Some(lm) = last_modified {
468        if let Ok(v) = HeaderValue::from_str(lm) {
469            res.headers.insert(LAST_MODIFIED, v);
470        }
471    }
472}
473
474/// Does a comma-separated `If-Match` / `If-None-Match` list match our tag?
475///
476/// `*` matches any existing entity. Comparison is weak, per RFC 9110 §13.1.2:
477/// the `W/` prefix is stripped from both sides before comparing, which is what
478/// If-None-Match requires.
479fn etag_matches(header: &str, etag: Option<&str>) -> bool {
480    let Some(ours) = etag else { return false };
481    let ours = ours.trim_start_matches("W/");
482    header.split(',').any(|candidate| {
483        let c = candidate.trim();
484        c == "*" || c.trim_start_matches("W/") == ours
485    })
486}
487
488/// `If-Range` accepts either an entity tag or a date, and unlike `If-None-Match`
489/// its tag comparison is strong. Ours are weak, so a tag only matches when it is
490/// byte-identical to what we minted.
491fn if_range_matches(header: &str, etag: Option<&str>, last_modified: Option<&str>) -> bool {
492    let h = header.trim();
493    if h.starts_with('"') || h.starts_with("W/") {
494        return etag.is_some_and(|t| t == h);
495    }
496    last_modified.is_some_and(|lm| lm == h)
497}
498
499fn parse_http_date(raw: &str) -> Option<std::time::SystemTime> {
500    httpdate::parse_http_date(raw.trim()).ok()
501}
502
503/// Compare with one-second granularity: HTTP dates carry no sub-second part, so
504/// a file written moments after the date it reports would otherwise look
505/// modified on every request.
506fn is_modified_after(modified: std::time::SystemTime, since: std::time::SystemTime) -> bool {
507    let secs = |t: std::time::SystemTime| {
508        t.duration_since(std::time::UNIX_EPOCH)
509            .map(|d| d.as_secs())
510            .unwrap_or(0)
511    };
512    secs(modified) > secs(since)
513}
514
515/// What a `Range` header amounts to for this entity.
516///
517/// Three outcomes need distinguishing and a bare `Option` collapses two of
518/// them: a multi-range request and an out-of-bounds one both fail to produce a
519/// span, but the first is answered with the whole entity and the second with
520/// `416`.
521enum RangeSpec {
522    /// No usable range: header absent, malformed, or a multi-range request.
523    /// All three are answered with the full representation.
524    Absent,
525    /// Syntactically valid but outside the entity — `416`.
526    Unsatisfiable,
527    /// An inclusive byte span.
528    Satisfiable(u64, u64),
529}
530
531/// Parse a single byte range into an inclusive span.
532fn parse_range(raw: &str, len: u64) -> RangeSpec {
533    let Some(spec) = raw.trim().strip_prefix("bytes=") else {
534        return RangeSpec::Absent; // not a byte-range unit: ignore
535    };
536    if spec.contains(',') {
537        return RangeSpec::Absent; // multi-range: fall back to the whole entity
538    }
539    let Some((from, to)) = spec.split_once('-') else {
540        return RangeSpec::Absent;
541    };
542    let (from, to) = (from.trim(), to.trim());
543
544    let (start, end) = if from.is_empty() {
545        // Suffix range: the last N bytes.
546        let Ok(n) = to.trim().parse::<u64>() else {
547            return RangeSpec::Absent; // malformed: ignore rather than reject
548        };
549        if len == 0 || n == 0 {
550            return RangeSpec::Unsatisfiable;
551        }
552        (len.saturating_sub(n), len - 1)
553    } else {
554        let Ok(s) = from.trim().parse::<u64>() else {
555            return RangeSpec::Absent;
556        };
557        let e = if to.trim().is_empty() {
558            match len.checked_sub(1) {
559                Some(e) => e,
560                None => return RangeSpec::Unsatisfiable, // empty file
561            }
562        } else {
563            match to.trim().parse::<u64>() {
564                // A too-large end is clamped, not rejected — RFC 9110 §14.1.1.
565                Ok(e) => e.min(len.saturating_sub(1)),
566                Err(_) => return RangeSpec::Absent,
567            }
568        };
569        (s, e)
570    };
571
572    if len == 0 || start > end || start >= len {
573        return RangeSpec::Unsatisfiable;
574    }
575    RangeSpec::Satisfiable(start, end)
576}
577
578/// Reject path traversal: no `..`, no root/prefix components. Returns a relative
579/// `PathBuf` of plain normal components, or `None` if unsafe.
580fn sanitize(rel: &str) -> Option<PathBuf> {
581    let mut out = PathBuf::new();
582    for component in Path::new(rel).components() {
583        match component {
584            Component::Normal(c) => out.push(c),
585            Component::CurDir => {}
586            // `..`, `/`, `C:\`, etc. are all rejected.
587            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
588        }
589    }
590    Some(out)
591}
592
593/// Stream `count` bytes starting at `start`, in 64 KiB chunks (no `tokio-util`
594/// dependency).
595///
596/// The seek happens lazily on first poll so this stays a plain synchronous
597/// constructor. `remaining` bounds the read, which is what keeps a range
598/// response from over-reading past its end.
599fn file_stream(
600    file: tokio::fs::File,
601    start: u64,
602    count: u64,
603) -> impl futures_util::stream::Stream<Item = std::result::Result<Bytes, std::io::Error>> {
604    futures_util::stream::unfold(
605        (file, count, false, start > 0),
606        move |(mut file, remaining, done, needs_seek)| async move {
607            if done || remaining == 0 {
608                return None;
609            }
610            if needs_seek {
611                use tokio::io::AsyncSeekExt;
612                if let Err(e) = file.seek(std::io::SeekFrom::Start(start)).await {
613                    return Some((Err(e), (file, 0, true, false)));
614                }
615            }
616
617            let want = remaining.min(64 * 1024) as usize;
618            let mut buf = vec![0u8; want];
619            match file.read(&mut buf).await {
620                Ok(0) => None,
621                Ok(n) => {
622                    buf.truncate(n);
623                    let left = remaining - n as u64;
624                    Some((Ok(Bytes::from(buf)), (file, left, false, false)))
625                }
626                Err(e) => Some((Err(e), (file, 0, true, false))),
627            }
628        },
629    )
630}
631
632/// Built-in extension → `Content-Type` map (not exhaustive). Falls back to
633/// `application/octet-stream`.
634fn content_type_for(path: &Path) -> &'static str {
635    let ext = path
636        .extension()
637        .and_then(|e| e.to_str())
638        .unwrap_or("")
639        .to_ascii_lowercase();
640    match ext.as_str() {
641        "html" | "htm" => "text/html; charset=utf-8",
642        "css" => "text/css; charset=utf-8",
643        "js" | "mjs" => "text/javascript; charset=utf-8",
644        "json" => "application/json",
645        "wasm" => "application/wasm",
646        "txt" => "text/plain; charset=utf-8",
647        "csv" => "text/csv; charset=utf-8",
648        "xml" => "application/xml",
649        "svg" => "image/svg+xml",
650        "png" => "image/png",
651        "jpg" | "jpeg" => "image/jpeg",
652        "gif" => "image/gif",
653        "webp" => "image/webp",
654        "ico" => "image/x-icon",
655        "woff" => "font/woff",
656        "woff2" => "font/woff2",
657        "ttf" => "font/ttf",
658        "pdf" => "application/pdf",
659        "mp4" => "video/mp4",
660        "mp3" => "audio/mpeg",
661        _ => "application/octet-stream",
662    }
663}
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668    use crate::{Churust, TestClient};
669    use http::StatusCode;
670
671    #[test]
672    fn sanitize_rejects_decoded_parent_segments() {
673        // Path parameters now arrive decoded, so `..` reaches sanitize as a
674        // literal parent component rather than as `%2e%2e`.
675        assert!(sanitize("../secret").is_none());
676        assert!(sanitize("a/../../secret").is_none());
677        assert!(sanitize("/etc/passwd").is_none(), "absolute paths rejected");
678        assert!(sanitize("ok/file.txt").is_some());
679        assert!(sanitize("./ok.txt").is_some(), "a bare `.` is harmless");
680    }
681
682    fn temp_dir_with_files() -> PathBuf {
683        // Unique-enough dir under the OS temp dir (no extra deps).
684        let base = std::env::temp_dir().join(format!("churust-fs-{}", std::process::id()));
685        let _ = std::fs::create_dir_all(base.join("sub"));
686        std::fs::write(base.join("hello.txt"), b"hello world").unwrap();
687        std::fs::write(base.join("page.html"), b"<h1>hi</h1>").unwrap();
688        std::fs::write(base.join("index.html"), b"INDEX").unwrap();
689        base
690    }
691
692    fn app(root: PathBuf) -> crate::App {
693        Churust::server()
694            .routing(move |r| {
695                r.get(
696                    "/files/{path...}",
697                    StaticFiles::dir(root.clone()).index("index.html").handler(),
698                );
699            })
700            .build()
701    }
702
703    #[tokio::test]
704    async fn serves_file_with_content_type() {
705        let root = temp_dir_with_files();
706        let res = TestClient::new(app(root))
707            .get("/files/hello.txt")
708            .send()
709            .await;
710        assert_eq!(res.status(), StatusCode::OK);
711        assert_eq!(
712            res.header("content-type"),
713            Some("text/plain; charset=utf-8")
714        );
715        assert_eq!(res.text(), "hello world");
716    }
717
718    #[tokio::test]
719    async fn html_content_type() {
720        let root = temp_dir_with_files();
721        let res = TestClient::new(app(root))
722            .get("/files/page.html")
723            .send()
724            .await;
725        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
726    }
727
728    #[tokio::test]
729    async fn missing_file_is_404() {
730        let root = temp_dir_with_files();
731        let res = TestClient::new(app(root))
732            .get("/files/nope.txt")
733            .send()
734            .await;
735        assert_eq!(res.status(), StatusCode::NOT_FOUND);
736    }
737
738    #[tokio::test]
739    async fn directory_serves_index() {
740        let root = temp_dir_with_files();
741        let res = TestClient::new(app(root)).get("/files/sub").send().await;
742        // sub/ has no index.html, so 404; root resolves index on "" path:
743        assert_eq!(res.status(), StatusCode::NOT_FOUND);
744    }
745
746    #[test]
747    fn sanitize_rejects_traversal() {
748        assert!(sanitize("../etc/passwd").is_none());
749        assert!(sanitize("/etc/passwd").is_none());
750        assert!(sanitize("a/../../b").is_none());
751        assert_eq!(sanitize("css/app.css"), Some(PathBuf::from("css/app.css")));
752        assert_eq!(sanitize("./x.txt"), Some(PathBuf::from("x.txt")));
753    }
754}