use crate::body::Body;
use crate::call::Call;
use crate::error::{Error, Result};
use crate::handler::{Handler, IntoHandler};
use crate::response::Response;
use bytes::Bytes;
use std::path::{Component, Path, PathBuf};
use tokio::io::AsyncReadExt;
#[derive(Debug, Clone)]
pub struct StaticFiles {
root: PathBuf,
index: Option<String>,
}
impl StaticFiles {
pub fn dir(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
index: None,
}
}
pub fn index(mut self, file: impl Into<String>) -> Self {
self.index = Some(file.into());
self
}
pub fn handler(self) -> impl Handler {
let cfg = self;
let closure = move |call: Call| {
let cfg = cfg.clone();
async move { cfg.serve(&call).await }
};
closure.into_handler()
}
async fn serve(&self, call: &Call) -> Result<Response> {
let rel = call
.params_iter()
.map(|(_, v)| v.to_string())
.next()
.unwrap_or_default();
let raw = call.path().to_ascii_lowercase();
if raw.contains("%2f") || raw.contains("%5c") {
return Err(Error::not_found("not found"));
}
let safe = sanitize(&rel).ok_or_else(|| Error::not_found("not found"))?;
let mut path = self.root.join(safe);
let meta = tokio::fs::metadata(&path)
.await
.map_err(|_| Error::not_found("not found"))?;
if meta.is_dir() {
match &self.index {
Some(index) => path = path.join(index),
None => return Err(Error::not_found("not found")),
}
}
let canonical = tokio::fs::canonicalize(&path)
.await
.map_err(|_| Error::not_found("not found"))?;
let canonical_root = tokio::fs::canonicalize(&self.root)
.await
.map_err(|_| Error::internal("static root does not exist"))?;
if !canonical.starts_with(&canonical_root) {
return Err(Error::not_found("not found"));
}
let file = tokio::fs::File::open(&canonical)
.await
.map_err(|_| Error::not_found("not found"))?;
let content_type = content_type_for(&canonical);
Ok(Response::stream(
content_type,
Body::from_stream(file_stream(file)),
))
}
}
fn sanitize(rel: &str) -> Option<PathBuf> {
let mut out = PathBuf::new();
for component in Path::new(rel).components() {
match component {
Component::Normal(c) => out.push(c),
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
}
}
Some(out)
}
fn file_stream(
file: tokio::fs::File,
) -> impl futures_util::stream::Stream<Item = std::result::Result<Bytes, std::io::Error>> {
futures_util::stream::unfold((file, false), |(mut file, done)| async move {
if done {
return None;
}
let mut buf = vec![0u8; 64 * 1024];
match file.read(&mut buf).await {
Ok(0) => None,
Ok(n) => {
buf.truncate(n);
Some((Ok(Bytes::from(buf)), (file, false)))
}
Err(e) => Some((Err(e), (file, true))),
}
})
}
fn content_type_for(path: &Path) -> &'static str {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
match ext.as_str() {
"html" | "htm" => "text/html; charset=utf-8",
"css" => "text/css; charset=utf-8",
"js" | "mjs" => "text/javascript; charset=utf-8",
"json" => "application/json",
"wasm" => "application/wasm",
"txt" => "text/plain; charset=utf-8",
"csv" => "text/csv; charset=utf-8",
"xml" => "application/xml",
"svg" => "image/svg+xml",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"ico" => "image/x-icon",
"woff" => "font/woff",
"woff2" => "font/woff2",
"ttf" => "font/ttf",
"pdf" => "application/pdf",
"mp4" => "video/mp4",
"mp3" => "audio/mpeg",
_ => "application/octet-stream",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Churust, TestClient};
use http::StatusCode;
#[test]
fn sanitize_rejects_decoded_parent_segments() {
assert!(sanitize("../secret").is_none());
assert!(sanitize("a/../../secret").is_none());
assert!(sanitize("/etc/passwd").is_none(), "absolute paths rejected");
assert!(sanitize("ok/file.txt").is_some());
assert!(sanitize("./ok.txt").is_some(), "a bare `.` is harmless");
}
fn temp_dir_with_files() -> PathBuf {
let base = std::env::temp_dir().join(format!("churust-fs-{}", std::process::id()));
let _ = std::fs::create_dir_all(base.join("sub"));
std::fs::write(base.join("hello.txt"), b"hello world").unwrap();
std::fs::write(base.join("page.html"), b"<h1>hi</h1>").unwrap();
std::fs::write(base.join("index.html"), b"INDEX").unwrap();
base
}
fn app(root: PathBuf) -> crate::App {
Churust::server()
.routing(move |r| {
r.get(
"/files/{path...}",
StaticFiles::dir(root.clone()).index("index.html").handler(),
);
})
.build()
}
#[tokio::test]
async fn serves_file_with_content_type() {
let root = temp_dir_with_files();
let res = TestClient::new(app(root))
.get("/files/hello.txt")
.send()
.await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
res.header("content-type"),
Some("text/plain; charset=utf-8")
);
assert_eq!(res.text(), "hello world");
}
#[tokio::test]
async fn html_content_type() {
let root = temp_dir_with_files();
let res = TestClient::new(app(root))
.get("/files/page.html")
.send()
.await;
assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
}
#[tokio::test]
async fn missing_file_is_404() {
let root = temp_dir_with_files();
let res = TestClient::new(app(root))
.get("/files/nope.txt")
.send()
.await;
assert_eq!(res.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn directory_serves_index() {
let root = temp_dir_with_files();
let res = TestClient::new(app(root)).get("/files/sub").send().await;
assert_eq!(res.status(), StatusCode::NOT_FOUND);
}
#[test]
fn sanitize_rejects_traversal() {
assert!(sanitize("../etc/passwd").is_none());
assert!(sanitize("/etc/passwd").is_none());
assert!(sanitize("a/../../b").is_none());
assert_eq!(sanitize("css/app.css"), Some(PathBuf::from("css/app.css")));
assert_eq!(sanitize("./x.txt"), Some(PathBuf::from("x.txt")));
}
}