#![cfg_attr(test, allow(clippy::unwrap_used))]
#![doc(html_favicon_url = "https://salvo.rs/favicon-32x32.png")]
#![doc(html_logo_url = "https://salvo.rs/images/logo.svg")]
#![cfg_attr(docsrs, feature(doc_cfg))]
pub mod dir;
mod file;
pub use dir::StaticDir;
pub use file::StaticFile;
use salvo_core::cfg_feature;
#[doc(hidden)]
#[macro_export]
macro_rules! join_path {
($($part:expr),+) => {
{
let mut p = std::path::PathBuf::new();
$(
p.push($part);
)*
path_slash::PathBufExt::to_slash_lossy(&p).to_string()
}
}
}
cfg_feature! {
#![feature = "embed"]
mod embed;
pub use embed::{render_embedded_file, static_embed, EmbeddedFileExt, StaticEmbed};
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::Path;
use salvo_core::http::HeaderValue;
use salvo_core::http::header::{
ACCEPT_RANGES, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE,
HeaderName, VARY, X_CONTENT_TYPE_OPTIONS,
};
use salvo_core::prelude::*;
use salvo_core::routing::{Filter, filters};
use salvo_core::test::{ResponseExt, TestClient};
use crate::*;
#[cfg(unix)]
fn create_dir_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(target, link)
}
#[cfg(windows)]
fn create_dir_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
std::os::windows::fs::symlink_dir(target, link)
}
#[tokio::test]
async fn test_serve_static_dir() {
let router = Router::with_path("{*path}").get(
StaticDir::new(vec!["test/static"])
.include_dot_files(false)
.auto_list(true)
.preload_threshold(0)
.defaults("index.html"),
);
let service = Service::new(router);
async fn access(service: &Service, accept: &str, url: &str) -> String {
TestClient::get(url)
.add_header("accept", accept, true)
.send(service)
.await
.take_string()
.await
.unwrap()
}
let content = access(&service, "text/plain", "http://127.0.0.1:5801").await;
assert!(content.contains("Index page"));
let content = access(&service, "text/plain", "http://127.0.0.1:5801/").await;
assert!(content.contains("Index page"));
let content = access(&service, "text/plain", "http://127.0.0.1:5801/dir1/").await;
assert!(content.contains("test3.txt") && content.contains("dir2"));
let content = access(&service, "text/xml", "http://127.0.0.1:5801/dir1/").await;
assert!(
content.starts_with("<list>")
&& content.contains("test3.txt")
&& content.contains("dir2")
);
let content = access(&service, "text/html", "http://127.0.0.1:5801/dir1/").await;
assert!(
content.contains("<html>") && content.contains("test3.txt") && content.contains("dir2")
);
let content = access(&service, "application/json", "http://127.0.0.1:5801/dir1/").await;
assert!(
content.starts_with('{') && content.contains("test3.txt") && content.contains("dir2")
);
let content = access(&service, "text/plain", "http://127.0.0.1:5801/test1.txt").await;
assert!(content.contains("copy1"));
let content = access(&service, "text/plain", "http://127.0.0.1:5801/test3.txt").await;
assert!(content.contains("Not Found"));
let content = access(
&service,
"text/plain",
"http://127.0.0.1:5801/../girl/love/eat.txt",
)
.await;
assert!(content.contains("Not Found"));
let content = access(
&service,
"text/plain",
"http://127.0.0.1:5801/..\\girl\\love\\eat.txt",
)
.await;
assert!(content.contains("Not Found"));
let content = access(
&service,
"text/plain",
"http://127.0.0.1:5801/dir1/test3.txt",
)
.await;
assert!(content.contains("copy3"));
let content = access(
&service,
"text/plain",
"http://127.0.0.1:5801/dir1/dir2/test3.txt",
)
.await;
assert_eq!(content, "dir2 test3");
let content = access(
&service,
"text/plain",
"http://127.0.0.1:5801/dir1/../dir1/test3.txt",
)
.await;
assert_eq!(content, "copy3");
let content = access(
&service,
"text/plain",
"http://127.0.0.1:5801/dir1\\..\\dir1\\test3.txt",
)
.await;
assert_eq!(content, "copy3");
}
#[tokio::test]
async fn test_static_dir_rejects_dot_directory_ancestors() {
let root = tempfile::TempDir::new().unwrap();
fs::create_dir_all(root.path().join(".git/objects")).unwrap();
fs::write(root.path().join(".git/config"), "token = secret").unwrap();
fs::write(root.path().join(".git/objects/data"), "object data").unwrap();
fs::write(root.path().join(".env"), "top-level secret").unwrap();
fs::write(root.path().join("public.txt"), "public data").unwrap();
let service = Service::new(
Router::with_path("{*path}")
.get(StaticDir::new(root.path().to_path_buf()).auto_list(true)),
);
for path in [
"/.env",
"/.git/config",
"/.git/objects/data",
"/.git/objects/",
] {
let response = TestClient::get(format!("http://127.0.0.1:5801{path}"))
.send(&service)
.await;
assert_eq!(response.status_code, Some(StatusCode::NOT_FOUND), "{path}");
}
let mut response = TestClient::get("http://127.0.0.1:5801/public.txt")
.send(&service)
.await;
assert_eq!(response.status_code, Some(StatusCode::OK));
assert_eq!(response.take_string().await.unwrap(), "public data");
let mut response = TestClient::get("http://127.0.0.1:5801/")
.add_header("accept", "application/json", true)
.send(&service)
.await;
let listing = response.take_string().await.unwrap();
assert!(listing.contains("public.txt"));
assert!(!listing.contains(".git"));
assert!(!listing.contains(".env"));
let included_service = Service::new(
Router::with_path("{*path}").get(
StaticDir::new(root.path().to_path_buf())
.auto_list(true)
.include_dot_files(true),
),
);
let mut response = TestClient::get("http://127.0.0.1:5801/.git/config")
.send(&included_service)
.await;
assert_eq!(response.status_code, Some(StatusCode::OK));
assert_eq!(response.take_string().await.unwrap(), "token = secret");
}
#[tokio::test]
async fn test_static_dir_rejects_symlinked_directory_escape() {
let public = tempfile::TempDir::new().unwrap();
let private = tempfile::TempDir::new().unwrap();
fs::write(private.path().join("secret.txt"), "secret").unwrap();
fs::write(private.path().join("fallback.html"), "fallback").unwrap();
let link = public.path().join("link");
if create_dir_symlink(private.path(), &link).is_err() {
return;
}
let router = Router::with_path("{*path}")
.get(StaticDir::new(public.path().to_path_buf()).fallback("link/fallback.html"));
let service = Service::new(router);
let response = TestClient::get("http://127.0.0.1:5801/link/secret.txt")
.send(&service)
.await;
assert_eq!(response.status_code.unwrap(), StatusCode::NOT_FOUND);
let response = TestClient::get("http://127.0.0.1:5801/missing")
.send(&service)
.await;
assert_eq!(response.status_code.unwrap(), StatusCode::NOT_FOUND);
}
const SVGZ: &[u8] = &[
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xb3, 0x29, 0x2e, 0x4b, 0x57,
0xa8, 0xc8, 0xcd, 0xc9, 0x2b, 0xb6, 0x55, 0xca, 0x28, 0x29, 0x29, 0xb0, 0xd2, 0xd7, 0x2f,
0x2f, 0x2f, 0xd7, 0x2b, 0x37, 0xd6, 0xcb, 0x2f, 0x4a, 0xd7, 0x37, 0x32, 0x30, 0x30, 0xd0,
0x07, 0xaa, 0x50, 0x52, 0x28, 0xcf, 0x4c, 0x29, 0xc9, 0xb0, 0x55, 0x32, 0x34, 0x50, 0x52,
0xc8, 0x48, 0xcd, 0x4c, 0xcf, 0x28, 0x01, 0xb3, 0xf5, 0xed, 0x00, 0xb8, 0xf1, 0x6a, 0x2e,
0x40, 0x00, 0x00, 0x00,
];
#[tokio::test]
async fn test_serve_static_dir_marks_svgz_as_gzip_encoded() {
let root = tempfile::TempDir::new().unwrap();
fs::write(root.path().join("logo.svgz"), SVGZ).unwrap();
let router = Router::with_path("{*path}")
.get(StaticDir::new(root.path().to_path_buf()).auto_list(false));
let service = Service::new(router);
let mut response = TestClient::get("http://127.0.0.1:5801/logo.svgz")
.add_header("accept-encoding", "gzip", true)
.send(&service)
.await;
assert_eq!(response.status_code.unwrap(), StatusCode::OK);
assert_eq!(
response.headers().get(CONTENT_TYPE).unwrap(),
"image/svg+xml"
);
assert_eq!(response.headers().get(CONTENT_ENCODING).unwrap(), "gzip");
assert_eq!(response.take_bytes(None).await.unwrap().as_ref(), SVGZ);
}
#[tokio::test]
async fn test_serve_static_dir_ignores_sidecar_for_self_coded_file() {
let root = tempfile::TempDir::new().unwrap();
fs::write(root.path().join("logo.svgz"), SVGZ).unwrap();
fs::write(root.path().join("logo.svgz.br"), b"brotli-of-gzip").unwrap();
let router = Router::with_path("{*path}")
.get(StaticDir::new(root.path().to_path_buf()).auto_list(false));
let service = Service::new(router);
let mut response = TestClient::get("http://127.0.0.1:5801/logo.svgz")
.add_header("accept-encoding", "br, gzip", true)
.send(&service)
.await;
assert_eq!(
response.headers().get(CONTENT_TYPE).unwrap(),
"image/svg+xml"
);
assert_eq!(response.headers().get(CONTENT_ENCODING).unwrap(), "gzip");
assert_eq!(response.take_bytes(None).await.unwrap().as_ref(), SVGZ);
}
#[tokio::test]
async fn test_static_dir_does_not_serve_uploaded_svg_inline() {
let root = tempfile::TempDir::new().unwrap();
fs::write(
root.path().join("poc.svg"),
r#"<svg xmlns="http://www.w3.org/2000/svg" onload="alert(document.domain)"/>"#,
)
.unwrap();
fs::write(
root.path().join("poc.xml"),
r#"<?xml-stylesheet type="text/xsl" href="poc.xsl"?><root/>"#,
)
.unwrap();
fs::write(root.path().join("logo.png"), b"\x89PNG\r\n\x1a\n").unwrap();
let router = Router::with_path("{*path}")
.get(StaticDir::new(root.path().to_path_buf()).auto_list(false));
let service = Service::new(router);
async fn headers_of(service: &Service, url: &str) -> (String, String) {
let response = TestClient::get(url).send(service).await;
let header = |name: HeaderName| {
response
.headers
.get(name)
.expect("header is set")
.to_str()
.expect("header is ascii")
.to_owned()
};
(header(CONTENT_DISPOSITION), header(CONTENT_TYPE))
}
for (name, expected_type) in [
("poc.svg", "image/svg+xml"),
("poc.xml", "text/xml; charset=utf-8"),
] {
let (disposition, content_type) =
headers_of(&service, &format!("http://127.0.0.1:5801/{name}")).await;
assert_eq!(content_type, expected_type, "{name} content type");
assert!(
disposition.starts_with("attachment"),
"{name} served with `{disposition}`"
);
}
let (disposition, content_type) =
headers_of(&service, "http://127.0.0.1:5801/logo.png").await;
assert_eq!(content_type, "image/png");
assert_eq!(disposition, "inline");
let response = TestClient::get("http://127.0.0.1:5801/logo.png")
.send(&service)
.await;
assert_eq!(
response
.headers
.get(X_CONTENT_TYPE_OPTIONS)
.and_then(|v| v.to_str().ok()),
Some("nosniff")
);
}
#[tokio::test]
async fn test_static_dir_names_download_after_the_requested_file() {
let root = tempfile::TempDir::new().unwrap();
fs::write(root.path().join("logo.svg"), "<svg/>").unwrap();
fs::write(root.path().join("logo.svg.br"), "brotli-bytes").unwrap();
let router = Router::with_path("{*path}")
.get(StaticDir::new(root.path().to_path_buf()).auto_list(false));
let service = Service::new(router);
let response = TestClient::get("http://127.0.0.1:5801/logo.svg")
.add_header("accept-encoding", "br", true)
.send(&service)
.await;
assert_eq!(
response
.headers
.get(CONTENT_ENCODING)
.and_then(|v| v.to_str().ok()),
Some("br")
);
assert_eq!(
response
.headers
.get(CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok()),
Some(r#"attachment; filename="logo.svg""#)
);
}
#[tokio::test]
async fn test_static_dir_disposition_type_can_restore_inline() {
let root = tempfile::TempDir::new().unwrap();
fs::write(
root.path().join("logo.svg"),
r#"<svg xmlns="http://www.w3.org/2000/svg"/>"#,
)
.unwrap();
let router = Router::with_path("{*path}").get(
StaticDir::new(root.path().to_path_buf())
.disposition_type("inline")
.use_content_type_options(false),
);
let service = Service::new(router);
let response = TestClient::get("http://127.0.0.1:5801/logo.svg")
.send(&service)
.await;
assert_eq!(
response
.headers
.get(CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok()),
Some("inline")
);
assert_eq!(response.headers.get(X_CONTENT_TYPE_OPTIONS), None);
}
#[tokio::test]
async fn test_serve_static_file() {
let router = Router::new()
.push(
Router::with_path("test1.txt")
.filter(filters::get().or(filters::head()))
.goal(
StaticFile::new("test/static/test1.txt")
.chunk_size(1024)
.preload_threshold(0),
),
)
.push(
Router::with_path("notexist.txt").get(StaticFile::new("test/static/notexist.txt")),
);
let service = Service::new(router);
let mut response = TestClient::get("http://127.0.0.1:5801/test1.txt")
.send(&service)
.await;
assert_eq!(response.status_code.unwrap(), StatusCode::OK);
assert_eq!(response.take_string().await.unwrap(), "copy1");
let response = TestClient::head("http://127.0.0.1:5801/test1.txt")
.send(&service)
.await;
assert_eq!(response.status_code.unwrap(), StatusCode::OK);
assert_eq!(response.headers().get(CONTENT_LENGTH).unwrap(), "5");
assert_eq!(response.headers().get(ACCEPT_RANGES).unwrap(), "bytes");
assert!(
response
.headers()
.get(CONTENT_TYPE)
.unwrap()
.to_str()
.unwrap()
.starts_with("text/plain")
);
let response = TestClient::get("http://127.0.0.1:5801/notexist.txt")
.send(&service)
.await;
assert_eq!(response.status_code.unwrap(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_serve_static_dir_head_matches_get_headers_without_body() {
let router = Router::with_path("{*path}")
.filter(filters::get().or(filters::head()))
.goal(StaticDir::new(vec!["test/static"]));
let service = Service::new(router);
let get_response = TestClient::get("http://127.0.0.1:5801/test1.txt")
.send(&service)
.await;
let mut response = TestClient::head("http://127.0.0.1:5801/test1.txt")
.send(&service)
.await;
assert_eq!(response.status_code.unwrap(), StatusCode::OK);
assert!(
response
.headers()
.get(CONTENT_TYPE)
.unwrap()
.to_str()
.unwrap()
.starts_with("text/plain")
);
assert_eq!(
response.headers().get(CONTENT_LENGTH),
get_response.headers().get(CONTENT_LENGTH)
);
assert_eq!(response.headers().get(CONTENT_LENGTH).unwrap(), "5");
assert_eq!(response.headers().get(ACCEPT_RANGES).unwrap(), "bytes");
assert_eq!(response.take_string().await.unwrap(), "");
}
#[tokio::test]
async fn test_serve_static_dir_respects_accept_encoding_q_order() {
let router = Router::with_path("{*path}").get(StaticDir::new(vec!["test/static"]));
let service = Service::new(router);
let response = TestClient::get("http://127.0.0.1:5801/test1.txt")
.add_header("accept-encoding", "br;q=0.1, gzip;q=1", true)
.send(&service)
.await;
assert_eq!(response.status_code.unwrap(), StatusCode::OK);
assert_eq!(
response.headers().get(CONTENT_ENCODING),
Some(&HeaderValue::from_static("gzip"))
);
let varies_on_accept_encoding = response
.headers()
.get_all(VARY)
.iter()
.filter_map(|value| value.to_str().ok())
.flat_map(|value| value.split(','))
.any(|value| value.trim().eq_ignore_ascii_case("accept-encoding"));
assert!(varies_on_accept_encoding);
let response = TestClient::get("http://127.0.0.1:5801/test1.txt")
.add_header("accept-encoding", "br;q=1, gzip;q=0.1", true)
.send(&service)
.await;
assert_eq!(response.status_code.unwrap(), StatusCode::OK);
assert_eq!(
response.headers().get(CONTENT_ENCODING),
Some(&HeaderValue::from_static("br"))
);
let mut response = TestClient::get("http://127.0.0.1:5801/test1.txt")
.add_header("accept-encoding", "identity;q=1, gzip;q=0.5", true)
.send(&service)
.await;
assert_eq!(response.status_code.unwrap(), StatusCode::OK);
assert_eq!(response.headers().get(CONTENT_ENCODING), None);
let varies_on_accept_encoding = response
.headers()
.get_all(VARY)
.iter()
.filter_map(|value| value.to_str().ok())
.flat_map(|value| value.split(','))
.any(|value| value.trim().eq_ignore_ascii_case("accept-encoding"));
assert!(varies_on_accept_encoding);
assert_eq!(response.take_string().await.unwrap(), "copy1");
}
#[tokio::test]
async fn encoding_negotiation_preserves_three_decimal_quality_values() {
let service = Service::new(
Router::with_path("{*path}")
.filter(filters::get().or(filters::head()))
.goal(StaticDir::new(vec!["test/static"])),
);
for (accept, expected) in [
("gzip;q=0.001, identity;q=0", Some("gzip")),
("gzip;q=0.101, br;q=0.109", Some("br")),
("gzip;q=0.109, identity;q=0.101", Some("gzip")),
("gzip;q=0.101, identity;q=0.109", None),
("gzip;q=0, br;q=0", None),
("gzip;q=1, br;q=0.999", Some("gzip")),
] {
for head in [false, true] {
let request = if head {
TestClient::head("http://localhost/test1.txt")
} else {
TestClient::get("http://localhost/test1.txt")
};
let response = request
.add_header("accept-encoding", accept, true)
.send(&service)
.await;
assert_eq!(
response
.headers()
.get(CONTENT_ENCODING)
.map(|value| value.to_str().unwrap()),
expected,
"{accept} (HEAD={head})"
);
}
}
}
#[tokio::test]
async fn repeated_accept_encoding_fields_match_the_combined_value() {
let service = Service::new(
Router::with_path("{*path}")
.filter(filters::get().or(filters::head()))
.goal(StaticDir::new(vec!["test/static"])),
);
for (first, second, expected) in [
("gzip;q=0.2", "br;q=1", "br"),
("unknown", "gzip", "gzip"),
("", "gzip", "gzip"),
("gzip;q=0", "br", "br"),
] {
for head in [false, true] {
let request = || {
if head {
TestClient::head("http://localhost/test1.txt")
} else {
TestClient::get("http://localhost/test1.txt")
}
};
let mut combined = request()
.add_header("accept-encoding", format!("{first},{second}"), true)
.send(&service)
.await;
let mut split = request()
.add_header("accept-encoding", first, false)
.add_header("accept-encoding", second, false)
.send(&service)
.await;
assert_eq!(combined.headers().get(CONTENT_ENCODING).unwrap(), expected);
assert_eq!(
split.headers().get(CONTENT_ENCODING),
combined.headers().get(CONTENT_ENCODING),
"split fields {first:?} and {second:?} must preserve negotiation"
);
assert_eq!(
split.headers().get(CONTENT_LENGTH),
combined.headers().get(CONTENT_LENGTH)
);
assert_eq!(
split.take_bytes(None).await.unwrap(),
combined.take_bytes(None).await.unwrap()
);
}
}
}
#[tokio::test]
async fn test_serve_static_dir_accept_encoding_is_case_insensitive() {
let router = Router::with_path("{*path}").get(StaticDir::new(vec!["test/static"]));
let service = Service::new(router);
for accept_encoding in ["GZIP", "br;Q=0, gzip"] {
let response = TestClient::get("http://127.0.0.1:5801/test1.txt")
.add_header("accept-encoding", accept_encoding, true)
.send(&service)
.await;
assert_eq!(response.status_code.unwrap(), StatusCode::OK);
assert_eq!(
response.headers().get(CONTENT_ENCODING),
Some(&HeaderValue::from_static("gzip")),
"{accept_encoding}"
);
}
}
#[cfg(feature = "embed")]
#[tokio::test]
async fn test_serve_embed_files() {
#[derive(rust_embed::RustEmbed)]
#[folder = "test/static"]
struct Assets;
let router = Router::new()
.push(
Router::with_path("test1.txt")
.get(Assets::get("test1.txt").unwrap().into_handler()),
)
.push(Router::with_path("files/{**path}").get(serve_file))
.push(
Router::with_path("dir/{**path}").get(
static_embed::<Assets>()
.defaults("index.html")
.fallback("fallback.html"),
),
)
.push(Router::with_path("dir2/{**path}").get(static_embed::<Assets>()))
.push(
Router::with_path("dir3/{**path}")
.get(static_embed::<Assets>().fallback("notexist.html")),
);
let service = Service::new(router);
#[handler]
async fn serve_file(req: &mut Request, res: &mut Response) {
let path = req.param::<String>("path").unwrap();
if let Some(file) = Assets::get(&path) {
file.render(req, res);
}
}
let mut response = TestClient::get("http://127.0.0.1:5801/files/test1.txt")
.send(&service)
.await;
assert_eq!(response.status_code.unwrap(), StatusCode::OK);
assert_eq!(response.take_string().await.unwrap(), "copy1");
let mut response = TestClient::get("http://127.0.0.1:5801/dir/test1.txt")
.send(&service)
.await;
assert_eq!(response.status_code.unwrap(), StatusCode::OK);
assert_eq!(response.take_string().await.unwrap(), "copy1");
let mut response = TestClient::get("http://127.0.0.1:5801/dir/test1111.txt")
.send(&service)
.await;
assert_eq!(response.status_code.unwrap(), StatusCode::OK);
assert!(
response
.take_string()
.await
.unwrap()
.contains("Fallback page")
);
let response = TestClient::get("http://127.0.0.1:5801/dir")
.send(&service)
.await;
assert_eq!(response.status_code.unwrap(), StatusCode::OK);
let mut response = TestClient::get("http://127.0.0.1:5801/dir/")
.send(&service)
.await;
assert_eq!(response.status_code.unwrap(), StatusCode::OK);
assert!(response.take_string().await.unwrap().contains("Index page"));
let response = TestClient::get("http://127.0.0.1:5801/dir2/")
.send(&service)
.await;
assert_eq!(response.status_code.unwrap(), StatusCode::NOT_FOUND);
let response = TestClient::get("http://127.0.0.1:5801/dir3/abc.txt")
.send(&service)
.await;
assert_eq!(response.status_code.unwrap(), StatusCode::NOT_FOUND);
let response = TestClient::get("http://127.0.0.1:5801/files/test1.txt")
.send(&service)
.await;
assert_eq!(response.status_code.unwrap(), StatusCode::OK);
let etag = response
.headers
.get("etag")
.expect("200 response should carry an ETag")
.to_str()
.unwrap()
.to_owned();
assert!(etag.starts_with('"') && etag.ends_with('"'));
let response = TestClient::get("http://127.0.0.1:5801/files/test1.txt")
.add_header("if-none-match", &etag, true)
.send(&service)
.await;
assert_eq!(response.status_code.unwrap(), StatusCode::NOT_MODIFIED);
assert_eq!(
response.headers.get("etag").and_then(|v| v.to_str().ok()),
Some(etag.as_str())
);
}
}