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("&"),
436 '<' => out.push_str("<"),
437 '>' => out.push_str(">"),
438 '"' => out.push_str("""),
439 '\'' => out.push_str("'"),
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}