Skip to content

Commit 07ddace

Browse files
committed
fix(filesystem) :: return 404 when a request resolves to a directory
When a directory name contains a dot the request is routed to the static file handler, which fails to read it and renders an error page. Reading a directory reports `IsADirectory` on unix but `PermissionDenied` on windows, so the status is decided by inspecting the path rather than by the error kind. The regression test covers both platforms: on unix it also exercises the windows branch, since `IsADirectory` is no longer special-cased.
1 parent 8bdd3c1 commit 07ddace

4 files changed

Lines changed: 54 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
```
1414

1515
SQLPage now keeps the variable value, producing `https://api.example.com/john.doe` as expected.
16+
- A request that resolves to a directory now returns a 404 page. Directory names that contain a dot are routed to the static file handler, which used to fail with a server error instead.
1617

1718
## v0.46
1819

src/filesystem.rs

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,7 @@ impl FileSystem {
9090
.await
9191
}
9292
(Err(e), _) => {
93-
let status = io_error_status(&e)
94-
.unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR);
93+
let status = io_error_status(&local_path, &e).await;
9594
Err(e).with_status(status).with_context(|| {
9695
format!("Unable to read local file metadata for {}", path.display())
9796
})
@@ -146,12 +145,8 @@ impl FileSystem {
146145
// no local file, try the database
147146
db_fs.read_file(app_state, path.as_ref()).await
148147
}
149-
(Err(e), None) if is_path_missing_error(&e) => Err(e)
150-
.with_status(actix_web::http::StatusCode::NOT_FOUND)
151-
.with_context(|| format!("Unable to read local file {}", path.display())),
152148
(Err(e), _) => {
153-
let status = io_error_status(&e)
154-
.unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR);
149+
let status = io_error_status(&local_path, &e).await;
155150
Err(e)
156151
.with_status(status)
157152
.with_context(|| format!("Unable to read local file {}", path.display()))
@@ -188,12 +183,11 @@ impl FileSystem {
188183
) -> anyhow::Result<bool> {
189184
let path = access.path();
190185
let safe_path = self.safe_local_path(app_state, access);
191-
let local_exists = match tokio::fs::try_exists(safe_path).await {
186+
let local_exists = match tokio::fs::try_exists(&safe_path).await {
192187
Ok(exists) => exists,
193188
Err(e) if is_path_missing_error(&e) => false,
194189
Err(e) => {
195-
let status = io_error_status(&e)
196-
.unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR);
190+
let status = io_error_status(&safe_path, &e).await;
197191
return Err(e).with_status(status).with_context(|| {
198192
format!("Unable to check if {} exists locally", path.display())
199193
});
@@ -252,16 +246,27 @@ fn is_path_missing_error(error: &std::io::Error) -> bool {
252246
matches!(error.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory)
253247
}
254248

255-
fn io_error_status(error: &std::io::Error) -> Option<actix_web::http::StatusCode> {
249+
async fn io_error_status(local_path: &Path, error: &std::io::Error) -> actix_web::http::StatusCode {
256250
match error.kind() {
257-
ErrorKind::NotFound | ErrorKind::NotADirectory => {
258-
Some(actix_web::http::StatusCode::NOT_FOUND)
251+
ErrorKind::NotFound | ErrorKind::NotADirectory => actix_web::http::StatusCode::NOT_FOUND,
252+
// Reading a directory reports IsADirectory on unix but PermissionDenied on
253+
// windows, so the path itself has to be inspected to tell the two apart.
254+
ErrorKind::IsADirectory | ErrorKind::PermissionDenied
255+
if is_local_directory(local_path).await =>
256+
{
257+
actix_web::http::StatusCode::NOT_FOUND
259258
}
260-
ErrorKind::PermissionDenied => Some(actix_web::http::StatusCode::FORBIDDEN),
261-
_ => None,
259+
ErrorKind::PermissionDenied => actix_web::http::StatusCode::FORBIDDEN,
260+
_ => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
262261
}
263262
}
264263

264+
async fn is_local_directory(path: &Path) -> bool {
265+
tokio::fs::metadata(path)
266+
.await
267+
.is_ok_and(|metadata| metadata.is_dir())
268+
}
269+
265270
async fn file_modified_since_local(path: &Path, since: DateTime<Utc>) -> tokio::io::Result<bool> {
266271
tokio::fs::metadata(path)
267272
.await
@@ -506,3 +511,25 @@ async fn test_sql_file_read_utf8() -> anyhow::Result<()> {
506511

507512
Ok(())
508513
}
514+
515+
#[actix_web::test]
516+
async fn test_local_file_modification_time() -> anyhow::Result<()> {
517+
let config = crate::app_config::tests::test_config();
518+
let state = AppState::init(&config).await?;
519+
let fs = FileSystem::init(".", &state.db).await;
520+
let committed_file = || FileAccess::unprivileged("tests/it_works.txt".as_ref());
521+
522+
assert!(
523+
fs.modified_since(&state, committed_file()?, DateTime::UNIX_EPOCH)
524+
.await?
525+
);
526+
assert!(
527+
!fs.modified_since(
528+
&state,
529+
committed_file()?,
530+
Utc::now() + chrono::Duration::hours(1)
531+
)
532+
.await?
533+
);
534+
Ok(())
535+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
not directly servable

tests/errors/mod.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,13 @@ async fn test_default_404_when_request_path_descends_into_file() {
173173
assert!(body.contains("The page you were looking for does not exist"));
174174
assert!(!body.contains("error"));
175175
}
176+
177+
#[actix_web::test]
178+
async fn test_requesting_a_directory_is_not_found() {
179+
let resp_result = req_path("/tests/errors/is_a_directory.d").await;
180+
let status = match resp_result {
181+
Ok(resp) => resp.status(),
182+
Err(e) => e.as_response_error().status_code(),
183+
};
184+
assert_eq!(status, StatusCode::NOT_FOUND);
185+
}

0 commit comments

Comments
 (0)