feat(file): add support for extracting multi-component path params (#4039)

This commit is contained in:
Yuki Okushi 2026-04-25 14:43:27 +09:00 committed by GitHub
parent 86eeea7f92
commit 1aa74f4234
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 547 additions and 78 deletions

View File

@ -2,11 +2,13 @@
## Unreleased
- Add support for passing multiple root directories to `Files::new`. [#3402]
- Add `Files::try_compressed()` to support serving pre-compressed static files [#2615]
- Fix handling of `bytes=0-`
- Fix `NamedFile` panic when serving files with pre-UNIX epoch modification times. [#2748]
- Fix invalid `Content-Encoding: identity` header in `NamedFile` range responses. [#3191]
[#3402]: https://github.com/actix/actix-web/issues/3402
[#2615]: https://github.com/actix/actix-web/pull/2615
[#2748]: https://github.com/actix/actix-web/issues/2748
[#3191]: https://github.com/actix/actix-web/issues/3191

View File

@ -1,5 +1,7 @@
use std::{
borrow::Cow,
cell::RefCell,
ffi::{OsStr, OsString},
fmt, io,
path::{Path, PathBuf},
rc::Rc,
@ -37,7 +39,7 @@ use crate::{
/// ```
pub struct Files {
mount_path: String,
directory: PathBuf,
directories: Vec<PathBuf>,
index: Option<String>,
show_index: bool,
redirect_to_slash: bool,
@ -63,7 +65,7 @@ impl fmt::Debug for Files {
impl Clone for Files {
fn clone(&self) -> Self {
Self {
directory: self.directory.clone(),
directories: self.directories.clone(),
index: self.index.clone(),
show_index: self.show_index,
redirect_to_slash: self.redirect_to_slash,
@ -83,6 +85,131 @@ impl Clone for Files {
}
}
/// File serving root directories for [`Files`].
///
/// This type is used by [`Files::new`] to accept either one root directory or an ordered
/// collection of root directories.
#[derive(Debug)]
pub struct FilesDirs(Vec<PathBuf>);
impl FilesDirs {
fn canonicalize(self) -> Vec<PathBuf> {
self.0
.into_iter()
.map(|orig_dir| match orig_dir.canonicalize() {
Ok(canon_dir) => canon_dir,
Err(_) => {
log::error!("Specified path is not a directory: {:?}", orig_dir);
// Preserve original path so requests don't fall back to CWD.
orig_dir
}
})
.collect()
}
}
impl From<&Path> for FilesDirs {
fn from(dir: &Path) -> Self {
Self(vec![dir.into()])
}
}
impl From<&PathBuf> for FilesDirs {
fn from(dir: &PathBuf) -> Self {
Self(vec![dir.into()])
}
}
impl From<PathBuf> for FilesDirs {
fn from(dir: PathBuf) -> Self {
Self(vec![dir])
}
}
impl From<&str> for FilesDirs {
fn from(dir: &str) -> Self {
Self(vec![dir.into()])
}
}
impl From<&String> for FilesDirs {
fn from(dir: &String) -> Self {
Self(vec![dir.into()])
}
}
impl From<String> for FilesDirs {
fn from(dir: String) -> Self {
Self(vec![dir.into()])
}
}
impl From<&OsStr> for FilesDirs {
fn from(dir: &OsStr) -> Self {
Self(vec![dir.into()])
}
}
impl From<OsString> for FilesDirs {
fn from(dir: OsString) -> Self {
Self(vec![dir.into()])
}
}
impl From<&OsString> for FilesDirs {
fn from(dir: &OsString) -> Self {
Self(vec![dir.into()])
}
}
impl From<Box<Path>> for FilesDirs {
fn from(dir: Box<Path>) -> Self {
Self(vec![dir.into()])
}
}
impl From<Cow<'_, Path>> for FilesDirs {
fn from(dir: Cow<'_, Path>) -> Self {
Self(vec![dir.into()])
}
}
impl<P, const N: usize> From<[P; N]> for FilesDirs
where
P: Into<PathBuf>,
{
fn from(dirs: [P; N]) -> Self {
Self(dirs.into_iter().map(Into::into).collect())
}
}
impl<P, const N: usize> From<&[P; N]> for FilesDirs
where
P: Clone + Into<PathBuf>,
{
fn from(dirs: &[P; N]) -> Self {
Self(dirs.iter().cloned().map(Into::into).collect())
}
}
impl<P> From<&[P]> for FilesDirs
where
P: Clone + Into<PathBuf>,
{
fn from(dirs: &[P]) -> Self {
Self(dirs.iter().cloned().map(Into::into).collect())
}
}
impl<P> From<Vec<P>> for FilesDirs
where
P: Into<PathBuf>,
{
fn from(dirs: Vec<P>) -> Self {
Self(dirs.into_iter().map(Into::into).collect())
}
}
impl Files {
/// Create new `Files` instance for a specified base directory.
///
@ -90,34 +217,34 @@ impl Files {
/// The first argument (`mount_path`) is the root URL at which the static files are served.
/// For example, `/assets` will serve files at `example.com/assets/...`.
///
/// The second argument (`serve_from`) is the location on disk at which files are loaded.
/// This can be a relative path. For example, `./` would serve files from the current
/// working directory.
/// The second argument (`serve_from`) is the location on disk that files are served from. This
/// can be a single path or an ordered collection of paths. Relative paths are resolved from the
/// current working directory.
///
/// When multiple directories are provided, they are checked in order. The first directory that
/// can serve the requested path is used.
///
/// Directory listings are generated from the first matching directory and are not merged across
/// roots. When [`Files::index_file()`] is configured, later roots are searched if an earlier
/// matching directory does not contain the index file.
///
/// Empty root collections never match files; requests fall through to the default handler, or
/// return `404 Not Found` if none is configured.
///
/// # Implementation Notes
/// If the mount path is set as the root path `/`, services registered after this one will
/// be inaccessible. Register more specific handlers and services first.
///
/// If `serve_from` cannot be canonicalized at startup, an error is logged and the original
/// path is preserved. Requests will return `404 Not Found` until the path exists.
/// If a `serve_from` path cannot be canonicalized at startup, an error is logged and the
/// original path is preserved. Requests will return `404 Not Found` until the path exists.
///
/// `Files` utilizes the existing Tokio thread-pool for blocking filesystem operations.
/// The number of running threads is adjusted over time as needed, up to a maximum of 512 times
/// the number of server [workers](actix_web::HttpServer::workers), by default.
pub fn new<T: Into<PathBuf>>(mount_path: &str, serve_from: T) -> Files {
let orig_dir = serve_from.into();
let dir = match orig_dir.canonicalize() {
Ok(canon_dir) => canon_dir,
Err(_) => {
log::error!("Specified path is not a directory: {:?}", orig_dir);
// Preserve original path so requests don't fall back to CWD.
orig_dir
}
};
pub fn new<T: Into<FilesDirs>>(mount_path: &str, serve_from: T) -> Files {
Files {
mount_path: mount_path.trim_end_matches('/').to_owned(),
directory: dir,
directories: serve_from.into().canonicalize(),
index: None,
show_index: false,
redirect_to_slash: false,
@ -149,6 +276,9 @@ impl Files {
/// Redirects to a slash-ended path when browsing a directory.
///
/// By default never redirect.
///
/// When multiple root directories are configured, a matching directory in an earlier root can
/// trigger a redirect before later roots are checked for a file at the same path.
pub fn redirect_to_slash_directory(mut self) -> Self {
self.redirect_to_slash = true;
self
@ -407,7 +537,7 @@ impl ServiceFactory<ServiceRequest> for Files {
fn new_service(&self, _: ()) -> Self::Future {
let mut inner = FilesServiceInner {
directory: self.directory.clone(),
directories: self.directories.clone(),
index: self.index.clone(),
show_index: self.show_index,
redirect_to_slash: self.redirect_to_slash,

View File

@ -37,8 +37,14 @@ mod range;
mod service;
pub use self::{
chunked::ChunkedReadFile, directory::Directory, error::UriSegmentError, files::Files,
named::NamedFile, path_buf::PathBufWrap, range::HttpRange, service::FilesService,
chunked::ChunkedReadFile,
directory::Directory,
error::UriSegmentError,
files::{Files, FilesDirs},
named::NamedFile,
path_buf::PathBufWrap,
range::HttpRange,
service::FilesService,
};
use self::{
directory::{directory_listing, DirectoryRenderer},
@ -63,9 +69,11 @@ type PathFilter = dyn Fn(&Path, &RequestHead) -> bool;
#[cfg(test)]
mod tests {
use std::{
ffi::OsString,
fmt::Write as _,
fs::{self},
ops::Add,
path::PathBuf,
time::{Duration, SystemTime},
};
@ -832,6 +840,243 @@ mod tests {
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[actix_rt::test]
async fn test_static_files_accepts_borrowed_os_string_directory() {
let dir = OsString::from(".");
let service = Files::new("/", &dir).new_service(()).await.unwrap();
let req = TestRequest::with_uri("/Cargo.toml").to_srv_request();
let resp = test::call_service(&service, req).await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[actix_rt::test]
async fn test_static_files_empty_directories() {
let service = Files::new("/", Vec::<PathBuf>::new())
.new_service(())
.await
.unwrap();
let req = TestRequest::with_uri("/Cargo.toml").to_srv_request();
let resp = test::call_service(&service, req).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let service = Files::new("/", Vec::<PathBuf>::new())
.default_handler(|req: ServiceRequest| async {
Ok(req.into_response(HttpResponse::Ok().body("default content")))
})
.new_service(())
.await
.unwrap();
let req = TestRequest::with_uri("/Cargo.toml").to_srv_request();
let resp = test::call_service(&service, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
test::read_body(resp).await,
Bytes::from_static(b"default content")
);
}
#[actix_rt::test]
async fn test_static_files_multiple_directories() {
let first_dir = tempfile::tempdir().unwrap();
let second_dir = tempfile::tempdir().unwrap();
fs::write(first_dir.path().join("shared.txt"), "first").unwrap();
fs::write(second_dir.path().join("shared.txt"), "second").unwrap();
fs::write(second_dir.path().join("fallback.txt"), "fallback").unwrap();
let service = Files::new("/", [first_dir.path(), second_dir.path()])
.new_service(())
.await
.unwrap();
let req = TestRequest::with_uri("/shared.txt").to_srv_request();
let resp = test::call_service(&service, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(test::read_body(resp).await, Bytes::from_static(b"first"));
let req = TestRequest::with_uri("/fallback.txt").to_srv_request();
let resp = test::call_service(&service, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(test::read_body(resp).await, Bytes::from_static(b"fallback"));
}
#[actix_rt::test]
async fn test_static_files_multiple_directories_file_as_parent_falls_back() {
let first_dir = tempfile::tempdir().unwrap();
let second_dir = tempfile::tempdir().unwrap();
fs::write(first_dir.path().join("assets"), "file").unwrap();
fs::create_dir(second_dir.path().join("assets")).unwrap();
fs::write(
second_dir.path().join("assets").join("fallback.txt"),
"fallback",
)
.unwrap();
let service = Files::new("/", [first_dir.path(), second_dir.path()])
.new_service(())
.await
.unwrap();
let req = TestRequest::with_uri("/assets/fallback.txt").to_srv_request();
let resp = test::call_service(&service, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(test::read_body(resp).await, Bytes::from_static(b"fallback"));
}
#[actix_rt::test]
async fn test_static_files_multiple_directories_default_handler() {
let first_dir = tempfile::tempdir().unwrap();
let second_dir = tempfile::tempdir().unwrap();
fs::write(second_dir.path().join("fallback.txt"), "fallback").unwrap();
let service = Files::new("/", vec![first_dir.path(), second_dir.path()])
.default_handler(|req: ServiceRequest| async {
Ok(req.into_response(HttpResponse::Ok().body("default content")))
})
.new_service(())
.await
.unwrap();
let req = TestRequest::with_uri("/fallback.txt").to_srv_request();
let resp = test::call_service(&service, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(test::read_body(resp).await, Bytes::from_static(b"fallback"));
let req = TestRequest::with_uri("/missing.txt").to_srv_request();
let resp = test::call_service(&service, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
test::read_body(resp).await,
Bytes::from_static(b"default content")
);
}
#[actix_rt::test]
async fn test_static_files_multiple_directories_index_file() {
let first_dir = tempfile::tempdir().unwrap();
let second_dir = tempfile::tempdir().unwrap();
fs::create_dir(first_dir.path().join("nested")).unwrap();
fs::create_dir(second_dir.path().join("nested")).unwrap();
fs::write(
second_dir.path().join("nested").join("index.html"),
"second index",
)
.unwrap();
let service = Files::new("/", [first_dir.path(), second_dir.path()])
.index_file("index.html")
.new_service(())
.await
.unwrap();
let req = TestRequest::with_uri("/nested/").to_srv_request();
let resp = test::call_service(&service, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
test::read_body(resp).await,
Bytes::from_static(b"second index")
);
}
#[actix_rt::test]
async fn test_static_files_multiple_directories_index_file_as_parent_falls_back() {
let first_dir = tempfile::tempdir().unwrap();
let second_dir = tempfile::tempdir().unwrap();
fs::create_dir(first_dir.path().join("nested")).unwrap();
fs::write(first_dir.path().join("nested").join("index.html"), "file").unwrap();
fs::create_dir(second_dir.path().join("nested")).unwrap();
fs::create_dir(second_dir.path().join("nested").join("index.html")).unwrap();
fs::write(
second_dir
.path()
.join("nested")
.join("index.html")
.join("fallback.txt"),
"fallback",
)
.unwrap();
let service = Files::new("/", [first_dir.path(), second_dir.path()])
.index_file("index.html/fallback.txt")
.new_service(())
.await
.unwrap();
let req = TestRequest::with_uri("/nested/").to_srv_request();
let resp = test::call_service(&service, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(test::read_body(resp).await, Bytes::from_static(b"fallback"));
}
#[actix_rt::test]
async fn test_static_files_index_file_error_falls_back_to_listing() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("listed.txt"), "listed").unwrap();
let service = Files::new("/", dir.path())
.index_file("index.html\0")
.show_files_listing()
.new_service(())
.await
.unwrap();
let req = TestRequest::with_uri("/").to_srv_request();
let resp = test::call_service(&service, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let bytes = test::read_body(resp).await;
assert!(format!("{bytes:?}").contains("listed.txt"));
}
#[actix_rt::test]
async fn test_static_files_multiple_directories_show_files_listing() {
let first_dir = tempfile::tempdir().unwrap();
let second_dir = tempfile::tempdir().unwrap();
fs::write(first_dir.path().join("listed.txt"), "listed").unwrap();
let service = Files::new("/", [first_dir.path(), second_dir.path()])
.show_files_listing()
.new_service(())
.await
.unwrap();
let req = TestRequest::with_uri("/").to_srv_request();
let resp = test::call_service(&service, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let bytes = test::read_body(resp).await;
assert!(format!("{bytes:?}").contains("listed.txt"));
}
#[actix_rt::test]
async fn test_static_files_multiple_directories_redirect_precedence() {
let first_dir = tempfile::tempdir().unwrap();
let second_dir = tempfile::tempdir().unwrap();
fs::create_dir(first_dir.path().join("item")).unwrap();
fs::write(second_dir.path().join("item"), "file").unwrap();
let service = Files::new("/", [first_dir.path(), second_dir.path()])
.show_files_listing()
.redirect_to_slash_directory()
.new_service(())
.await
.unwrap();
let req = TestRequest::with_uri("/item").to_srv_request();
let resp = test::call_service(&service, req).await;
assert_eq!(resp.status(), StatusCode::TEMPORARY_REDIRECT);
assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/item/");
}
#[actix_rt::test]
async fn test_default_handler_file_missing() {
let st = Files::new("/", ".")

View File

@ -33,7 +33,7 @@ impl Deref for FilesService {
}
pub struct FilesServiceInner {
pub(crate) directory: PathBuf,
pub(crate) directories: Vec<PathBuf>,
pub(crate) index: Option<String>,
pub(crate) show_index: bool,
pub(crate) redirect_to_slash: bool,
@ -113,8 +113,8 @@ impl FilesService {
self.serve_named_file_with_encoding(req, named_file, header::ContentEncoding::Identity)
}
fn show_index(&self, req: ServiceRequest, path: PathBuf) -> ServiceResponse {
let dir = Directory::new(self.directory.clone(), path);
fn show_index(&self, req: ServiceRequest, base: PathBuf, path: PathBuf) -> ServiceResponse {
let dir = Directory::new(base, path);
let (req, _) = req.into_parts();
@ -171,70 +171,124 @@ impl Service<ServiceRequest> for FilesService {
}
}
// full file path
let path = this.directory.join(&path_on_disk);
let mut last_miss = None;
let mut first_index_listing = None;
let mut found_unrenderable_dir = false;
// Try serving pre-compressed file even if the uncompressed file doesn't exist yet.
// Still handle directories (index/listing) through the normal branch below.
if this.try_compressed && !path.is_dir() {
if let Some((named_file, encoding)) = find_compressed(&req, &path).await {
return Ok(this.serve_named_file_with_encoding(req, named_file, encoding));
}
}
for directory in &this.directories {
// full file path
let path = directory.join(&path_on_disk);
if let Err(err) = path.canonicalize() {
return this.handle_err(err, req).await;
}
if path.is_dir() {
if this.redirect_to_slash
&& !req.path().ends_with('/')
&& (this.index.is_some() || this.show_index)
{
let redirect_to = format!("{}/", req.path());
let response = if this.with_permanent_redirect {
HttpResponse::PermanentRedirect()
} else {
HttpResponse::TemporaryRedirect()
// Try serving pre-compressed file even if the uncompressed file doesn't exist yet.
// Still handle directories (index/listing) through the normal branch below.
if this.try_compressed && !path.is_dir() {
if let Some((named_file, encoding)) = find_compressed(&req, &path).await {
return Ok(this.serve_named_file_with_encoding(req, named_file, encoding));
}
.insert_header((header::LOCATION, redirect_to))
.finish();
return Ok(req.into_response(response));
}
match this.index {
Some(ref index) => {
let named_path = path.join(index);
if this.try_compressed {
if let Some((named_file, encoding)) =
find_compressed(&req, &named_path).await
{
return Ok(
this.serve_named_file_with_encoding(req, named_file, encoding)
);
if let Err(err) = path.canonicalize() {
if matches!(
err.kind(),
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
) {
last_miss = Some(err);
continue;
}
return this.handle_err(err, req).await;
}
if path.is_dir() {
if this.redirect_to_slash
&& !req.path().ends_with('/')
&& (this.index.is_some() || this.show_index)
{
let redirect_to = format!("{}/", req.path());
let response = if this.with_permanent_redirect {
HttpResponse::PermanentRedirect()
} else {
HttpResponse::TemporaryRedirect()
}
.insert_header((header::LOCATION, redirect_to))
.finish();
return Ok(req.into_response(response));
}
match &this.index {
Some(index) => {
let named_path = path.join(index);
if this.try_compressed {
if let Some((named_file, encoding)) =
find_compressed(&req, &named_path).await
{
return Ok(this.serve_named_file_with_encoding(
req, named_file, encoding,
));
}
}
// fallback to the uncompressed version
match NamedFile::open_async(named_path).await {
Ok(named_file) => return Ok(this.serve_named_file(req, named_file)),
Err(err)
if matches!(
err.kind(),
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
) =>
{
if this.show_index && first_index_listing.is_none() {
first_index_listing =
Some((directory.to_path_buf(), path.clone()));
}
last_miss = Some(err);
}
Err(_) if this.show_index => {
if first_index_listing.is_none() {
first_index_listing =
Some((directory.to_path_buf(), path.clone()));
}
break;
}
Err(err) => return this.handle_err(err, req).await,
}
}
// fallback to the uncompressed version
match NamedFile::open_async(named_path).await {
Ok(named_file) => Ok(this.serve_named_file(req, named_file)),
Err(_) if this.show_index => Ok(this.show_index(req, path)),
Err(err) => this.handle_err(err, req).await,
None if this.show_index => {
return Ok(this.show_index(req, directory.to_path_buf(), path));
}
None => found_unrenderable_dir = true,
}
} else {
match NamedFile::open_async(&path).await {
Ok(named_file) => return Ok(this.serve_named_file(req, named_file)),
Err(err)
if matches!(
err.kind(),
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
) =>
{
last_miss = Some(err);
}
Err(err) => return this.handle_err(err, req).await,
}
None if this.show_index => Ok(this.show_index(req, path)),
None => Ok(ServiceResponse::from_err(
FilesError::IsDirectory,
req.into_parts().0,
)),
}
} else {
match NamedFile::open_async(&path).await {
Ok(named_file) => Ok(this.serve_named_file(req, named_file)),
Err(err) => this.handle_err(err, req).await,
}
}
if let Some((base, path)) = first_index_listing {
return Ok(this.show_index(req, base, path));
}
if found_unrenderable_dir {
return Ok(ServiceResponse::from_err(
FilesError::IsDirectory,
req.into_parts().0,
));
}
let err = last_miss
.unwrap_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No such file"));
this.handle_err(err, req).await
})
}
}

View File

@ -166,6 +166,44 @@ async fn test_compression_encodings() {
assert_eq!(res.headers().get(header::CONTENT_ENCODING), None);
}
#[actix_web::test]
async fn test_compression_encodings_multiple_directories() {
use actix_web::body::MessageBody;
let first_dir = tempfile::tempdir().unwrap();
let second_dir = tempfile::tempdir().unwrap();
let compressed_path = second_dir.path().join("fallback.txt.gz");
std::fs::write(&compressed_path, b"compressed").unwrap();
let compressed_len = std::fs::metadata(compressed_path).unwrap().len();
let srv = test::init_service(
App::new().service(Files::new("/", [first_dir.path(), second_dir.path()]).try_compressed()),
)
.await;
let mut req = TestRequest::with_uri("/fallback.txt").to_request();
req.headers_mut().insert(
header::ACCEPT_ENCODING,
header::HeaderValue::from_static("gzip"),
);
let res = test::call_service(&srv, req).await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
res.headers().get(header::CONTENT_TYPE),
Some(&HeaderValue::from_static("text/plain; charset=utf-8")),
);
assert_eq!(
res.headers().get(header::CONTENT_ENCODING),
Some(&HeaderValue::from_static("gzip")),
);
assert_eq!(
res.into_body().size(),
actix_web::body::BodySize::Sized(compressed_len),
);
}
#[actix_web::test]
async fn partial_range_response_encoding() {
let srv = test::init_service(App::new().default_service(web::to(|| async {