//! Serve static directories with directory listing support
use std::borrow::Cow;
use std::collections::HashMap;
use std::ffi::OsStr;
use std::fmt::{self, Debug, Display, Formatter, Write};
use std::fs::Metadata;
use std::path::{Component, Path, PathBuf};
use std::str::FromStr;
use std::time::SystemTime;
use salvo_core::fs::{NamedFile, extension_content_encoding};
use salvo_core::handler::Handler;
use salvo_core::http::header::ACCEPT_ENCODING;
use salvo_core::http::{
self, HeaderValue, Method, Request, Response, StatusCode, StatusError, append_vary_header, mime,
};
use salvo_core::routing::{
decode_url_path, encode_url_path, normalize_url_path, redirect_to_dir_url,
};
use salvo_core::writing::Text;
use salvo_core::{Depot, FlowCtrl, IntoVecString, async_trait};
use serde::{Deserialize, Serialize};
use serde_json::json;
use time::OffsetDateTime;
use time::macros::format_description;
use super::join_path;
/// Supported compression algorithms for serving compressed file variants
#[derive(Eq, PartialEq, Clone, Copy, Debug, Hash)]
#[non_exhaustive]
pub enum CompressionAlgo {
/// Brotli compression
Brotli,
/// Deflate compression
Deflate,
/// Gzip compression
Gzip,
/// Zstandard compression
Zstd,
}
impl FromStr for CompressionAlgo {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"br" | "brotli" => Ok(Self::Brotli),
"deflate" => Ok(Self::Deflate),
"gzip" => Ok(Self::Gzip),
"zstd" => Ok(Self::Zstd),
_ => Err(format!("unknown compression algorithm: {s}")),
}
}
}
impl Display for CompressionAlgo {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
Self::Brotli => write!(f, "br"),
Self::Deflate => write!(f, "deflate"),
Self::Gzip => write!(f, "gzip"),
Self::Zstd => write!(f, "zstd"),
}
}
}
impl From<CompressionAlgo> for HeaderValue {
#[inline]
fn from(algo: CompressionAlgo) -> Self {
match algo {
CompressionAlgo::Brotli => Self::from_static("br"),
CompressionAlgo::Deflate => Self::from_static("deflate"),
CompressionAlgo::Gzip => Self::from_static("gzip"),
CompressionAlgo::Zstd => Self::from_static("zstd"),
}
}
}
/// Trait for collecting static roots.
pub trait StaticRoots {
/// Collect all static roots.
fn collect(self) -> Vec<PathBuf>;
}
impl StaticRoots for &str {
#[inline]
fn collect(self) -> Vec<PathBuf> {
vec![PathBuf::from(self)]
}
}
impl StaticRoots for &String {
#[inline]
fn collect(self) -> Vec<PathBuf> {
vec![PathBuf::from(self)]
}
}
impl StaticRoots for String {
#[inline]
fn collect(self) -> Vec<PathBuf> {
vec![PathBuf::from(self)]
}
}
impl StaticRoots for PathBuf {
#[inline]
fn collect(self) -> Vec<PathBuf> {
vec![self]
}
}
impl<T> StaticRoots for Vec<T>
where
T: Into<PathBuf> + AsRef<OsStr>,
{
#[inline]
fn collect(self) -> Vec<PathBuf> {
self.iter().map(Into::into).collect()
}
}
impl<T, const N: usize> StaticRoots for [T; N]
where
T: Into<PathBuf> + AsRef<OsStr>,
{
#[inline]
fn collect(self) -> Vec<PathBuf> {
self.iter().map(Into::into).collect()
}
}
/// Handler that serves static files from directories.
///
/// This handler can serve files from one or more directory paths,
/// with support for directory listing, compressed file variants,
/// and default files.
///
/// # Examples
///
/// ```
/// use salvo_core::prelude::*;
/// use salvo_serve_static::StaticDir;
///
/// let router = Router::new().push(
/// Router::with_path("static/{**}").get(
/// StaticDir::new(["assets", "static"])
/// .defaults("index.html")
/// .auto_list(true),
/// ),
/// );
/// ```
///
/// `HEAD` requests are supported when the route is configured to match them. This lets clients
/// and caches check file metadata such as `Content-Length`, `ETag`, and `Last-Modified` without
/// downloading the response body.
///
/// **Security note**: a `HEAD` request still follows the same file lookup and metadata path as
/// `GET`. A client can send a very small request that makes the server touch the filesystem.
/// Enable `HEAD` at your own risk.
///
/// You can match both methods with a composed method filter:
///
/// ```
/// use salvo_core::prelude::*;
/// use salvo_core::routing::{Filter, filters};
/// use salvo_serve_static::StaticDir;
///
/// let router = Router::new().push(
/// Router::with_path("static/{**}")
/// .filter(filters::get().or(filters::head()))
/// .goal(
/// StaticDir::new(["assets", "static"])
/// .defaults("index.html")
/// .auto_list(true),
/// ),
/// );
/// ```
#[non_exhaustive]
pub struct StaticDir {
/// Static root directories to search for files
pub roots: Vec<PathBuf>,
/// Chunk size for file reading (in bytes)
pub chunk_size: Option<u64>,
/// Small-file preload threshold for served files (in bytes)
pub preload_threshold: Option<u64>,
/// Whether to include dot files (files/directories starting with .)
pub include_dot_files: bool,
exclude_filters: Vec<Box<dyn Fn(&str) -> bool + Send + Sync>>,
/// Whether to automatically list directories when default file isn't found
pub auto_list: bool,
/// Map of compression algorithms to file extensions for compressed variants
pub compressed_variations: HashMap<CompressionAlgo, Vec<String>>,
/// Default file names to look for in directories (e.g., "index.html")
pub defaults: Vec<String>,
/// Fallback file to serve when requested file isn't found
pub fallback: Option<String>,
/// Overrides the `Content-Disposition` type for every served file
pub disposition_type: Option<String>,
/// Whether to send `X-Content-Type-Options: nosniff`
pub use_content_type_options: bool,
}
impl Debug for StaticDir {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.debug_struct("StaticDir")
.field("roots", &self.roots)
.field("chunk_size", &self.chunk_size)
.field("preload_threshold", &self.preload_threshold)
.field("include_dot_files", &self.include_dot_files)
.field("auto_list", &self.auto_list)
.field("compressed_variations", &self.compressed_variations)
.field("defaults", &self.defaults)
.field("fallback", &self.fallback)
.field("disposition_type", &self.disposition_type)
.field("use_content_type_options", &self.use_content_type_options)
.finish()
}
}
impl StaticDir {
/// Creates a new `StaticDir`.
#[inline]
pub fn new<T: StaticRoots + Sized>(roots: T) -> Self {
let mut compressed_variations = HashMap::new();
compressed_variations.insert(CompressionAlgo::Brotli, vec!["br".to_owned()]);
compressed_variations.insert(CompressionAlgo::Zstd, vec!["zst".to_owned()]);
compressed_variations.insert(CompressionAlgo::Gzip, vec!["gz".to_owned()]);
compressed_variations.insert(CompressionAlgo::Deflate, vec!["deflate".to_owned()]);
Self {
roots: roots.collect(),
chunk_size: None,
preload_threshold: None,
include_dot_files: false,
exclude_filters: vec![],
auto_list: false,
compressed_variations,
defaults: vec![],
fallback: None,
disposition_type: None,
use_content_type_options: true,
}
}
/// Sets the `Content-Disposition` type used for every served file, e.g.
/// `inline` or `attachment`.
///
/// By default the type is derived per file from its content type: `inline`
/// for text, image, video and audio, `attachment` for everything else
/// including XML-based documents such as `image/svg+xml` and `text/xml`.
///
/// Forcing `inline` makes every file in the directory render in the browser,
/// so only do it for directories whose contents you control. An SVG served
/// inline runs its own `<script>` in the serving origin, which is stored XSS
/// when the directory holds user uploads.
#[inline]
#[must_use]
pub fn disposition_type(mut self, disposition_type: impl Into<String>) -> Self {
self.disposition_type = Some(disposition_type.into());
self
}
/// Specifies whether to send `X-Content-Type-Options: nosniff`.
///
/// Default is true.
#[inline]
#[must_use]
pub fn use_content_type_options(mut self, value: bool) -> Self {
self.use_content_type_options = value;
self
}
/// Sets include_dot_files.
#[inline]
#[must_use]
pub fn include_dot_files(mut self, include_dot_files: bool) -> Self {
self.include_dot_files = include_dot_files;
self
}
/// Exclude files.
///
/// The filter function returns true to exclude the file.
#[inline]
#[must_use]
pub fn exclude<F>(mut self, filter: F) -> Self
where
F: Fn(&str) -> bool + Send + Sync + 'static,
{
self.exclude_filters.push(Box::new(filter));
self
}
/// Sets auto_list.
#[inline]
#[must_use]
pub fn auto_list(mut self, auto_list: bool) -> Self {
self.auto_list = auto_list;
self
}
/// Sets compressed_variations.
#[inline]
#[must_use]
pub fn compressed_variation<A>(mut self, algo: A, exts: &str) -> Self
where
A: Into<CompressionAlgo>,
{
self.compressed_variations.insert(
algo.into(),
exts.split(',').map(|s| s.trim().to_owned()).collect(),
);
self
}
/// Sets defaults.
#[inline]
#[must_use]
pub fn defaults(mut self, defaults: impl IntoVecString) -> Self {
self.defaults = defaults.into_vec_string();
self
}
/// Sets fallback.
#[must_use]
pub fn fallback(mut self, fallback: impl Into<String>) -> Self {
self.fallback = Some(fallback.into());
self
}
/// During the file chunk read, the maximum read size at one time will affect the
/// access experience and the demand for server memory.
///
/// This controls streaming chunks and does not change `NamedFile`'s small-file preload
/// threshold.
///
/// Please set it according to your own situation.
///
/// The default is 1M.
#[inline]
#[must_use]
pub fn chunk_size(mut self, size: u64) -> Self {
self.chunk_size = Some(size);
self
}
/// Sets the small-file preload threshold.
///
/// Files whose size is less than or equal to this threshold are read during `NamedFile`
/// construction and sent from memory. Larger files are streamed in chunks. Set this to `0` to
/// disable preloading for non-empty files.
#[inline]
#[must_use]
pub fn preload_threshold(mut self, threshold: u64) -> Self {
self.preload_threshold = Some(threshold);
self
}
#[inline]
fn is_compressed_ext(&self, ext: &str) -> bool {
for exts in self.compressed_variations.values() {
if exts.iter().any(|e| e == ext) {
return true;
}
}
false
}
}
struct CanonicalRoot {
path: PathBuf,
canonical_path: PathBuf,
}
struct ResolvedPath {
path: PathBuf,
canonical_root: PathBuf,
metadata: Metadata,
}
impl StaticDir {
async fn canonical_roots(&self) -> Vec<CanonicalRoot> {
// `roots` is public, so derive canonical roots from the current value.
let mut roots = Vec::with_capacity(self.roots.len());
for root in &self.roots {
if let Ok(canonical) = tokio::fs::canonicalize(root).await {
roots.push(CanonicalRoot {
path: root.clone(),
canonical_path: canonical,
});
}
}
roots
}
async fn resolve_root_path(
&self,
root: &CanonicalRoot,
path: PathBuf,
apply_exclude_filters: bool,
) -> Option<ResolvedPath> {
if apply_exclude_filters {
let raw_path = path_slash::PathBufExt::to_slash_lossy(&path);
if self.exclude_filters.iter().any(|filter| filter(&raw_path)) {
return None;
}
}
let metadata = tokio::fs::symlink_metadata(&path).await.ok()?;
let canonical_path = tokio::fs::canonicalize(&path).await.ok()?;
let relative_path = canonical_path.strip_prefix(&root.canonical_path).ok()?;
// Windows 8.3 aliases can resolve to dot files even when the requested
// path has no dot segments. Check the resolved path below the root too;
// the configured root itself may legitimately have a dot-prefixed name.
if !self.include_dot_files
&& relative_path
.components()
.any(|component| component.as_os_str().as_encoded_bytes().starts_with(b"."))
{
return None;
}
Some(ResolvedPath {
path,
canonical_root: root.canonical_path.clone(),
metadata,
})
}
async fn resolve_relative_path(
&self,
root: &CanonicalRoot,
rel_path: impl AsRef<Path>,
) -> Option<ResolvedPath> {
self.resolve_root_path(root, root.path.join(rel_path), true)
.await
}
async fn resolve_child_path(&self, root: &Path, path: PathBuf) -> Option<ResolvedPath> {
let synthetic_root = CanonicalRoot {
path: root.to_owned(),
canonical_path: root.to_owned(),
};
// Apply exclude filters here too: default files (e.g. `index.html`) and
// compressed variants (e.g. `.gz`) must not bypass `exclude_filters`.
self.resolve_root_path(&synthetic_root, path, true).await
}
}
#[derive(Serialize, Deserialize, Debug)]
struct CurrentInfo {
path: String,
files: Vec<FileInfo>,
dirs: Vec<DirInfo>,
}
impl CurrentInfo {
#[inline]
fn new(path: String, files: Vec<FileInfo>, dirs: Vec<DirInfo>) -> Self {
Self { path, files, dirs }
}
}
#[derive(Serialize, Deserialize, Debug)]
struct FileInfo {
name: String,
size: u64,
modified: OffsetDateTime,
}
impl FileInfo {
#[inline]
#[must_use]
fn new(name: String, metadata: &Metadata) -> Self {
Self {
name,
size: metadata.len(),
modified: metadata
.modified()
.unwrap_or_else(|_| SystemTime::now())
.into(),
}
}
}
#[derive(Serialize, Deserialize, Debug)]
struct DirInfo {
name: String,
modified: OffsetDateTime,
}
impl DirInfo {
#[inline]
fn new(name: String, metadata: &Metadata) -> Self {
Self {
name,
modified: metadata
.modified()
.unwrap_or_else(|_| SystemTime::now())
.into(),
}
}
}
#[async_trait]
impl Handler for StaticDir {
async fn handle(
&self,
req: &mut Request,
_depot: &mut Depot,
res: &mut Response,
_ctrl: &mut FlowCtrl,
) {
let req_path = req.uri().path();
let decoded_path;
let rel_path = if let Some(rest) = req.params().tail() {
rest
} else {
decoded_path = decode_url_path(req_path);
decoded_path.as_ref()
};
let rel_path = normalize_url_path(rel_path);
let mut files: HashMap<String, Metadata> = HashMap::new();
let mut dirs: HashMap<String, Metadata> = HashMap::new();
let has_dot_segment = rel_path.split('/').any(|part| part.starts_with('.'));
let mut abs_path = None;
let roots = self.canonical_roots().await;
if self.include_dot_files || !has_dot_segment {
for root in &roots {
// Use a single async symlink_metadata call for file type checks, then verify
// the canonical target stays under the canonical root before serving it.
if let Some(path) = self.resolve_relative_path(root, &rel_path).await {
if path.metadata.is_dir() {
if !req_path.ends_with('/') && !req_path.is_empty() {
redirect_to_dir_url(req.uri(), res);
return;
}
for default_file in &self.defaults {
let default_path = path.path.join(default_file);
let Some(default_path) = self
.resolve_child_path(&path.canonical_root, default_path)
.await
else {
continue;
};
if default_path.metadata.is_file() {
abs_path = Some(default_path);
break;
}
}
if self.auto_list && abs_path.is_none() {
abs_path = Some(path);
}
if abs_path.is_some() {
break;
}
} else if path.metadata.is_file() {
abs_path = Some(path);
}
}
}
}
let fallback = self.fallback.as_deref().unwrap_or_default();
if abs_path.is_none() && !fallback.is_empty() && is_safe_relative_path(fallback) {
for root in &roots {
if let Some(path) = self.resolve_relative_path(root, fallback).await {
if !path.metadata.is_file() {
continue;
}
abs_path = Some(path);
break;
}
}
}
let Some(abs_path) = abs_path else {
res.render(StatusError::not_found());
return;
};
let is_file = abs_path.metadata.is_file();
let canonical_root = abs_path.canonical_root;
let abs_path = abs_path.path;
if is_file {
let ext = abs_path
.extension()
.and_then(|s| s.to_str())
.map(|s| s.to_lowercase());
let is_compressed_ext = ext
.as_deref()
.map(|ext| self.is_compressed_ext(ext))
.unwrap_or(false);
// A `.svgz` already is a gzip stream. Serving a precompressed variant of
// one would stack a second coding on top, and only the outer coding can
// be reported, so the client would strip that and be left holding gzip
// bytes labelled `image/svg+xml`. Serve the file itself instead.
let is_self_coded_ext = ext
.as_deref()
.map(|ext| extension_content_encoding(ext).is_some())
.unwrap_or(false);
let mut content_encoding = None;
let mut varies_on_accept_encoding = false;
let content_type = mime_infer::from_path(&abs_path).first();
// Captured before the compressed-variant lookup may replace the path:
// a download must be named after the resource that was requested, not
// after the sidecar the bytes happen to be read from.
let requested_name = abs_path
.file_name()
.map(|name| name.to_string_lossy().into_owned());
let named_path = if !is_compressed_ext && !is_self_coded_ext {
if !self.compressed_variations.is_empty() {
// Even an identity response participates in negotiation.
// Its Vary policy must not depend on this request's header.
varies_on_accept_encoding = true;
let mut new_abs_path = None;
// Accept-Encoding has list semantics across field lines.
// Borrow the common single-field value and only allocate
// when there are more preferences to combine.
let mut header = Cow::Borrowed("");
for value in req
.headers()
.get_all(ACCEPT_ENCODING)
.iter()
.filter_map(|value| value.to_str().ok())
{
if header.is_empty() {
header = Cow::Borrowed(value);
} else {
let combined = header.to_mut();
combined.push(',');
combined.push_str(value);
}
}
// Skip compressed variant lookup when client only accepts identity
if !header.is_empty() && header != "identity" {
'accepted_encoding: for (algo, level) in
http::parse_accept_encoding_with_quality(&header)
{
if level == 0 {
continue;
}
if algo.eq_ignore_ascii_case("identity") {
break;
}
let Ok(algo) = algo.parse::<CompressionAlgo>() else {
continue;
};
let Some(exts) = self.compressed_variations.get(&algo) else {
continue;
};
for zip_ext in exts {
let mut path = abs_path.clone();
path.as_mut_os_string().push(".");
path.as_mut_os_string().push(zip_ext.as_str());
if self
.resolve_child_path(&canonical_root, path.clone())
.await
.map(|path| path.metadata.is_file())
.unwrap_or(false)
{
new_abs_path = Some(path);
content_encoding = Some(algo.to_string());
break 'accepted_encoding;
}
}
}
}
new_abs_path.unwrap_or(abs_path)
} else {
abs_path
}
} else {
abs_path
};
let (builder, varies_on_accept_encoding) = {
let mut builder = NamedFile::builder(named_path);
if let Some(content_encoding) = content_encoding {
builder = builder.content_encoding(content_encoding);
}
if let Some(size) = self.chunk_size {
builder = builder.buffer_size(size);
}
if let Some(threshold) = self.preload_threshold {
builder = builder.preload_threshold(threshold);
}
if req.method() == Method::HEAD {
builder = builder.preload_threshold(0);
}
if let Some(content_type) = content_type {
builder = builder.content_type(content_type);
}
if let Some(requested_name) = requested_name {
builder = builder.disposition_name(requested_name);
}
if let Some(disposition_type) = &self.disposition_type {
builder = builder.disposition_type(disposition_type.clone());
}
builder = builder.use_content_type_options(self.use_content_type_options);
(builder, varies_on_accept_encoding)
};
if let Ok(named_file) = builder.build().await {
named_file
.send_with_method(req.method(), req.headers(), res)
.await;
if varies_on_accept_encoding {
append_vary_header(res.headers_mut(), "accept-encoding");
}
} else {
res.render(StatusError::internal_server_error().brief("read file failed"));
}
} else if !is_file {
// list the dir
if let Ok(mut entries) = tokio::fs::read_dir(&abs_path).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let file_name = entry.file_name().to_string_lossy().into_owned();
if self.include_dot_files || !file_name.starts_with('.') {
let raw_path = join_path!(&abs_path, &file_name);
if self.exclude_filters.iter().any(|filter| filter(&raw_path)) {
continue;
}
if let Ok(metadata) = entry.metadata().await {
if metadata.is_dir() {
dirs.entry(file_name).or_insert(metadata);
} else {
files.entry(file_name).or_insert(metadata);
}
}
}
}
}
let format = req.first_accept().unwrap_or(mime::TEXT_HTML);
let mut files: Vec<FileInfo> = files
.into_iter()
.map(|(name, metadata)| FileInfo::new(name, &metadata))
.collect();
files.sort_by(|a, b| a.name.cmp(&b.name));
let mut dirs: Vec<DirInfo> = dirs
.into_iter()
.map(|(name, metadata)| DirInfo::new(name, &metadata))
.collect();
dirs.sort_by(|a, b| a.name.cmp(&b.name));
let root = CurrentInfo::new(decode_url_path(req_path).into_owned(), files, dirs);
res.status_code(StatusCode::OK);
match format.subtype().as_ref() {
"plain" => res.render(Text::Plain(list_text(&root))),
"json" => res.render(Text::Json(list_json(&root))),
"xml" => res.render(Text::Xml(list_xml(&root))),
_ => res.render(Text::Html(list_html(&root))),
};
}
}
}
#[inline]
fn list_json(current: &CurrentInfo) -> String {
json!(current).to_string()
}
fn list_xml(current: &CurrentInfo) -> String {
let mut xml = "<list>".to_owned();
if current.dirs.is_empty() && current.files.is_empty() {
xml.push_str("No files");
} else {
let format = format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
for dir in ¤t.dirs {
let _ = write!(
xml,
"<dir><name>{}</name><modified>{}</modified><link>{}</link></dir>",
xml_escape(&dir.name),
dir.modified.format(&format).unwrap_or_else(|_| "-".into()),
encode_url_path(&dir.name),
);
}
for file in ¤t.files {
let _ = write!(
xml,
"<file><name>{}</name><modified>{}</modified><size>{}</size><link>{}</link></file>",
xml_escape(&file.name),
file.modified.format(&format).unwrap_or_else(|_| "-".into()),
file.size,
encode_url_path(&file.name),
);
}
}
xml.push_str("</list>");
xml
}
fn is_safe_relative_path(path: &str) -> bool {
let path = Path::new(path);
!path.is_absolute()
&& path
.components()
.all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
}
/// Escape the five XML special characters. Also used for HTML text nodes and
/// the page title in the directory listing: the escape set is identical, and
/// unlike percent-encoding it is an actual markup escaper.
fn xml_escape(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'&' => escaped.push_str("&"),
'<' => escaped.push_str("<"),
'>' => escaped.push_str(">"),
'"' => escaped.push_str("""),
'\'' => escaped.push_str("'"),
_ => escaped.push(ch),
}
}
escaped
}
fn human_size(bytes: u64) -> String {
let units = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
let mut index = 0;
let mut bytes = bytes as f64;
while bytes >= 1024.0 && index < units.len() - 1 {
bytes /= 1024.0;
index += 1;
}
bytes = (bytes * 100.0).round() / 100.0;
if bytes == 1024.0 && index < units.len() - 1 {
index += 1;
bytes = 1.0;
}
format!("{} {}", bytes, units[index])
}
fn list_html(current: &CurrentInfo) -> String {
fn header_link(out: &mut String, path: &str) {
let _ = write!(out, r#"<a href="/">{HOME_ICON}</a>"#);
let mut link = String::new();
for seg in path
.trim_start_matches('/')
.trim_end_matches('/')
.split('/')
{
// URL-encode the href, but entity-escape the visible text: percent
// encoding is a URL escaper, not an HTML one, and it also renders
// non-ASCII names as unreadable `%xx` runs.
link.push('/');
link.push_str(&encode_url_path(seg));
let _ = write!(out, r#"/<a href="{link}">{}</a>"#, xml_escape(seg));
}
}
let mut html = format!(
r#"<!DOCTYPE html><html><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>{}</title>
<style>{}</style></head><body><header><h3>Index of: "#,
xml_escape(¤t.path),
HTML_STYLE,
);
header_link(&mut html, ¤t.path);
let _ = write!(html, "</h3></header><hr/>");
if current.dirs.is_empty() && current.files.is_empty() {
let _ = write!(html, "<p>No files</p>");
} else {
let _ = write!(html, "<table><tr><th>");
if !(current.path.is_empty() || current.path == "/") {
let _ = write!(html, "<a href=\"../\">[..]</a>");
}
let _ = write!(
html,
"</th><th>Name</th><th>Last modified</th><th>Size</th></tr>"
);
let format = format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
for dir in ¤t.dirs {
let _ = write!(
html,
r#"<tr><td>{}</td><td><a href="./{}/">{}</a></td><td>{}</td><td></td></tr>"#,
DIR_ICON,
encode_url_path(&dir.name),
xml_escape(&dir.name),
dir.modified.format(&format).unwrap_or_else(|_| "-".into()),
);
}
for file in ¤t.files {
let _ = write!(
html,
r#"<tr><td>{}</td><td><a href="./{}">{}</a></td><td>{}</td><td>{}</td></tr>"#,
FILE_ICON,
encode_url_path(&file.name),
xml_escape(&file.name),
file.modified.format(&format).unwrap_or_else(|_| "-".into()),
human_size(file.size)
);
}
let _ = write!(html, "</table>");
}
let _ = write!(
html,
r#"<hr/><footer><a href="https://salvo.rs" target="_blank">salvo</a></footer></body>"#
);
html
}
fn list_text(current: &CurrentInfo) -> String {
use std::fmt::Write;
let format = format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
let mut txt = format!("Directory: {}\n\n", current.path);
for dir in ¤t.dirs {
let _ = writeln!(
txt,
"[DIR] {} {}",
dir.modified.format(&format).unwrap_or_else(|_| "-".into()),
dir.name,
);
}
for file in ¤t.files {
let _ = writeln!(
txt,
"{:>10} {} {}",
human_size(file.size),
file.modified.format(&format).unwrap_or_else(|_| "-".into()),
file.name,
);
}
txt
}
const HTML_STYLE: &str = r#"
:root {
--bg-color: #fff;
--text-color: #222;
--link-color: #0366d6;
--link-visited-color: #f22526;
--dir-icon-color: #79b8ff;
--file-icon-color: #959da5;
}
body {background: var(--bg-color); color: var(--text-color);}
a {text-decoration:none;color:var(--link-color);}
a:visited {color: var(--link-visited-color);}
a:hover {text-decoration:underline;}
header a {padding: 0 6px;}
footer {text-align:center;font-size:12px;}
table {text-align:left;border-collapse: collapse;}
tr {border-bottom: solid 1px #ccc;}
tr:last-child {border-bottom: none;}
th, td {padding: 5px;}
th:first-child,td:first-child {text-align: center;}
svg[data-icon="dir"] {vertical-align: text-bottom; color: var(--dir-icon-color); fill: currentColor;}
svg[data-icon="file"] {vertical-align: text-bottom; color: var(--file-icon-color); fill: currentColor;}
svg[data-icon="home"] {width:18px;}
@media (prefers-color-scheme: dark) {
:root {
--bg-color: #222;
--text-color: #ddd;
--link-color: #539bf5;
--link-visited-color: #f25555;
--dir-icon-color: #7da3d0;
--file-icon-color: #545d68;
}
}"#;
const DIR_ICON: &str = r#"<svg aria-label="Directory" data-icon="dir" width="20" height="20" viewBox="0 0 512 512" version="1.1" role="img"><path fill="currentColor" d="M464 128H272l-64-64H48C21.49 64 0 85.49 0 112v288c0 26.51 21.49 48 48 48h416c26.51 0 48-21.49 48-48V176c0-26.51-21.49-48-48-48z"></path></svg>"#;
const FILE_ICON: &str = r#"<svg aria-label="File" data-icon="file" width="20" height="20" viewBox="0 0 384 512" version="1.1" role="img"><path d="M369.9 97.9L286 14C277 5 264.8-.1 252.1-.1H48C21.5 0 0 21.5 0 48v416c0 26.5 21.5 48 48 48h288c26.5 0 48-21.5 48-48V131.9c0-12.7-5.1-25-14.1-34zM332.1 128H256V51.9l76.1 76.1zM48 464V48h160v104c0 13.3 10.7 24 24 24h104v288H48z"/></svg>"#;
const HOME_ICON: &str = r#"<svg aria-hidden="true" data-icon="home" viewBox="0 0 576 512"><path fill="currentColor" d="M280.37 148.26L96 300.11V464a16 16 0 0 0 16 16l112.06-.29a16 16 0 0 0 15.92-16V368a16 16 0 0 1 16-16h64a16 16 0 0 1 16 16v95.64a16 16 0 0 0 16 16.05L464 480a16 16 0 0 0 16-16V300L295.67 148.26a12.19 12.19 0 0 0-15.3 0zM571.6 251.47L488 182.56V44.05a12 12 0 0 0-12-12h-56a12 12 0 0 0-12 12v72.61L318.47 43a48 48 0 0 0-61 0L4.34 251.47a12 12 0 0 0-1.6 16.9l25.5 31A12 12 0 0 0 45.15 301l235.22-193.74a12.19 12.19 0 0 1 15.3 0L530.9 301a12 12 0 0 0 16.9-1.6l25.5-31a12 12 0 0 0-1.7-16.93z"></path></svg>"#;
#[cfg(test)]
mod tests {
use crate::dir::{
CurrentInfo, DirInfo, FileInfo, human_size, is_safe_relative_path, list_html, xml_escape,
};
#[tokio::test]
async fn directory_redirect_cannot_change_authority_after_path_normalization() {
use salvo_core::http::StatusCode;
use salvo_core::http::header::LOCATION;
use salvo_core::{Depot, FlowCtrl, Handler, Request, Response};
let root = tempfile::tempdir().unwrap();
std::fs::create_dir(root.path().join("assets")).unwrap();
let handler = super::StaticDir::new(root.path().to_path_buf());
for prefix in ["", "http://trusted.example"] {
let mut req = Request::new();
*req.uri_mut() = format!("{prefix}//evil.example/../assets?download=1")
.parse()
.unwrap();
let mut res = Response::new();
handler
.handle(
&mut req,
&mut Depot::new(),
&mut res,
&mut FlowCtrl::new(vec![]),
)
.await;
assert_eq!(res.status_code, Some(StatusCode::FOUND));
assert_eq!(
res.headers()[LOCATION],
"/.//evil.example/../assets/?download=1"
);
}
}
#[test]
fn test_list_html_escapes_visible_names() {
// Names in the listing are attacker-influenced only when the served
// directory is writable by untrusted parties, but the text nodes must be
// entity-escaped regardless — percent-encoding is a URL escaper, not an
// HTML one. (Names here need not be creatable on every filesystem; the
// renderer takes them as plain strings.)
let temp = tempfile::tempdir().expect("create temp dir");
let file = temp.path().join("probe");
std::fs::write(&file, b"x").expect("write file");
let metadata = std::fs::metadata(&file).expect("metadata");
let files = vec![FileInfo::new("a&b'<c>.txt".to_owned(), &metadata)];
let dirs = vec![DirInfo::new(r#"d"ir&"#.to_owned(), &metadata)];
let html = list_html(&CurrentInfo::new("pa&th".to_owned(), files, dirs));
// Visible text is entity-escaped...
assert!(html.contains("a&b'<c>.txt"));
assert!(html.contains("d"ir&"));
assert!(html.contains("<title>pa&th</title>"));
// ...and the raw markup-significant name never appears anywhere.
assert!(!html.contains("<c>.txt"));
}
#[tokio::test]
async fn test_convert_bytes_to_units() {
assert_eq!("94.03 MB", human_size(98595176)); // 98.59 MB
let unit = 1024;
assert_eq!("1 KB", human_size(unit));
assert_eq!("1023 B", human_size(unit - 1));
assert_eq!("1 MB", human_size(unit * unit));
assert_eq!("1 MB", human_size(unit * unit - 1));
assert_eq!("1023.99 KB", human_size(unit * unit - 10));
assert_eq!("1 GB", human_size(unit * unit * unit));
assert_eq!("1 GB", human_size(unit * unit * unit - 1));
assert_eq!("1 TB", human_size(unit * unit * unit * unit));
assert_eq!("1 TB", human_size(unit * unit * unit * unit - 1));
assert_eq!("1 PB", human_size(unit * unit * unit * unit * unit));
assert_eq!("1 PB", human_size(unit * unit * unit * unit * unit - 1));
}
#[test]
fn test_xml_escape() {
assert_eq!(
xml_escape(r#"<script a="b">'&</script>"#),
"<script a="b">'&</script>"
);
}
#[test]
fn test_fallback_path_must_stay_relative() {
assert!(is_safe_relative_path("index.html"));
assert!(is_safe_relative_path("./spa/index.html"));
assert!(!is_safe_relative_path("../secret.html"));
assert!(!is_safe_relative_path("spa/../../secret.html"));
assert!(!is_safe_relative_path("/var/www/index.html"));
}
}