use headers::{AcceptRanges, HeaderMap, HeaderMapExt, HeaderValue};
use hyper::{Body, Method, Response, StatusCode, header::CONTENT_ENCODING, header::CONTENT_LENGTH};
use std::cell::RefCell;
use std::collections::HashSet;
use std::fs::{File, Metadata};
use std::path::{Path, PathBuf};
use crate::Result;
use crate::conditional_headers::ConditionalHeaders;
use crate::fs::meta::{FileMetadata, try_file_open, try_metadata, try_metadata_with_html_suffix};
use crate::fs::path::{PathExt, sanitize_path};
use crate::http_ext::{HTTP_SUPPORTED_METHODS, MethodExt};
use crate::response::response_body;
#[cfg(feature = "experimental")]
use crate::mem_cache::{cache, cache::MemCacheOpts};
use crate::compression_static;
#[cfg(feature = "directory-listing")]
use crate::{
directory_listing,
directory_listing::{DirListFmt, DirListOpts},
};
#[cfg(feature = "directory-listing-download")]
use crate::directory_listing_download::{
DOWNLOAD_PARAM_KEY, DirDownloadFmt, DirDownloadOpts, archive_reply,
};
const DEFAULT_INDEX_FILES: &[&str; 1] = &["index.html"];
const CONTAINMENT_CACHE_CAP: usize = 1024;
#[cfg(test)]
thread_local! {
static FILE_OPEN_RACE_HOOK: RefCell<Option<Box<dyn FnOnce()>>> = RefCell::new(None);
static PRE_FILE_OPEN_RACE_HOOK: RefCell<Option<Box<dyn FnOnce()>>> = RefCell::new(None);
}
thread_local! {
static CONTAINMENT_CACHE: RefCell<HashSet<PathBuf>> =
RefCell::new(HashSet::with_capacity(64));
}
#[cfg(all(test, unix))]
fn set_file_open_race_hook(hook: impl FnOnce() + 'static) {
FILE_OPEN_RACE_HOOK.with(|slot| *slot.borrow_mut() = Some(Box::new(hook)));
}
#[cfg(test)]
fn run_file_open_race_hook() {
FILE_OPEN_RACE_HOOK.with(|slot| {
if let Some(hook) = slot.borrow_mut().take() {
hook();
}
});
}
#[cfg(all(test, unix))]
fn set_pre_file_open_race_hook(hook: impl FnOnce() + 'static) {
PRE_FILE_OPEN_RACE_HOOK.with(|slot| *slot.borrow_mut() = Some(Box::new(hook)));
}
#[cfg(test)]
fn run_pre_file_open_race_hook() {
PRE_FILE_OPEN_RACE_HOOK.with(|slot| {
if let Some(hook) = slot.borrow_mut().take() {
hook();
}
});
}
#[cfg(test)]
thread_local! {
static CANONICALIZE_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(all(test, unix))]
fn reset_canonicalize_calls() {
CANONICALIZE_CALLS.with(|counter| counter.set(0));
}
#[cfg(all(test, unix))]
fn canonicalize_calls() -> usize {
CANONICALIZE_CALLS.with(std::cell::Cell::get)
}
#[inline]
fn cache_safe_probe(probe: &Path) {
CONTAINMENT_CACHE.with(|c| {
let mut set = c.borrow_mut();
if set.len() >= CONTAINMENT_CACHE_CAP {
set.clear();
}
set.insert(probe.to_path_buf());
});
}
pub struct HandleOpts<'a> {
pub method: &'a Method,
#[cfg(feature = "experimental")]
pub memory_cache: Option<&'a MemCacheOpts>,
pub headers: &'a HeaderMap<HeaderValue>,
pub base_path: &'a PathBuf,
pub uri_path: &'a str,
pub index_files: &'a [&'a str],
pub uri_query: Option<&'a str>,
#[cfg(feature = "directory-listing")]
#[cfg_attr(docsrs, doc(cfg(feature = "directory-listing")))]
pub dir_listing: bool,
#[cfg(feature = "directory-listing")]
#[cfg_attr(docsrs, doc(cfg(feature = "directory-listing")))]
pub dir_listing_order: u8,
#[cfg(feature = "directory-listing")]
#[cfg_attr(docsrs, doc(cfg(feature = "directory-listing")))]
pub dir_listing_format: &'a DirListFmt,
#[cfg(feature = "directory-listing-download")]
#[cfg_attr(docsrs, doc(cfg(feature = "directory-listing-download")))]
pub dir_listing_download: &'a [DirDownloadFmt],
pub redirect_trailing_slash: bool,
pub compression_static: bool,
pub ignore_hidden_files: bool,
pub disable_symlinks: bool,
}
enum SafeOpenPath {
Canonical(PathBuf),
Requested,
}
impl SafeOpenPath {
#[inline]
fn as_path<'a>(&'a self, requested: &'a Path) -> &'a Path {
match self {
Self::Canonical(path) => path.as_path(),
Self::Requested => requested,
}
}
}
fn enforce_path_security(
file_path: &Path,
is_dir: bool,
resolved_exists: bool,
opts: &HandleOpts<'_>,
use_containment_cache: bool,
) -> Result<SafeOpenPath, StatusCode> {
let is_placeholder = is_dir && !resolved_exists;
let mut probe = file_path.to_path_buf();
if is_placeholder {
probe.pop();
}
let relative = probe.strip_prefix(opts.base_path).map_err(|err| {
tracing::error!(
"unable to strip prefix from file path '{}': {}",
file_path.display(),
err,
);
StatusCode::NOT_FOUND
})?;
let contained = enforce_containment(&probe, opts.base_path, use_containment_cache)?;
if opts.disable_symlinks {
enforce_symlink_policy(relative, opts.base_path, file_path)?;
}
let hidden_relative = if is_dir && resolved_exists {
relative.parent().unwrap_or(relative)
} else {
relative
};
if opts.ignore_hidden_files && hidden_relative.is_hidden() {
tracing::trace!(
"considering hidden file {} as not found",
file_path.display()
);
return Err(StatusCode::NOT_FOUND);
}
if is_placeholder {
return Ok(SafeOpenPath::Requested);
}
Ok(match contained {
Some(resolved) => SafeOpenPath::Canonical(resolved),
None => SafeOpenPath::Requested,
})
}
fn enforce_containment(
probe: &Path,
base_path: &Path,
use_cache: bool,
) -> Result<Option<PathBuf>, StatusCode> {
if use_cache && CONTAINMENT_CACHE.with(|cache| cache.borrow().contains(probe)) {
return Ok(None);
}
#[cfg(test)]
CANONICALIZE_CALLS.with(|counter| counter.set(counter.get() + 1));
let resolved = probe.canonicalize().map_err(|err| {
tracing::error!(
"unable to resolve '{}' symlink path: {}",
probe.display(),
err,
);
StatusCode::NOT_FOUND
})?;
if resolved.starts_with(base_path) {
if use_cache {
cache_safe_probe(probe);
}
return Ok(Some(resolved));
}
let base_path = base_path.canonicalize().map_err(|err| {
tracing::error!(
"unable to resolve '{}' base path: {}",
base_path.display(),
err,
);
StatusCode::NOT_FOUND
})?;
if !resolved.starts_with(&base_path) {
tracing::error!(
"file path '{}' resolves outside of the base path, access denied",
resolved.display()
);
return Err(StatusCode::NOT_FOUND);
}
if use_cache {
cache_safe_probe(probe);
}
Ok(Some(resolved))
}
fn enforce_symlink_policy(
relative: &Path,
base_path: &Path,
file_path: &Path,
) -> Result<(), StatusCode> {
let has_symlink = relative.contains_symlink(base_path).map_err(|err| {
tracing::error!(
"unable to check if file path '{}' contains symlink: {}",
relative.display(),
err,
);
StatusCode::NOT_FOUND
})?;
if has_symlink {
tracing::warn!(
"file path '{}' contains a symlink, access denied",
file_path.display()
);
return Err(StatusCode::FORBIDDEN);
}
Ok(())
}
pub struct StaticFileResponse {
pub resp: Response<Body>,
pub file_path: PathBuf,
}
pub async fn handle(opts: &HandleOpts<'_>) -> Result<StaticFileResponse, StatusCode> {
let method = opts.method;
if !method.is_allowed() {
return Err(StatusCode::METHOD_NOT_ALLOWED);
}
let uri_path = opts.uri_path;
let mut file_path = sanitize_path(opts.base_path, uri_path)?;
let headers_opt = opts.headers;
#[cfg(feature = "experimental")]
if opts.memory_cache.is_some() {
if opts.redirect_trailing_slash && uri_path.ends_with('/') {
file_path.push("index.html");
}
if let Some(result) = cache::get_or_acquire(file_path.as_path(), headers_opt).await {
match result {
cache::CacheResult::Hit(result) => {
return Ok(StaticFileResponse {
resp: result?,
file_path,
});
}
cache::CacheResult::Error(status) => {
return Err(status);
}
cache::CacheResult::Miss(_permit) => {
}
}
}
}
let FileMetadata {
file_path,
is_dir,
resolved_exists,
precompressed_variant,
} = get_composed_file_metadata(
&mut file_path,
headers_opt,
opts.compression_static,
opts.index_files,
)?;
let use_containment_cache = opts.disable_symlinks;
let safe_path = enforce_path_security(
file_path,
is_dir,
resolved_exists,
opts,
use_containment_cache,
)?;
let safe_precompressed_path = match precompressed_variant.as_ref() {
Some((precompressed_path, _)) => Some(enforce_path_security(
precompressed_path,
false,
true,
opts,
use_containment_cache,
)?),
None => None,
};
let resp_file_path = file_path.to_owned();
if is_dir && opts.redirect_trailing_slash && !uri_path.ends_with('/') {
let query = opts.uri_query.map_or(String::new(), |s| ["?", s].concat());
let uri = [uri_path, "/", query.as_str()].concat();
let loc = match HeaderValue::from_str(uri.as_str()) {
Ok(val) => val,
Err(err) => {
tracing::error!("invalid header value from current uri: {:?}", err);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
let mut resp = Response::new(Body::empty());
resp.headers_mut().insert(hyper::header::LOCATION, loc);
*resp.status_mut() = StatusCode::PERMANENT_REDIRECT;
tracing::trace!("uri doesn't end with a slash so redirecting permanently");
return Ok(StaticFileResponse {
resp,
file_path: resp_file_path,
});
}
if method.is_options() {
let mut resp = Response::new(Body::empty());
*resp.status_mut() = StatusCode::NO_CONTENT;
resp.headers_mut()
.typed_insert(headers::Allow::from_iter(HTTP_SUPPORTED_METHODS.clone()));
resp.headers_mut().typed_insert(AcceptRanges::bytes());
return Ok(StaticFileResponse {
resp,
file_path: resp_file_path,
});
}
#[cfg(feature = "directory-listing")]
if is_dir && opts.dir_listing && !resolved_exists {
#[cfg(feature = "directory-listing-download")]
if !opts.dir_listing_download.is_empty()
&& let Some((_k, _dl_archive_opt)) =
form_urlencoded::parse(opts.uri_query.unwrap_or("").as_bytes())
.find(|(k, _v)| k == DOWNLOAD_PARAM_KEY)
{
let mut fp = file_path.clone();
fp.pop();
if let Some(filename) = fp.file_name() {
let resp = archive_reply(
filename,
&fp,
DirDownloadOpts {
method,
disable_symlinks: opts.disable_symlinks,
ignore_hidden_files: opts.ignore_hidden_files,
},
);
return Ok(StaticFileResponse {
resp,
file_path: resp_file_path,
});
} else {
tracing::error!("Unable to get filename from {}", fp.to_string_lossy());
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
}
let resp = directory_listing::auto_index(DirListOpts {
root_path: opts.base_path.as_path(),
method,
current_path: uri_path,
uri_query: opts.uri_query,
filepath: file_path,
dir_listing_order: opts.dir_listing_order,
dir_listing_format: opts.dir_listing_format,
ignore_hidden_files: opts.ignore_hidden_files,
disable_symlinks: opts.disable_symlinks,
#[cfg(feature = "directory-listing-download")]
dir_listing_download: opts.dir_listing_download,
})?;
return Ok(StaticFileResponse {
resp,
file_path: resp_file_path,
});
}
if let Some((precomp_path, precomp_encoding)) = precompressed_variant {
let open_path = safe_precompressed_path
.as_ref()
.map_or(precomp_path.as_path(), |safe| safe.as_path(&precomp_path));
#[cfg(test)]
run_pre_file_open_race_hook();
let (file, precomp_meta) = try_file_open(open_path)?;
#[cfg(test)]
run_file_open_race_hook();
let mut resp = file_reply(
headers_opt,
file_path,
&precomp_meta,
file,
#[cfg(feature = "experimental")]
None,
)?;
resp.headers_mut().remove(CONTENT_LENGTH);
let encoding = match HeaderValue::from_str(precomp_encoding.as_str()) {
Ok(val) => val,
Err(err) => {
tracing::error!(
"unable to parse header value from content encoding: {:?}",
err
);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
resp.headers_mut().insert(CONTENT_ENCODING, encoding);
return Ok(StaticFileResponse {
resp,
file_path: resp_file_path,
});
}
#[cfg(test)]
run_pre_file_open_race_hook();
let (file, serve_meta) = try_file_open(safe_path.as_path(file_path))?;
#[cfg(test)]
run_file_open_race_hook();
#[cfg(feature = "experimental")]
let resp = file_reply(headers_opt, file_path, &serve_meta, file, opts.memory_cache)?;
#[cfg(not(feature = "experimental"))]
let resp = file_reply(headers_opt, file_path, &serve_meta, file)?;
Ok(StaticFileResponse {
resp,
file_path: resp_file_path,
})
}
fn get_composed_file_metadata<'a>(
mut file_path: &'a mut PathBuf,
headers: &'a HeaderMap<HeaderValue>,
compression_static: bool,
mut index_files: &'a [&'a str],
) -> Result<FileMetadata<'a>, StatusCode> {
tracing::trace!("getting metadata for file {}", file_path.display());
match try_metadata(file_path) {
Ok((_, is_dir)) => {
let mut resolved_exists = !is_dir;
if is_dir {
if index_files.is_empty() {
index_files = DEFAULT_INDEX_FILES;
}
for index in index_files {
tracing::debug!("dir: appending {} to the directory path", index);
file_path.push(index);
if matches!(try_metadata(file_path), Ok((_, false))) {
resolved_exists = true;
break;
}
file_path.pop();
let new_meta: Option<Metadata>;
(file_path, new_meta) = try_metadata_with_html_suffix(file_path);
if new_meta.is_some() {
resolved_exists = true;
break;
}
}
if !resolved_exists && !index_files.is_empty() {
file_path.push(index_files.last().unwrap());
}
}
let precompressed_variant = (compression_static && resolved_exists)
.then(|| compression_static::precompressed_variant(file_path, headers))
.flatten()
.map(|p| (p.file_path, p.encoding));
Ok(FileMetadata {
file_path,
is_dir,
resolved_exists,
precompressed_variant,
})
}
Err(err) => {
let new_meta: Option<Metadata>;
(file_path, new_meta) = try_metadata_with_html_suffix(file_path);
if new_meta.is_none() {
return Err(err);
}
let precompressed_variant = compression_static
.then(|| compression_static::precompressed_variant(file_path, headers))
.flatten()
.map(|p| (p.file_path, p.encoding));
Ok(FileMetadata {
file_path,
is_dir: false,
resolved_exists: true,
precompressed_variant,
})
}
}
}
fn file_reply<'a>(
headers: &'a HeaderMap<HeaderValue>,
path: &'a Path,
meta: &'a Metadata,
file: File,
#[cfg(feature = "experimental")] memory_cache: Option<&'a MemCacheOpts>,
) -> Result<Response<Body>, StatusCode> {
let conditionals = ConditionalHeaders::new(headers);
#[cfg(feature = "experimental")]
let resp = response_body(file, path, meta, conditionals, memory_cache);
#[cfg(not(feature = "experimental"))]
let resp = response_body(file, path, meta, conditionals);
resp
}
#[cfg(all(test, unix))]
mod tests {
use super::{
HandleOpts, StaticFileResponse, canonicalize_calls, handle, reset_canonicalize_calls,
set_file_open_race_hook, set_pre_file_open_race_hook,
};
use headers::HeaderMap;
use hyper::{Method, StatusCode};
use std::fs;
use std::os::unix::fs::symlink;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
struct TempDir {
path: PathBuf,
}
impl TempDir {
fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
let path = std::env::temp_dir().join(format!(
"sws-static-files-{tag}-{}-{nanos}-{seq}",
std::process::id(),
));
fs::create_dir_all(&path).expect("unexpected error creating temporary directory");
Self { path }
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
fn web_root(tag: &str) -> (TempDir, PathBuf) {
let temp_dir = TempDir::new(tag);
let root_dir = temp_dir.path().join("public");
fs::create_dir(&root_dir).expect("unexpected error creating web root");
(temp_dir, root_dir)
}
struct RequestOpts<'a> {
uri_path: &'a str,
encoding: &'a str,
disable_symlinks: bool,
ignore_hidden_files: bool,
index_files: &'a [&'a str],
}
impl Default for RequestOpts<'_> {
fn default() -> Self {
Self {
uri_path: "/app.js",
encoding: "identity",
disable_symlinks: false,
ignore_hidden_files: false,
index_files: &[],
}
}
}
async fn request_with(
root_dir: &PathBuf,
opts: RequestOpts<'_>,
) -> Result<StaticFileResponse, StatusCode> {
let mut headers = HeaderMap::new();
headers.insert(
http::header::ACCEPT_ENCODING,
opts.encoding.parse().expect("unexpected invalid encoding"),
);
handle(&HandleOpts {
method: &Method::GET,
headers: &headers,
base_path: root_dir,
uri_path: opts.uri_path,
uri_query: None,
#[cfg(feature = "experimental")]
memory_cache: None,
#[cfg(feature = "directory-listing")]
dir_listing: false,
#[cfg(feature = "directory-listing")]
dir_listing_order: 6,
#[cfg(feature = "directory-listing")]
dir_listing_format: &crate::directory_listing::DirListFmt::Html,
#[cfg(feature = "directory-listing-download")]
dir_listing_download: &[],
redirect_trailing_slash: true,
compression_static: true,
ignore_hidden_files: opts.ignore_hidden_files,
disable_symlinks: opts.disable_symlinks,
index_files: opts.index_files,
})
.await
}
async fn request(
root_dir: &PathBuf,
encoding: &str,
disable_symlinks: bool,
) -> Result<StaticFileResponse, StatusCode> {
request_with(
root_dir,
RequestOpts {
encoding,
disable_symlinks,
..Default::default()
},
)
.await
}
async fn body_of(result: StaticFileResponse) -> Vec<u8> {
hyper::body::to_bytes(result.resp.into_body())
.await
.expect("unexpected bytes error during body conversion")
.to_vec()
}
#[tokio::test]
async fn opened_precompressed_file_retargeted_before_response_uses_opened_handle() {
for (extension, encoding) in [("gz", "gzip"), ("br", "br"), ("zst", "zstd")] {
for disable_symlinks in [false, true] {
let (temp_dir, root_dir) = web_root("open-race");
fs::write(root_dir.join("app.js"), "source")
.expect("unexpected error writing source file");
let inside_marker = root_dir.join("inside-marker");
fs::write(&inside_marker, "inside")
.expect("unexpected error writing inside marker");
let outside_marker = temp_dir.path().join("outside-marker");
fs::write(&outside_marker, "beyond")
.expect("unexpected error writing outside marker");
let precompressed_path = root_dir.join(format!("app.js.{extension}"));
if disable_symlinks {
fs::write(&precompressed_path, "inside")
.expect("unexpected error writing pre-compressed file");
} else {
symlink(&inside_marker, &precompressed_path)
.expect("unexpected error creating contained pre-compressed symlink");
}
let race_path = precompressed_path.clone();
set_file_open_race_hook(move || {
fs::remove_file(&race_path)
.expect("unexpected error removing pre-compressed path");
symlink(&outside_marker, &race_path)
.expect("unexpected error retargeting pre-compressed path");
});
let result = request(&root_dir, encoding, disable_symlinks)
.await
.expect("expected pre-compressed response");
assert_eq!(
body_of(result).await,
b"inside",
"served retargeted {encoding} path with disable_symlinks={disable_symlinks}"
);
}
}
}
#[tokio::test]
async fn precompressed_file_retargeted_within_check_open_window_is_not_served() {
for (extension, encoding) in [("gz", "gzip"), ("br", "br"), ("zst", "zstd")] {
let (temp_dir, root_dir) = web_root("precomp-check-open-race");
fs::write(root_dir.join("app.js"), "source")
.expect("unexpected error writing source file");
let inside_marker = root_dir.join("inside-marker");
fs::write(&inside_marker, "inside").expect("unexpected error writing inside marker");
let outside_marker = temp_dir.path().join("outside-marker");
fs::write(&outside_marker, "beyond").expect("unexpected error writing outside marker");
let precompressed_path = root_dir.join(format!("app.js.{extension}"));
symlink(&inside_marker, &precompressed_path)
.expect("unexpected error creating contained pre-compressed symlink");
let race_path = precompressed_path.clone();
set_pre_file_open_race_hook(move || {
fs::remove_file(&race_path).expect("unexpected error removing pre-compressed path");
symlink(&outside_marker, &race_path)
.expect("unexpected error retargeting pre-compressed path");
});
let result = request(&root_dir, encoding, false)
.await
.expect("expected pre-compressed response");
assert_eq!(
body_of(result).await,
b"inside",
"served {encoding} variant retargeted inside the check-then-open window"
);
}
}
#[tokio::test]
async fn requested_file_retargeted_within_check_open_window_is_not_served() {
let (temp_dir, root_dir) = web_root("requested-check-open-race");
let inside_marker = root_dir.join("inside-marker");
fs::write(&inside_marker, "inside").expect("unexpected error writing inside marker");
let outside_marker = temp_dir.path().join("outside-marker");
fs::write(&outside_marker, "beyond").expect("unexpected error writing outside marker");
let requested_path = root_dir.join("app.js");
symlink(&inside_marker, &requested_path)
.expect("unexpected error creating contained requested symlink");
let race_path = requested_path.clone();
set_pre_file_open_race_hook(move || {
fs::remove_file(&race_path).expect("unexpected error removing requested path");
symlink(&outside_marker, &race_path)
.expect("unexpected error retargeting requested path");
});
let result = request(&root_dir, "identity", false)
.await
.expect("expected requested-file response");
assert_eq!(
body_of(result).await,
b"inside",
"served requested file retargeted inside the check-then-open window"
);
}
#[tokio::test]
async fn index_file_symlink_escaping_root_is_rejected() {
let (temp_dir, root_dir) = web_root("index-symlink-outside");
let outside_marker = temp_dir.path().join("outside-marker");
fs::write(&outside_marker, "beyond").expect("unexpected error writing outside marker");
symlink(&outside_marker, root_dir.join("index.html"))
.expect("unexpected error creating escaping index symlink");
match request_with(
&root_dir,
RequestOpts {
uri_path: "/",
..Default::default()
},
)
.await
{
Ok(result) => panic!(
"expected escaping index symlink to be rejected, got {}",
result.resp.status()
),
Err(status) => assert_eq!(status, StatusCode::NOT_FOUND),
}
}
#[tokio::test]
async fn index_file_symlink_is_rejected_when_symlinks_are_disabled() {
let (_temp_dir, root_dir) = web_root("index-symlink-disabled");
let inside_marker = root_dir.join("inside-marker");
fs::write(&inside_marker, "inside").expect("unexpected error writing inside marker");
symlink(&inside_marker, root_dir.join("index.html"))
.expect("unexpected error creating contained index symlink");
match request_with(
&root_dir,
RequestOpts {
uri_path: "/",
disable_symlinks: true,
..Default::default()
},
)
.await
{
Ok(result) => panic!(
"expected index symlink to be rejected, got {}",
result.resp.status()
),
Err(status) => assert_eq!(status, StatusCode::FORBIDDEN),
}
}
#[tokio::test]
async fn index_file_symlink_inside_root_is_served_when_symlinks_are_enabled() {
let (_temp_dir, root_dir) = web_root("index-symlink-allowed");
let inside_marker = root_dir.join("inside-marker");
fs::write(&inside_marker, "inside").expect("unexpected error writing inside marker");
symlink(&inside_marker, root_dir.join("index.html"))
.expect("unexpected error creating contained index symlink");
let result = request_with(
&root_dir,
RequestOpts {
uri_path: "/",
..Default::default()
},
)
.await
.expect("expected contained index symlink to be served");
assert_eq!(body_of(result).await, b"inside");
}
#[tokio::test]
async fn precompressed_index_variant_escaping_root_is_rejected() {
for (extension, encoding) in [("gz", "gzip"), ("br", "br"), ("zst", "zstd")] {
let (temp_dir, root_dir) = web_root("index-precomp-outside");
fs::write(root_dir.join("index.html"), "source")
.expect("unexpected error writing index file");
let outside_marker = temp_dir.path().join("outside-marker");
fs::write(&outside_marker, "beyond").expect("unexpected error writing outside marker");
symlink(
&outside_marker,
root_dir.join(format!("index.html.{extension}")),
)
.expect("unexpected error creating escaping variant symlink");
match request_with(
&root_dir,
RequestOpts {
uri_path: "/",
encoding,
..Default::default()
},
)
.await
{
Ok(result) => panic!(
"expected escaping {encoding} index variant to be rejected, got {}",
result.resp.status()
),
Err(status) => assert_eq!(status, StatusCode::NOT_FOUND),
}
}
}
#[tokio::test]
async fn index_file_in_hidden_directory_is_served_when_hidden_files_are_ignored() {
let (_temp_dir, root_dir) = web_root("hidden-index");
fs::write(root_dir.join(".hidden-index.html"), "hidden-index")
.expect("unexpected error writing hidden index file");
let result = request_with(
&root_dir,
RequestOpts {
uri_path: "/",
ignore_hidden_files: true,
index_files: &[".hidden-index.html"],
..Default::default()
},
)
.await
.expect("expected configured hidden index file to be served");
assert_eq!(body_of(result).await, b"hidden-index");
}
#[tokio::test]
async fn hidden_directory_request_is_still_rejected_when_hidden_files_are_ignored() {
let (_temp_dir, root_dir) = web_root("hidden-dir");
let hidden_dir = root_dir.join(".hidden");
fs::create_dir(&hidden_dir).expect("unexpected error creating hidden directory");
fs::write(hidden_dir.join("index.html"), "hidden-dir-index")
.expect("unexpected error writing hidden directory index");
match request_with(
&root_dir,
RequestOpts {
uri_path: "/.hidden/",
ignore_hidden_files: true,
..Default::default()
},
)
.await
{
Ok(result) => panic!(
"expected hidden directory request to be rejected, got {}",
result.resp.status()
),
Err(status) => assert_eq!(status, StatusCode::NOT_FOUND),
}
}
#[cfg(feature = "experimental")]
#[tokio::test]
async fn precompressed_variant_is_not_inserted_into_memory_cache() {
use crate::mem_cache::cache::{CACHE_STORE, MemCacheOpts};
use compact_str::CompactString;
let (_temp_dir, root_dir) = web_root("precomp-mem-cache");
fs::write(root_dir.join("app.js"), "source").expect("unexpected error writing source file");
let precompressed_body = b"pre-compressed-bytes";
fs::write(root_dir.join("app.js.gz"), precompressed_body)
.expect("unexpected error writing pre-compressed file");
let _ = CACHE_STORE.set(
mini_moka::sync::Cache::builder()
.max_capacity(8)
.time_to_live(std::time::Duration::from_secs(60))
.build(),
);
let store = CACHE_STORE
.get()
.expect("expected an in-memory cache store");
let memory_cache = MemCacheOpts::new(8);
let mut headers = HeaderMap::new();
headers.insert(
http::header::ACCEPT_ENCODING,
"gzip".parse().expect("unexpected invalid encoding"),
);
let result = handle(&HandleOpts {
method: &Method::GET,
headers: &headers,
base_path: &root_dir,
uri_path: "/app.js",
uri_query: None,
memory_cache: Some(&memory_cache),
#[cfg(feature = "directory-listing")]
dir_listing: false,
#[cfg(feature = "directory-listing")]
dir_listing_order: 6,
#[cfg(feature = "directory-listing")]
dir_listing_format: &crate::directory_listing::DirListFmt::Html,
#[cfg(feature = "directory-listing-download")]
dir_listing_download: &[],
redirect_trailing_slash: true,
compression_static: true,
ignore_hidden_files: false,
disable_symlinks: false,
index_files: &[],
})
.await
.expect("expected pre-compressed response");
assert_eq!(body_of(result).await, precompressed_body);
let key = CompactString::from(
root_dir
.join("app.js")
.to_str()
.expect("unexpected non-UTF-8 web root"),
);
assert!(
store.get(&key).is_none(),
"pre-compressed body was inserted into the in-memory cache store"
);
}
#[tokio::test]
async fn containment_cache_is_bypassed_when_symlinks_are_followed() {
let (_temp_dir, root_dir) = web_root("containment-cache-bypassed");
fs::write(root_dir.join("app.js"), "source").expect("unexpected error writing source file");
reset_canonicalize_calls();
for _ in 0..3 {
request(&root_dir, "identity", false)
.await
.expect("expected a successful response");
}
assert_eq!(
canonicalize_calls(),
3,
"containment decisions were reused while symlinks are followed"
);
}
#[tokio::test]
async fn containment_cache_is_reused_when_symlinks_are_disabled() {
let (_temp_dir, root_dir) = web_root("containment-cache-reused");
fs::write(root_dir.join("app.js"), "source").expect("unexpected error writing source file");
reset_canonicalize_calls();
for _ in 0..3 {
request(&root_dir, "identity", true)
.await
.expect("expected a successful response");
}
assert_eq!(
canonicalize_calls(),
1,
"containment decisions were not reused while symlinks are disabled"
);
}
}