use crate::body::util::BodyExt;
use crate::header::ALLOW;
#[cfg(unix)]
use crate::service::fs::ServeDirSymlinkPolicy;
use crate::service::fs::serve_dir::DirSource;
use crate::service::fs::{DirectoryServeMode, ServeDir, ServeFile};
use crate::{Body, Request, StatusCode, StreamingBody};
use crate::{Method, Response, header};
use brotli::BrotliDecompress;
use flate2::bufread::{DeflateDecoder, GzDecoder};
use rama_core::Service;
use rama_core::bytes::Bytes;
use rama_core::service::service_fn;
use rama_utils::include_dir::{Dir, include_dir};
use std::convert::Infallible;
use std::io::Read;
#[tokio::test]
async fn basic() {
let svc = ServeDir::new("../rama-http");
test_basic(svc).await;
}
#[tokio::test]
async fn basic_embedded() {
let svc = ServeDir::new_embedded(include_dir!("$CARGO_MANIFEST_DIR"));
test_basic(svc).await;
}
async fn test_basic(svc: ServeDir) {
let req = Request::builder()
.uri("/README.md")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.headers()["content-type"], "text/markdown");
let body = body_into_text(res.into_body()).await;
let contents = std::fs::read_to_string("../rama-http/README.md").unwrap();
assert_eq!(body, contents);
}
#[tokio::test]
async fn dot_dot_traversal_is_clamped_to_root() {
let svc = ServeDir::new("../rama-http");
for uri in [
"/foo/../README.md",
"/../../../README.md",
"/a/b/../../README.md",
] {
let req = Request::builder().uri(uri).body(Body::empty()).unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK, "uri: {uri}");
assert_eq!(res.headers()["content-type"], "text/markdown", "uri: {uri}");
}
}
#[tokio::test]
async fn basic_with_index() {
let svc = ServeDir::new("../test-files");
test_basic_with_index(svc).await;
}
#[tokio::test]
async fn basic_with_index_embedded() {
const EMBEDDED_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR/../test-files");
let svc = ServeDir::new_embedded(EMBEDDED_FILES);
test_basic_with_index(svc).await;
}
async fn test_basic_with_index(svc: ServeDir) {
let req = Request::new(Body::empty());
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.headers()[header::CONTENT_TYPE], "text/html");
let body = body_into_text(res.into_body()).await;
#[cfg(target_os = "windows")]
assert_eq!(body, "<b>HTML!</b>\r\n");
#[cfg(not(target_os = "windows"))]
assert_eq!(body, "<b>HTML!</b>\n");
}
#[tokio::test]
async fn head_request() {
let svc = ServeDir::new("../test-files");
test_head_request(svc).await;
}
#[tokio::test]
async fn head_request_embedded() {
const EMBEDDED_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR/../test-files");
let svc = ServeDir::new_embedded(EMBEDDED_FILES);
test_head_request(svc).await;
}
async fn test_head_request(svc: ServeDir) {
let req = Request::builder()
.uri("/precompressed.txt")
.method(Method::HEAD)
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.headers()["content-type"], "text/plain");
#[cfg(target_os = "windows")]
assert_eq!(res.headers()["content-length"], "11");
#[cfg(not(target_os = "windows"))]
assert_eq!(res.headers()["content-length"], "10");
assert!(res.into_body().frame().await.is_none());
}
#[tokio::test]
async fn precompresed_head_request() {
let svc = ServeDir::new("../test-files").with_precompressed_gzip();
test_precompresed_head_request(svc).await;
}
#[tokio::test]
async fn precompresed_head_request_embedded() {
const EMBEDDED_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR/../test-files");
let svc = ServeDir::new_embedded(EMBEDDED_FILES).with_precompressed_gzip();
test_precompresed_head_request(svc).await;
}
async fn test_precompresed_head_request(svc: ServeDir) {
let req = Request::builder()
.uri("/precompressed.txt")
.header("Accept-Encoding", "gzip")
.method(Method::HEAD)
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.headers()["content-type"], "text/plain");
assert_eq!(res.headers()["content-encoding"], "gzip");
assert_eq!(res.headers()["content-length"], "30");
assert!(res.into_body().frame().await.is_none());
}
#[tokio::test]
async fn with_custom_chunk_size() {
let svc = ServeDir::new("../rama-http").with_buf_chunk_size(1024 * 32);
test_with_custom_chunk_size(svc).await;
}
#[tokio::test]
async fn with_custom_chunk_size_embedded() {
const EMBEDDED_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR");
let svc = ServeDir::new_embedded(EMBEDDED_FILES).with_buf_chunk_size(1024 * 32);
test_with_custom_chunk_size(svc).await;
}
async fn test_with_custom_chunk_size(svc: ServeDir) {
let req = Request::builder()
.uri("/README.md")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.headers()["content-type"], "text/markdown");
let body = body_into_text(res.into_body()).await;
let contents = std::fs::read_to_string("../rama-http/README.md").unwrap();
assert_eq!(body, contents);
}
#[tokio::test]
async fn precompressed_gzip() {
let svc = ServeDir::new("../test-files").with_precompressed_gzip();
test_precompressed_gzip(svc).await;
}
#[tokio::test]
async fn precompressed_gzip_embedded() {
const EMBEDDED_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR/../test-files");
let svc = ServeDir::new_embedded(EMBEDDED_FILES).with_precompressed_gzip();
test_precompressed_gzip(svc).await;
}
async fn test_precompressed_gzip(svc: ServeDir) {
let req = Request::builder()
.uri("/precompressed.txt")
.header("Accept-Encoding", "gzip")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.headers()["content-type"], "text/plain");
assert_eq!(res.headers()["content-encoding"], "gzip");
let body = res.into_body().collect().await.unwrap().to_bytes();
let mut decoder = GzDecoder::new(&body[..]);
let mut decompressed = String::new();
decoder.read_to_string(&mut decompressed).unwrap();
assert!(decompressed.starts_with("Test file"));
}
#[tokio::test]
async fn precompressed_br() {
let svc = ServeDir::new("../test-files").with_precompressed_br();
test_precompressed_br(svc).await;
}
#[tokio::test]
async fn precompressed_br_embedded() {
const EMBEDDED_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR/../test-files");
let svc = ServeDir::new_embedded(EMBEDDED_FILES).with_precompressed_br();
test_precompressed_br(svc).await;
}
async fn test_precompressed_br(svc: ServeDir) {
let req = Request::builder()
.uri("/precompressed.txt")
.header("Accept-Encoding", "br")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.headers()["content-type"], "text/plain");
assert_eq!(res.headers()["content-encoding"], "br");
let body = res.into_body().collect().await.unwrap().to_bytes();
let mut decompressed = Vec::new();
BrotliDecompress(&mut &body[..], &mut decompressed).unwrap();
let decompressed = String::from_utf8(decompressed.clone()).unwrap();
assert!(decompressed.starts_with("Test file"));
}
#[tokio::test]
async fn precompressed_deflate() {
let svc = ServeDir::new("../test-files").with_precompressed_deflate();
test_precompressed_deflate(svc).await;
}
#[tokio::test]
async fn precompressed_deflate_embedded() {
const EMBEDDED_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR/../test-files");
let svc = ServeDir::new_embedded(EMBEDDED_FILES).with_precompressed_deflate();
test_precompressed_deflate(svc).await;
}
async fn test_precompressed_deflate(svc: ServeDir) {
let request = Request::builder()
.uri("/precompressed.txt")
.header("Accept-Encoding", "deflate,br")
.body(Body::empty())
.unwrap();
let res = svc.serve(request).await.unwrap();
assert_eq!(res.headers()["content-type"], "text/plain");
assert_eq!(res.headers()["content-encoding"], "deflate");
let body = res.into_body().collect().await.unwrap().to_bytes();
let mut decoder = DeflateDecoder::new(&body[..]);
let mut decompressed = String::new();
decoder.read_to_string(&mut decompressed).unwrap();
assert!(decompressed.starts_with("Test file"));
}
#[tokio::test]
async fn unsupported_precompression_algorithm_fallbacks_to_uncompressed() {
let svc = ServeDir::new("../test-files").with_precompressed_gzip();
test_unsupported_precompression_algorithm_fallbacks_to_uncompressed(svc).await;
}
#[tokio::test]
async fn unsupported_precompression_algorithm_fallbacks_to_uncompressed_embedded() {
const EMBEDDED_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR/../test-files");
let svc = ServeDir::new_embedded(EMBEDDED_FILES).with_precompressed_gzip();
test_unsupported_precompression_algorithm_fallbacks_to_uncompressed(svc).await;
}
async fn test_unsupported_precompression_algorithm_fallbacks_to_uncompressed(svc: ServeDir) {
let request = Request::builder()
.uri("/precompressed.txt")
.header("Accept-Encoding", "br")
.body(Body::empty())
.unwrap();
let res = svc.serve(request).await.unwrap();
assert_eq!(res.headers()["content-type"], "text/plain");
assert!(res.headers().get("content-encoding").is_none());
let body = res.into_body().collect().await.unwrap().to_bytes();
let body = String::from_utf8(body.to_vec()).unwrap();
assert!(body.starts_with("Test file"));
}
#[tokio::test]
async fn only_precompressed_variant_existing() {
let svc = ServeDir::new("../test-files").with_precompressed_gzip();
test_only_precompressed_variant_existing(svc).await;
}
#[tokio::test]
async fn only_precompressed_variant_existing_embedded() {
const EMBEDDED_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR/../test-files");
let svc = ServeDir::new_embedded(EMBEDDED_FILES).with_precompressed_gzip();
test_only_precompressed_variant_existing(svc).await;
}
async fn test_only_precompressed_variant_existing(svc: ServeDir) {
let request = Request::builder()
.uri("/only_gzipped.txt")
.body(Body::empty())
.unwrap();
let res = svc.clone().serve(request).await.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
let request = Request::builder()
.uri("/only_gzipped.txt")
.header("Accept-Encoding", "gzip")
.body(Body::empty())
.unwrap();
let res = svc.serve(request).await.unwrap();
assert_eq!(res.headers()["content-type"], "text/plain");
assert_eq!(res.headers()["content-encoding"], "gzip");
let body = res.into_body().collect().await.unwrap().to_bytes();
let mut decoder = GzDecoder::new(&body[..]);
let mut decompressed = String::new();
decoder.read_to_string(&mut decompressed).unwrap();
assert!(decompressed.starts_with("Test file"));
}
#[tokio::test]
async fn missing_precompressed_variant_fallbacks_to_uncompressed() {
let svc = ServeDir::new("../test-files").with_precompressed_gzip();
test_missing_precompressed_variant_fallbacks_to_uncompressed(svc).await;
}
#[tokio::test]
async fn missing_precompressed_variant_fallbacks_to_uncompressed_embedded() {
const EMBEDDED_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR/../test-files");
let svc = ServeDir::new_embedded(EMBEDDED_FILES).with_precompressed_gzip();
test_missing_precompressed_variant_fallbacks_to_uncompressed(svc).await;
}
async fn test_missing_precompressed_variant_fallbacks_to_uncompressed(svc: ServeDir) {
let request = Request::builder()
.uri("/missing_precompressed.txt")
.header("Accept-Encoding", "gzip")
.body(Body::empty())
.unwrap();
let res = svc.serve(request).await.unwrap();
assert_eq!(res.headers()["content-type"], "text/plain");
assert!(res.headers().get("content-encoding").is_none());
let body = res.into_body().collect().await.unwrap().to_bytes();
let body = String::from_utf8(body.to_vec()).unwrap();
assert!(body.starts_with("Test file"));
}
#[tokio::test]
async fn missing_precompressed_variant_fallbacks_to_uncompressed_for_head_request() {
let svc = ServeDir::new("../test-files").with_precompressed_gzip();
test_missing_precompressed_variant_fallbacks_to_uncompressed_for_head_request(svc).await;
}
#[tokio::test]
async fn missing_precompressed_variant_fallbacks_to_uncompressed_for_head_request_embedded() {
const EMBEDDED_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR/../test-files");
let svc = ServeDir::new_embedded(EMBEDDED_FILES).with_precompressed_gzip();
test_missing_precompressed_variant_fallbacks_to_uncompressed_for_head_request(svc).await;
}
async fn test_missing_precompressed_variant_fallbacks_to_uncompressed_for_head_request(
svc: ServeDir,
) {
let request = Request::builder()
.uri("/missing_precompressed.txt")
.header("Accept-Encoding", "gzip")
.method(Method::HEAD)
.body(Body::empty())
.unwrap();
let res = svc.serve(request).await.unwrap();
assert_eq!(res.headers()["content-type"], "text/plain");
#[cfg(target_os = "windows")]
assert_eq!(res.headers()["content-length"], "11");
#[cfg(not(target_os = "windows"))]
assert_eq!(res.headers()["content-length"], "10");
assert!(res.headers().get("content-encoding").is_none());
assert!(res.into_body().frame().await.is_none());
}
#[tokio::test]
async fn access_to_sub_dirs() {
let svc = ServeDir::new("../rama-http");
test_access_to_sub_dirs(svc).await;
}
#[tokio::test]
async fn access_to_sub_dirs_embedded() {
const EMBEDDED_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR");
let svc = ServeDir::new_embedded(EMBEDDED_FILES);
test_access_to_sub_dirs(svc).await;
}
async fn test_access_to_sub_dirs(svc: ServeDir) {
let req = Request::builder()
.uri("/Cargo.toml")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.headers()["content-type"], "text/x-toml");
let body = body_into_text(res.into_body()).await;
let contents = std::fs::read_to_string("../rama-http/Cargo.toml").unwrap();
assert_eq!(body, contents);
}
#[tokio::test]
async fn not_found() {
let svc = ServeDir::new("..");
test_not_found(svc).await;
}
#[tokio::test]
async fn not_found_embedded() {
const EMBEDDED_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR/../test-files");
let svc = ServeDir::new_embedded(EMBEDDED_FILES);
test_not_found(svc).await;
}
async fn test_not_found(svc: ServeDir) {
let req = Request::builder()
.uri("/not-found")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
assert!(res.headers().get(header::CONTENT_TYPE).is_none());
let body = body_into_text(res.into_body()).await;
assert!(body.is_empty());
}
#[cfg(target_family = "unix")]
#[tokio::test]
async fn not_found_when_not_a_directory() {
let svc = ServeDir::new("../test-files");
let req = Request::builder()
.uri("/index.html/some_file")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
assert!(res.headers().get(header::CONTENT_TYPE).is_none());
let body = body_into_text(res.into_body()).await;
assert!(body.is_empty());
}
#[tokio::test]
async fn not_found_precompressed() {
let svc = ServeDir::new("../test-files").with_precompressed_gzip();
test_not_found_precompressed(svc).await;
}
#[tokio::test]
async fn not_found_precompressed_embedded() {
const EMBEDDED_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR/../test-files");
let svc = ServeDir::new_embedded(EMBEDDED_FILES).with_precompressed_gzip();
test_not_found_precompressed(svc).await;
}
async fn test_not_found_precompressed(svc: ServeDir) {
let req = Request::builder()
.uri("/not-found")
.header("Accept-Encoding", "gzip")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
assert!(res.headers().get(header::CONTENT_TYPE).is_none());
let body = body_into_text(res.into_body()).await;
assert!(body.is_empty());
}
#[tokio::test]
async fn fallbacks_to_different_precompressed_variant_if_not_found_for_head_request() {
let svc = ServeDir::new("../test-files")
.with_precompressed_gzip()
.with_precompressed_br();
test_fallbacks_to_different_precompressed_variant_if_not_found_for_head_request(svc).await;
}
#[tokio::test]
async fn fallbacks_to_different_precompressed_variant_if_not_found_for_head_request_embedded() {
const EMBEDDED_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR/../test-files");
let svc = ServeDir::new_embedded(EMBEDDED_FILES)
.with_precompressed_gzip()
.with_precompressed_br();
test_fallbacks_to_different_precompressed_variant_if_not_found_for_head_request(svc).await;
}
async fn test_fallbacks_to_different_precompressed_variant_if_not_found_for_head_request(
svc: ServeDir,
) {
let req = Request::builder()
.uri("/precompressed_br.txt")
.header("Accept-Encoding", "gzip,br,deflate")
.method(Method::HEAD)
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.headers()["content-type"], "text/plain");
assert_eq!(res.headers()["content-encoding"], "br");
assert_eq!(res.headers()["content-length"], "15");
assert!(res.into_body().frame().await.is_none());
}
#[tokio::test]
async fn fallbacks_to_different_precompressed_variant_if_not_found() {
let svc = ServeDir::new("../test-files")
.with_precompressed_gzip()
.with_precompressed_br();
test_fallbacks_to_different_precompressed_variant_if_not_found(svc).await;
}
#[tokio::test]
async fn fallbacks_to_different_precompressed_variant_if_not_found_embedded() {
const EMBEDDED_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR/../test-files");
let svc = ServeDir::new_embedded(EMBEDDED_FILES)
.with_precompressed_gzip()
.with_precompressed_br();
test_fallbacks_to_different_precompressed_variant_if_not_found(svc).await;
}
async fn test_fallbacks_to_different_precompressed_variant_if_not_found(svc: ServeDir) {
let req = Request::builder()
.uri("/precompressed_br.txt")
.header("Accept-Encoding", "gzip,br,deflate")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.headers()["content-type"], "text/plain");
assert_eq!(res.headers()["content-encoding"], "br");
let body = res.into_body().collect().await.unwrap().to_bytes();
let mut decompressed = Vec::new();
BrotliDecompress(&mut &body[..], &mut decompressed).unwrap();
let decompressed = String::from_utf8(decompressed.clone()).unwrap();
assert!(decompressed.starts_with("Test file"));
}
#[tokio::test]
async fn redirect_to_trailing_slash_on_dir() {
let svc = ServeDir::new("..");
let req = Request::builder().uri("/src").body(Body::empty()).unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::TEMPORARY_REDIRECT);
let location = &res.headers()[rama_http_types::header::LOCATION];
assert_eq!(location, "/src/");
}
#[tokio::test]
async fn empty_directory_without_index() {
let svc = ServeDir::new("..").with_directory_serve_mode(DirectoryServeMode::NotFound);
let req = Request::new(Body::empty());
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
assert!(res.headers().get(header::CONTENT_TYPE).is_none());
let body = body_into_text(res.into_body()).await;
assert!(body.is_empty());
}
#[cfg(feature = "html")]
#[tokio::test]
async fn serve_directory_as_file_tree() {
use rama_http_types::BodyExtractExt as _;
let svc =
ServeDir::new("../test-files").with_directory_serve_mode(DirectoryServeMode::HtmlFileList);
let req = Request::new(Body::empty());
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.headers()["content-type"], "text/html; charset=utf-8");
let payload = res.into_body().try_into_string().await.unwrap();
assert!(payload.contains("Directory listing for"));
assert!(payload.contains("hello.txt"));
assert!(payload.contains("index.html"));
}
#[tokio::test]
async fn empty_directory_without_index_no_information_leak() {
let svc = ServeDir::new("..").with_directory_serve_mode(DirectoryServeMode::NotFound);
let req = Request::builder()
.uri("/test-files")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
assert!(res.headers().get(header::CONTENT_TYPE).is_none());
let body = body_into_text(res.into_body()).await;
assert!(body.is_empty());
}
#[cfg(unix)]
#[tokio::test]
async fn symlink_file_is_not_served() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let secret_path = outside.path().join("secret.txt");
std::fs::write(&secret_path, "secret").unwrap();
std::os::unix::fs::symlink(&secret_path, root.path().join("link.txt")).unwrap();
let svc = ServeDir::new(root.path());
let req = Request::builder()
.uri("/link.txt")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
}
#[cfg(unix)]
#[tokio::test]
async fn symlinked_root_is_served() {
let real_root = tempfile::tempdir().unwrap();
std::fs::write(real_root.path().join("file.txt"), "hello").unwrap();
let link_parent = tempfile::tempdir().unwrap();
let symlinked_root = link_parent.path().join("root-link");
std::os::unix::fs::symlink(real_root.path(), &symlinked_root).unwrap();
let svc = ServeDir::new(&symlinked_root);
let req = Request::builder()
.uri("/file.txt")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(body_into_text(res.into_body()).await, "hello");
}
#[cfg(unix)]
#[tokio::test]
async fn servefile_symlinked_target_is_served() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("2026-06-19.log");
std::fs::write(&target, "log-line").unwrap();
let link = dir.path().join("latest.log");
std::os::unix::fs::symlink(&target, &link).unwrap();
let svc = ServeFile::new(link);
let req = Request::builder().uri("/").body(Body::empty()).unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(body_into_text(res.into_body()).await, "log-line");
}
#[cfg(unix)]
#[tokio::test]
async fn symlink_file_can_be_served_with_final_component_policy() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let secret_path = outside.path().join("secret.txt");
std::fs::write(&secret_path, "secret").unwrap();
std::os::unix::fs::symlink(&secret_path, root.path().join("link.txt")).unwrap();
let svc =
ServeDir::new(root.path()).with_symlink_policy(ServeDirSymlinkPolicy::AllowFinalComponent);
let req = Request::builder()
.uri("/link.txt")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(body_into_text(res.into_body()).await, "secret");
}
#[cfg(unix)]
#[tokio::test]
async fn symlink_directory_component_is_not_served() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let secret_path = outside.path().join("secret.txt");
std::fs::write(&secret_path, "secret").unwrap();
std::os::unix::fs::symlink(outside.path(), root.path().join("linked")).unwrap();
let svc = ServeDir::new(root.path());
let req = Request::builder()
.uri("/linked/secret.txt")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
}
#[cfg(unix)]
#[tokio::test]
async fn symlink_directory_component_is_not_served_with_final_component_policy() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let secret_path = outside.path().join("secret.txt");
std::fs::write(&secret_path, "secret").unwrap();
std::os::unix::fs::symlink(outside.path(), root.path().join("linked")).unwrap();
let svc =
ServeDir::new(root.path()).with_symlink_policy(ServeDirSymlinkPolicy::AllowFinalComponent);
let req = Request::builder()
.uri("/linked/secret.txt")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
}
#[cfg(unix)]
#[tokio::test]
async fn symlink_directory_component_can_be_served_with_allow_all_policy() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let secret_path = outside.path().join("secret.txt");
std::fs::write(&secret_path, "secret").unwrap();
std::os::unix::fs::symlink(outside.path(), root.path().join("linked")).unwrap();
let svc = ServeDir::new(root.path()).with_symlink_policy(ServeDirSymlinkPolicy::AllowAll);
let req = Request::builder()
.uri("/linked/secret.txt")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(body_into_text(res.into_body()).await, "secret");
}
async fn body_into_text<B>(body: B) -> String
where
B: StreamingBody<Data = rama_core::bytes::Bytes, Error: Into<rama_core::error::BoxError>>
+ Unpin,
{
let bytes = body.collect().await.unwrap().to_bytes();
String::from_utf8(bytes.to_vec()).unwrap()
}
#[tokio::test]
async fn access_cjk_percent_encoded_uri_path() {
let svc = ServeDir::new("../test-files");
test_access_cjk_percent_encoded_uri_path(svc).await;
}
#[tokio::test]
async fn access_cjk_percent_encoded_uri_path_embedded() {
const EMBEDDED_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR/../test-files");
let svc = ServeDir::new_embedded(EMBEDDED_FILES);
test_access_cjk_percent_encoded_uri_path(svc).await;
}
async fn test_access_cjk_percent_encoded_uri_path(svc: ServeDir) {
let cjk_filename_encoded = "%E4%BD%A0%E5%A5%BD%E4%B8%96%E7%95%8C.txt";
let req = Request::builder()
.uri(format!("/{cjk_filename_encoded}"))
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.headers()["content-type"], "text/plain");
}
#[tokio::test]
async fn access_space_percent_encoded_uri_path() {
let svc = ServeDir::new("../test-files");
test_access_space_percent_encoded_uri_path(svc).await;
}
#[tokio::test]
async fn access_space_percent_encoded_uri_path_embedded() {
const EMBEDDED_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR/../test-files");
let svc = ServeDir::new_embedded(EMBEDDED_FILES);
test_access_space_percent_encoded_uri_path(svc).await;
}
async fn test_access_space_percent_encoded_uri_path(svc: ServeDir) {
let encoded_filename = "filename%20with%20space.txt";
let req = Request::builder()
.uri(format!("/{encoded_filename}"))
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.headers()["content-type"], "text/plain");
}
#[tokio::test]
async fn read_partial_empty() {
let svc = ServeDir::new("../test-files");
test_read_partial_empty(svc).await;
}
#[tokio::test]
async fn read_partial_empty_embedded() {
const EMBEDDED_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR/../test-files");
let svc = ServeDir::new_embedded(EMBEDDED_FILES);
test_read_partial_empty(svc).await;
}
async fn test_read_partial_empty(svc: ServeDir) {
let req = Request::builder()
.uri("/empty.txt")
.header("Range", "bytes=0-")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(res.headers()["content-length"], "0");
assert_eq!(res.headers()["content-range"], "bytes 0-0/0");
let body = res.into_body().collect().await.unwrap().to_bytes();
assert!(body.is_empty());
}
#[tokio::test]
async fn read_partial_in_bounds() {
let svc = ServeDir::new("../rama-http");
test_read_partial_in_bounds(svc).await;
}
#[tokio::test]
async fn read_partial_in_bounds_embedded() {
const EMBEDDED_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR");
let svc = ServeDir::new_embedded(EMBEDDED_FILES);
test_read_partial_in_bounds(svc).await;
}
async fn test_read_partial_in_bounds(svc: ServeDir) {
let bytes_start_incl = 9;
let bytes_end_incl = 1023;
let req = Request::builder()
.uri("/README.md")
.header(
"Range",
format!("bytes={bytes_start_incl}-{bytes_end_incl}"),
)
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
let file_contents = std::fs::read("../rama-http/README.md").unwrap();
assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(
res.headers()["content-length"],
(bytes_end_incl - bytes_start_incl + 1).to_string()
);
assert!(
res.headers()["content-range"]
.to_str()
.unwrap()
.starts_with(&format!(
"bytes {}-{}/{}",
bytes_start_incl,
bytes_end_incl,
file_contents.len()
))
);
assert_eq!(res.headers()["content-type"], "text/markdown");
let body = res.into_body().collect().await.unwrap().to_bytes();
let source = Bytes::from(file_contents[bytes_start_incl..=bytes_end_incl].to_vec());
assert_eq!(body, source);
}
#[tokio::test]
async fn read_partial_accepts_out_of_bounds_range() {
let svc = ServeDir::new("../rama-http");
test_read_partial_accepts_out_of_bounds_range(svc).await;
}
#[tokio::test]
async fn read_partial_accepts_out_of_bounds_range_embedded() {
const EMBEDDED_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR");
let svc = ServeDir::new_embedded(EMBEDDED_FILES);
test_read_partial_accepts_out_of_bounds_range(svc).await;
}
async fn test_read_partial_accepts_out_of_bounds_range(svc: ServeDir) {
let bytes_start_incl = 0;
let bytes_end_excl = 9999999;
let requested_len = bytes_end_excl - bytes_start_incl;
let req = Request::builder()
.uri("/README.md")
.header(
"Range",
format!("bytes={}-{}", bytes_start_incl, requested_len - 1),
)
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
let file_contents = std::fs::read("../rama-http/README.md").unwrap();
assert_eq!(
res.headers()["content-range"],
&format!(
"bytes 0-{}/{}",
file_contents.len() - 1,
file_contents.len()
)
)
}
#[tokio::test]
async fn read_partial_errs_on_garbage_header() {
let svc = ServeDir::new("../rama-http");
test_read_partial_errs_on_garbage_header(svc).await;
}
#[tokio::test]
async fn read_partial_errs_on_garbage_header_embedded() {
const EMBEDDED_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR");
let svc = ServeDir::new_embedded(EMBEDDED_FILES);
test_read_partial_errs_on_garbage_header(svc).await;
}
async fn test_read_partial_errs_on_garbage_header(svc: ServeDir) {
let req = Request::builder()
.uri("/README.md")
.header("Range", "bad_format")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::RANGE_NOT_SATISFIABLE);
let file_contents = std::fs::read("../rama-http/README.md").unwrap();
assert_eq!(
res.headers()["content-range"],
&format!("bytes */{}", file_contents.len())
)
}
#[tokio::test]
async fn read_partial_errs_on_bad_range() {
let svc = ServeDir::new("../rama-http");
test_read_partial_errs_on_bad_range(svc).await;
}
#[tokio::test]
async fn read_partial_errs_on_bad_range_embedded() {
const EMBEDDED_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR");
let svc = ServeDir::new_embedded(EMBEDDED_FILES);
test_read_partial_errs_on_bad_range(svc).await;
}
async fn test_read_partial_errs_on_bad_range(svc: ServeDir) {
let req = Request::builder()
.uri("/README.md")
.header("Range", "bytes=-1-15")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::RANGE_NOT_SATISFIABLE);
let file_contents = std::fs::read("../rama-http/README.md").unwrap();
assert_eq!(
res.headers()["content-range"],
&format!("bytes */{}", file_contents.len())
)
}
#[tokio::test]
async fn accept_encoding_identity() {
let svc = ServeDir::new("../rama-http");
test_accept_encoding_identity(svc).await;
}
#[tokio::test]
async fn accept_encoding_identity_embedded() {
const EMBEDDED_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR");
let svc = ServeDir::new_embedded(EMBEDDED_FILES);
test_accept_encoding_identity(svc).await;
}
async fn test_accept_encoding_identity(svc: ServeDir) {
let req = Request::builder()
.uri("/README.md")
.header("Accept-Encoding", "identity")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert!(res.headers().get("content-encoding").is_none());
}
#[tokio::test]
async fn last_modified() {
let svc = ServeDir::new("../rama-http");
test_last_modified(svc).await;
}
#[tokio::test]
async fn last_modified_embedded() {
const EMBEDDED_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR");
let svc = ServeDir::new_embedded(EMBEDDED_FILES);
test_last_modified(svc).await;
}
async fn test_last_modified(svc: ServeDir) {
let req = Request::builder()
.uri("/README.md")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let last_modified = res
.headers()
.get(header::LAST_MODIFIED)
.expect("Missing last modified header!");
let svc_clone = svc.clone();
let req = Request::builder()
.uri("/README.md")
.header(header::IF_MODIFIED_SINCE, last_modified)
.body(Body::empty())
.unwrap();
let res = svc_clone.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::NOT_MODIFIED);
assert!(res.into_body().frame().await.is_none());
let svc_clone = svc.clone();
let req = Request::builder()
.uri("/README.md")
.header(header::IF_MODIFIED_SINCE, "Fri, 09 Aug 1996 14:21:40 GMT")
.body(Body::empty())
.unwrap();
let res = svc_clone.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let readme_content = std::fs::read("../rama-http/README.md").unwrap();
let body = res.into_body().collect().await.unwrap().to_bytes();
assert_eq!(body.as_ref(), &readme_content);
let svc_clone = svc.clone();
let req = Request::builder()
.uri("/README.md")
.header(header::IF_UNMODIFIED_SINCE, last_modified)
.body(Body::empty())
.unwrap();
let res = svc_clone.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = res.into_body().collect().await.unwrap().to_bytes();
assert_eq!(body.as_ref(), &readme_content);
let req = Request::builder()
.uri("/README.md")
.header(header::IF_UNMODIFIED_SINCE, "Fri, 09 Aug 1996 14:21:40 GMT")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::PRECONDITION_FAILED);
assert!(res.into_body().frame().await.is_none());
}
#[tokio::test]
async fn with_fallback_svc() {
let svc = ServeDir::new("..");
test_with_fallback_svc(svc).await;
}
#[tokio::test]
async fn with_fallback_svc_embedded() {
const EMBEDDED_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR/../test-files");
let svc = ServeDir::new_embedded(EMBEDDED_FILES);
test_with_fallback_svc(svc).await;
}
async fn test_with_fallback_svc(svc: ServeDir) {
async fn fallback(req: Request) -> Result<Response, Infallible> {
Ok(Response::new(Body::from(format!(
"from fallback {}",
req.uri().path_or_root()
))))
}
let svc = svc.fallback(service_fn(fallback));
let req = Request::builder()
.uri("/doesnt-exist")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_into_text(res.into_body()).await;
assert_eq!(body, "from fallback /doesnt-exist");
}
#[tokio::test]
async fn with_fallback_serve_file() {
let svc = ServeDir::new("..").fallback(ServeFile::new("../README.md"));
let req = Request::builder()
.uri("/doesnt-exist")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.headers()["content-type"], "text/markdown");
let body = body_into_text(res.into_body()).await;
let contents = std::fs::read_to_string("../README.md").unwrap();
assert_eq!(body, contents);
}
#[tokio::test]
async fn method_not_allowed() {
let svc = ServeDir::new("../rama-http");
test_method_not_allowed(svc).await;
}
#[tokio::test]
async fn method_not_allowed_embedded() {
const EMBEDDED_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR");
let svc = ServeDir::new_embedded(EMBEDDED_FILES);
test_method_not_allowed(svc).await;
}
async fn test_method_not_allowed(svc: ServeDir) {
let req = Request::builder()
.method(Method::POST)
.uri("/README.md")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(res.headers()[ALLOW], "GET,HEAD");
}
#[tokio::test]
async fn calling_fallback_on_not_allowed() {
async fn fallback(req: Request) -> Result<Response, Infallible> {
Ok(Response::new(Body::from(format!(
"from fallback {}",
req.uri().path_or_root()
))))
}
let svc = ServeDir::new("..")
.with_call_fallback_on_method_not_allowed(true)
.fallback(service_fn(fallback));
let req = Request::builder()
.method(Method::POST)
.uri("/doesnt-exist")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_into_text(res.into_body()).await;
assert_eq!(body, "from fallback /doesnt-exist");
}
#[tokio::test]
async fn method_not_allowed_without_fallback() {
let svc = ServeDir::new("..").with_call_fallback_on_method_not_allowed(true);
let req = Request::builder()
.method(Method::POST)
.uri("/README.md")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(res.headers()[ALLOW], "GET,HEAD");
}
#[tokio::test]
async fn with_fallback_svc_and_not_append_index_html_on_directories() {
async fn fallback(req: Request) -> Result<Response, Infallible> {
Ok(Response::new(Body::from(format!(
"from fallback {}",
req.uri().path_or_root()
))))
}
let svc = ServeDir::new("..")
.with_directory_serve_mode(DirectoryServeMode::NotFound)
.fallback(service_fn(fallback));
let req = Request::builder().uri("/").body(Body::empty()).unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_into_text(res.into_body()).await;
assert_eq!(body, "from fallback /");
}
#[tokio::test]
async fn calls_fallback_on_invalid_paths() {
async fn fallback<T>(_: T) -> Result<Response, Infallible> {
let mut res = Response::new(Body::empty());
res.headers_mut()
.insert("from-fallback", "1".parse().unwrap());
Ok(res)
}
let svc = ServeDir::new("..").fallback(service_fn(fallback));
let req = Request::builder()
.uri("/weird_%c3%28_path")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.headers()["from-fallback"], "1");
}
#[tokio::test]
async fn calls_fallback_on_invalid_filenames() {
async fn fallback<T>(_: T) -> Result<Response<Body>, Infallible> {
let mut res = Response::new(Body::empty());
res.headers_mut()
.insert("from-fallback", "1".parse().unwrap());
Ok(res)
}
let svc = ServeDir::new("..").fallback(service_fn(fallback));
let req = Request::builder()
.uri("/invalid|path")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.headers()["from-fallback"], "1");
}
#[tokio::test]
async fn calls_fallback_on_null() {
async fn fallback<T>(_: T) -> Result<Response<Body>, Infallible> {
let mut res = Response::new(Body::empty());
res.headers_mut()
.insert("from-fallback", "1".parse().unwrap());
Ok(res)
}
let svc = ServeDir::new("..").fallback(service_fn(fallback));
let req = Request::builder()
.uri("/invalid-path%00")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.headers()["from-fallback"], "1");
}
#[tokio::test]
async fn identity_encoding_does_not_strip_extension() {
let svc = ServeDir::new("../test-files");
test_identity_encoding_does_not_strip_extension(svc, Method::GET).await;
}
#[tokio::test]
async fn identity_encoding_does_not_strip_extension_embedded() {
const EMBEDDED_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR/../test-files");
let svc = ServeDir::new_embedded(EMBEDDED_FILES);
test_identity_encoding_does_not_strip_extension(svc, Method::GET).await;
}
#[tokio::test]
async fn identity_encoding_does_not_strip_extension_head_request() {
let svc = ServeDir::new("../test-files");
test_identity_encoding_does_not_strip_extension(svc, Method::HEAD).await;
}
#[tokio::test]
async fn identity_encoding_does_not_strip_extension_head_request_embedded() {
const EMBEDDED_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR/../test-files");
let svc = ServeDir::new_embedded(EMBEDDED_FILES);
test_identity_encoding_does_not_strip_extension(svc, Method::HEAD).await;
}
async fn test_identity_encoding_does_not_strip_extension(svc: ServeDir, method: Method) {
let req = Request::builder()
.method(method)
.uri("/hello.txt.foobar")
.header("Accept-Encoding", "identity")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn precompressed_response_includes_vary_header() {
let svc = ServeDir::new("../test-files").with_precompressed_gzip();
let req = Request::builder()
.uri("/precompressed.txt")
.header("Accept-Encoding", "gzip")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.headers()["content-encoding"], "gzip");
assert_eq!(res.headers()["vary"], "accept-encoding");
}
#[tokio::test]
async fn no_vary_header_without_precompressed_serving() {
let svc = ServeDir::new("../test-files");
let req = Request::builder()
.uri("/precompressed.txt")
.header("Accept-Encoding", "gzip")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert!(res.headers().get("vary").is_none());
}
#[tokio::test]
async fn vary_header_present_when_precompressed_configured_but_fallback_to_uncompressed() {
let svc = ServeDir::new("../test-files").with_precompressed_gzip();
let req = Request::builder()
.uri("/precompressed.txt")
.header("Accept-Encoding", "br")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert!(res.headers().get("content-encoding").is_none());
assert_eq!(res.headers()["vary"], "accept-encoding");
}
#[tokio::test]
async fn vary_header_present_when_precompressed_configured_but_no_accept_encoding() {
let svc = ServeDir::new("../test-files").with_precompressed_gzip();
let req = Request::builder()
.uri("/precompressed.txt")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert!(res.headers().get("content-encoding").is_none());
assert_eq!(res.headers()["vary"], "accept-encoding");
}
#[tokio::test]
async fn precompressed_head_request_includes_vary_header() {
let svc = ServeDir::new("../test-files").with_precompressed_gzip();
let req = Request::builder()
.uri("/precompressed.txt")
.method(Method::HEAD)
.header("Accept-Encoding", "gzip")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.headers()["content-encoding"], "gzip");
assert_eq!(res.headers()["vary"], "accept-encoding");
}
#[tokio::test]
async fn not_found_when_file_requested_with_trailing_slash() {
let svc = ServeDir::new("../test-files");
let req = Request::builder()
.uri("/index.html/")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
assert!(res.headers().get(header::CONTENT_TYPE).is_none());
let body = body_into_text(res.into_body()).await;
assert!(body.is_empty());
}
#[tokio::test]
async fn file_requested_with_trailing_slash_with_fallback() {
async fn fallback(req: Request) -> Result<Response, Infallible> {
Ok(Response::new(Body::from(format!(
"from fallback {}",
req.uri().path_or_root()
))))
}
let svc = ServeDir::new("../test-files").fallback(service_fn(fallback));
let req = Request::builder()
.uri("/index.html/")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_into_text(res.into_body()).await;
assert_eq!(body, "from fallback /index.html/");
}
#[tokio::test]
async fn directory_with_trailing_slash_appends_index_html() {
let svc = ServeDir::new("../test-files");
let req = Request::builder().uri("/foo/").body(Body::empty()).unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.headers()["content-type"], "text/html");
let body = body_into_text(res.into_body()).await;
#[cfg(target_os = "windows")]
assert_eq!(body, "<b>HTML!</b>\r\n");
#[cfg(not(target_os = "windows"))]
assert_eq!(body, "<b>HTML!</b>\n");
}
#[tokio::test]
async fn root_with_trailing_slash_serves_index_html() {
let svc = ServeDir::new("../test-files");
let req = Request::builder().uri("/").body(Body::empty()).unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.headers()["content-type"], "text/html");
let body = body_into_text(res.into_body()).await;
#[cfg(target_os = "windows")]
assert_eq!(body, "<b>HTML!</b>\r\n");
#[cfg(not(target_os = "windows"))]
assert_eq!(body, "<b>HTML!</b>\n");
}
#[tokio::test]
async fn html_as_default_extension_serves_html_file() {
let svc = ServeDir::new("../test-files").with_html_as_default_extension(true);
let req = Request::builder().uri("/page").body(Body::empty()).unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.headers()["content-type"], "text/html");
let body = body_into_text(res.into_body()).await;
#[cfg(target_os = "windows")]
assert_eq!(body, "<b>page</b>\r\n");
#[cfg(not(target_os = "windows"))]
assert_eq!(body, "<b>page</b>\n");
}
#[tokio::test]
async fn html_as_default_extension_not_found_when_html_missing() {
let svc = ServeDir::new("../test-files").with_html_as_default_extension(true);
let req = Request::builder()
.uri("/nonexistent")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn html_as_default_extension_does_not_apply_when_extension_present() {
let svc = ServeDir::new("../test-files").with_html_as_default_extension(true);
let req = Request::builder()
.uri("/precompressed.txt")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.headers()["content-type"], "text/plain");
}
#[cfg(windows)]
fn verify_windows_device(name: &str, is_positive: bool) {
use std::fs::OpenOptions;
use std::os::windows::io::AsRawHandle;
unsafe extern "system" {
fn GetFileType(hFile: *mut std::ffi::c_void) -> u32;
}
const FILE_TYPE_CHAR: u32 = 0x0002;
let file_res = OpenOptions::new().read(true).open(name);
if let Ok(file) = file_res {
let handle = file.as_raw_handle();
let file_type = unsafe { GetFileType(handle as _) };
if is_positive {
assert_eq!(
file_type, FILE_TYPE_CHAR,
"Expected Windows to treat {name:?} as a system character device",
);
} else {
assert_ne!(
file_type, FILE_TYPE_CHAR,
"Expected Windows NOT to treat {name:?} as a system character device",
);
}
}
}
#[test]
fn test_is_reserved_dos_name() {
use rama_utils::fs::is_reserved_device_name;
let positives = [
"CON",
"con",
"Con",
"PRN",
"Prn",
"AUX",
"aux",
"NUL",
"nul",
"CONIN$",
"conin$",
"CONOUT$",
"ConOut$",
"COM0",
"com0",
"Com0",
"COM1",
"com9",
"Com3",
"COM¹",
"com³",
"LPT0",
"lpt0",
"Lpt0",
"LPT1",
"lpt9",
"Lpt3",
"LPT¹",
"lpt²",
"CON.txt",
"con.anything",
"AUX.tar.gz",
"NUL.",
"COM1:",
"com9.ext:",
"CON ",
"CON ",
"NUL .txt",
"CON\t",
"CON\n",
"CON\r",
"CON \t",
"CON\x0B",
];
for name in positives {
assert!(is_reserved_device_name(name), "Expected true for {name:?}",);
#[cfg(windows)]
verify_windows_device(name, true);
}
let negatives = [
"C0N",
"PRN1",
"AUX42",
"NULL",
"CONIN",
"CONOUT",
"COM10",
"LPT42",
"COMa",
"LPTb",
"safe.txt",
"index.html",
"aux-file.js",
"contact.html",
];
for name in negatives {
assert!(
!is_reserved_device_name(name),
"Expected false for {name:?}",
);
#[cfg(windows)]
verify_windows_device(name, false);
}
}
#[test]
fn test_build_and_validate_path_reserved_dos_names() {
use super::ServeVariant;
use std::path::Path;
let variant = ServeVariant::Directory {
serve_mode: DirectoryServeMode::AppendIndexHtml,
html_as_default_extension: false,
};
let base = Path::new("/base");
let reserved = ["/CON", "/CON.txt", "/com0", "/com1", "/com¹", "/CONIN$"];
for path in reserved {
let result = variant.build_and_validate_path(&DirSource::Filesystem(base.into()), path);
if cfg!(windows) {
assert!(result.is_none(), "Expected None for path: {path}");
} else {
assert!(result.is_some(), "Expected Some for path: {path}");
}
}
}
#[tokio::test]
async fn etag_is_set_on_response() {
let svc = ServeDir::new("../rama-http");
let req = Request::builder()
.uri("/README.md")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let etag = res
.headers()
.get(header::ETAG)
.expect("missing ETag header");
let etag_str = etag.to_str().unwrap();
assert!(etag_str.starts_with('"'));
assert!(etag_str.ends_with('"'));
assert!(!etag_str.starts_with("W/"));
assert!(etag_str.contains('-'));
}
#[tokio::test]
async fn if_none_match_returns_304() {
let svc = ServeDir::new("../rama-http");
let req = Request::builder()
.uri("/README.md")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let etag = res.headers().get(header::ETAG).unwrap().clone();
let last_modified = res.headers().get(header::LAST_MODIFIED).unwrap().clone();
let req = Request::builder()
.uri("/README.md")
.header(header::IF_NONE_MATCH, &etag)
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::NOT_MODIFIED);
assert_eq!(res.headers().get(header::ETAG).unwrap(), &etag);
assert_eq!(
res.headers().get(header::LAST_MODIFIED).unwrap(),
&last_modified
);
assert!(res.into_body().frame().await.is_none());
}
#[tokio::test]
async fn if_none_match_with_non_matching_etag_returns_200() {
let svc = ServeDir::new("../rama-http");
let req = Request::builder()
.uri("/README.md")
.header(header::IF_NONE_MATCH, "\"not-a-real-etag\"")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
#[tokio::test]
async fn if_none_match_wildcard_returns_304() {
let svc = ServeDir::new("../rama-http");
let req = Request::builder()
.uri("/README.md")
.header(header::IF_NONE_MATCH, "*")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::NOT_MODIFIED);
}
#[tokio::test]
async fn if_match_with_matching_etag_succeeds() {
let svc = ServeDir::new("../rama-http");
let req = Request::builder()
.uri("/README.md")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let etag = res.headers().get(header::ETAG).unwrap().clone();
let req = Request::builder()
.uri("/README.md")
.header(header::IF_MATCH, etag)
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
#[tokio::test]
async fn if_match_with_non_matching_etag_returns_412() {
let svc = ServeDir::new("../rama-http");
let req = Request::builder()
.uri("/README.md")
.header(header::IF_MATCH, "\"not-a-real-etag\"")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::PRECONDITION_FAILED);
}
#[tokio::test]
async fn if_none_match_takes_precedence_over_if_modified_since() {
let svc = ServeDir::new("../rama-http");
let req = Request::builder()
.uri("/README.md")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let etag = res.headers().get(header::ETAG).unwrap().clone();
let req = Request::builder()
.uri("/README.md")
.header(header::IF_NONE_MATCH, etag)
.header(header::IF_MODIFIED_SINCE, "Fri, 09 Aug 1996 14:21:40 GMT")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::NOT_MODIFIED);
}
#[tokio::test]
async fn if_match_takes_precedence_over_if_unmodified_since() {
let svc = ServeDir::new("../rama-http");
let req = Request::builder()
.uri("/README.md")
.header(header::IF_MATCH, "\"not-a-real-etag\"")
.header(header::IF_UNMODIFIED_SINCE, "Sun, 01 Jan 2100 00:00:00 GMT")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::PRECONDITION_FAILED);
}
#[tokio::test]
async fn if_none_match_weak_comparison() {
let svc = ServeDir::new("../rama-http");
let req = Request::builder()
.uri("/README.md")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let etag = res
.headers()
.get(header::ETAG)
.unwrap()
.to_str()
.unwrap()
.to_owned();
let weak_etag = format!("W/{etag}");
let req = Request::builder()
.uri("/README.md")
.header(header::IF_NONE_MATCH, &weak_etag)
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::NOT_MODIFIED);
}
#[tokio::test]
async fn if_match_strong_comparison_rejects_weak_etag() {
let svc = ServeDir::new("../rama-http");
let req = Request::builder()
.uri("/README.md")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let etag = res
.headers()
.get(header::ETAG)
.unwrap()
.to_str()
.unwrap()
.to_owned();
let weak_etag = format!("W/{etag}");
let req = Request::builder()
.uri("/README.md")
.header(header::IF_MATCH, &weak_etag)
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::PRECONDITION_FAILED);
}
#[tokio::test]
async fn if_none_match_multiple_etags() {
let svc = ServeDir::new("../rama-http");
let req = Request::builder()
.uri("/README.md")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
let etag = res
.headers()
.get(header::ETAG)
.unwrap()
.to_str()
.unwrap()
.to_owned();
let multi = format!("\"bogus\", {etag}, \"also-bogus\"");
let req = Request::builder()
.uri("/README.md")
.header(header::IF_NONE_MATCH, &multi)
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::NOT_MODIFIED);
}
#[tokio::test]
async fn if_match_wildcard_succeeds() {
let svc = ServeDir::new("../rama-http");
let req = Request::builder()
.uri("/README.md")
.header(header::IF_MATCH, "*")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
#[tokio::test]
async fn etag_on_head_request() {
let svc = ServeDir::new("../rama-http");
let req = Request::builder()
.uri("/README.md")
.method(Method::HEAD)
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert!(res.headers().get(header::ETAG).is_some());
}
#[tokio::test]
async fn if_modified_since_304_includes_etag() {
let svc = ServeDir::new("../rama-http");
let req = Request::builder()
.uri("/README.md")
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
let last_modified = res.headers().get(header::LAST_MODIFIED).unwrap().clone();
let etag = res.headers().get(header::ETAG).unwrap().clone();
let req = Request::builder()
.uri("/README.md")
.header(header::IF_MODIFIED_SINCE, &last_modified)
.body(Body::empty())
.unwrap();
let res = svc.serve(req).await.unwrap();
assert_eq!(res.status(), StatusCode::NOT_MODIFIED);
assert_eq!(res.headers().get(header::ETAG).unwrap(), &etag);
assert_eq!(
res.headers().get(header::LAST_MODIFIED).unwrap(),
&last_modified
);
}