use hyper::HeaderMap;
use tokio::task;
use std::{fs, path::Path};
use crate::{
response::{
error_response::{internal_server_error_response, not_found_response},
file_response::FileResponse,
},
types::BoxBody,
};
use apimock_routing::util::json::JSON_COMPATIBLE_EXTENSIONS;
pub async fn dyn_route_content(
url_path: &str,
fallback_respond_dir: &str,
request_headers: &HeaderMap,
confine_to: Option<&Path>,
cors_allow_credentials_origins: &[String],
) -> Result<hyper::Response<BoxBody>, hyper::http::Error> {
let request_path =
Path::new(fallback_respond_dir).join(url_path.strip_prefix("/").unwrap_or_default());
let request_file_name = request_path
.file_name()
.unwrap_or_default()
.to_str()
.unwrap_or_default();
let Some(parent) = request_path.parent() else {
return internal_server_error_response(
&format!("parent dir not found: url_path = {}", url_path),
request_headers,
cors_allow_credentials_origins,
);
};
let dir = parent.to_owned();
let mut found = if is_existing_file(&request_path).await {
Some(request_path.clone())
} else {
None
};
if found.is_none()
&& request_path.extension().is_none()
&& let Some(stem) = request_path.file_stem().and_then(|s| s.to_str())
{
for ext in JSON_COMPATIBLE_EXTENSIONS {
let candidate = dir.join(format!("{}.{}", stem, ext));
if is_existing_file(&candidate).await {
found = Some(candidate);
break;
}
}
}
if found.is_none() {
if !dir.exists() {
return not_found_response(request_headers, cors_allow_credentials_origins);
}
found = match find_by_case_insensitive_listing(&dir, request_file_name).await {
Ok(found) => found,
Err(err) => {
log::error!("{}", err);
return internal_server_error_response(
"failed to read fallback directory",
request_headers,
cors_allow_credentials_origins,
);
}
};
}
let Some(found) = found else {
return not_found_response(request_headers, cors_allow_credentials_origins);
};
let file_path = found.to_str().unwrap_or_default();
FileResponse::new(
file_path,
None,
request_headers,
confine_to,
cors_allow_credentials_origins,
)
.file_content_response()
.await
}
async fn is_existing_file(path: &Path) -> bool {
let path = path.to_owned();
task::spawn_blocking(move || path.is_file())
.await
.unwrap_or(false)
}
async fn find_by_case_insensitive_listing(
dir: &Path,
request_file_name: &str,
) -> Result<Option<std::path::PathBuf>, String> {
let dir_for_blocking_task = dir.to_owned();
let request_file_name = request_file_name.to_owned();
let read_dir_result =
task::spawn_blocking(move || -> Result<Option<std::path::PathBuf>, String> {
let entries = fs::read_dir(dir_for_blocking_task.as_path()).map_err(|err| {
format!(
"failed to get dir: {} ({})",
dir_for_blocking_task.to_string_lossy(),
err
)
})?;
for entry in entries {
let entry = entry.map_err(|err| {
format!(
"failed to get dir entry from dir: {} ({})",
dir_for_blocking_task.to_string_lossy(),
err
)
})?;
let path = entry.path();
let name = path
.file_name()
.unwrap_or_default()
.to_str()
.unwrap_or_default();
if name.eq_ignore_ascii_case(&request_file_name) {
return Ok(Some(path));
}
}
Ok(None)
})
.await;
match read_dir_result {
Ok(Ok(found)) => Ok(found),
Ok(Err(err)) => Err(err),
Err(err) => Err(format!(
"failed to get dir entries ({}): {}",
dir.to_string_lossy(),
err
)),
}
}
#[cfg(test)]
mod tests {
use hyper::HeaderMap;
use crate::response::confine::canonical_dir;
use super::dyn_route_content;
#[tokio::test]
async fn a_raw_dot_dot_is_refused_even_without_url_normalisation_first() {
let outer = tempfile::tempdir().unwrap();
let respond_dir = outer.path().join("respond_dir");
std::fs::create_dir(&respond_dir).unwrap();
std::fs::write(outer.path().join("outside.txt"), "SECRET-OUTSIDE-CONTENT").unwrap();
let confine_to = canonical_dir(respond_dir.to_str().unwrap());
assert!(confine_to.is_some(), "fixture directory must canonicalise");
let response = dyn_route_content(
"/../outside.txt",
respond_dir.to_str().unwrap(),
&HeaderMap::new(),
confine_to.as_deref(),
&[],
)
.await
.expect("dyn_route_content must not fail to build a response");
assert_eq!(response.status(), hyper::StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn a_base_that_failed_to_canonicalise_refuses_every_candidate() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("hello.json"), "{}").unwrap();
let response = dyn_route_content(
"/hello.json",
dir.path().to_str().unwrap(),
&HeaderMap::new(),
None,
&[],
)
.await
.expect("dyn_route_content must not fail to build a response");
assert_eq!(response.status(), hyper::StatusCode::NOT_FOUND);
}
async fn body_bytes(response: hyper::Response<crate::types::BoxBody>) -> Vec<u8> {
http_body_util::BodyExt::collect(response.into_body())
.await
.unwrap()
.to_bytes()
.to_vec()
}
#[tokio::test]
async fn exact_case_match_resolves_without_listing() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("hello.json"), "{\"ok\":true}").unwrap();
let confine_to = canonical_dir(dir.path().to_str().unwrap());
let response = dyn_route_content(
"/hello.json",
dir.path().to_str().unwrap(),
&HeaderMap::new(),
confine_to.as_deref(),
&[],
)
.await
.unwrap();
assert_eq!(response.status(), hyper::StatusCode::OK);
assert_eq!(body_bytes(response).await, b"{\"ok\":true}");
}
#[tokio::test]
async fn extension_inference_still_resolves() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("hello.json"), "{\"ok\":true}").unwrap();
let confine_to = canonical_dir(dir.path().to_str().unwrap());
let response = dyn_route_content(
"/hello",
dir.path().to_str().unwrap(),
&HeaderMap::new(),
confine_to.as_deref(),
&[],
)
.await
.unwrap();
assert_eq!(response.status(), hyper::StatusCode::OK);
assert_eq!(body_bytes(response).await, b"{\"ok\":true}");
}
#[tokio::test]
async fn case_mismatch_still_resolves_via_the_listing_fallback() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("hello.json"), "{\"ok\":true}").unwrap();
let confine_to = canonical_dir(dir.path().to_str().unwrap());
let response = dyn_route_content(
"/Hello.JSON",
dir.path().to_str().unwrap(),
&HeaderMap::new(),
confine_to.as_deref(),
&[],
)
.await
.unwrap();
assert_eq!(response.status(), hyper::StatusCode::OK);
assert_eq!(body_bytes(response).await, b"{\"ok\":true}");
}
#[tokio::test]
async fn bare_differently_cased_file_vs_extension_match_resolves_per_filesystem_case_sensitivity()
{
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("FOO"), "BARE-FILE-CONTENT").unwrap();
std::fs::write(dir.path().join("foo.json"), "{\"extension\":\"inferred\"}").unwrap();
let filesystem_is_case_insensitive = dir.path().join("foo").is_file();
let confine_to = canonical_dir(dir.path().to_str().unwrap());
let response = dyn_route_content(
"/foo",
dir.path().to_str().unwrap(),
&HeaderMap::new(),
confine_to.as_deref(),
&[],
)
.await
.unwrap();
assert_eq!(response.status(), hyper::StatusCode::OK);
let body = body_bytes(response).await;
if filesystem_is_case_insensitive {
assert_eq!(
body, b"BARE-FILE-CONTENT",
"case-insensitive filesystem: the exact-path stat for \
\"foo\" already resolves to \"FOO\" at the OS level, so \
the bare file should still win, unchanged from before \
this fix"
);
} else {
assert_eq!(
body, b"{\"extension\":\"inferred\"}",
"case-sensitive filesystem: the exact-path stat for \
\"foo\" misses \"FOO\", so extension inference (which \
now runs before the listing) should resolve to \
\"foo.json\" — the disclosed precedence change"
);
}
}
#[tokio::test]
async fn no_match_anywhere_is_not_found() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("other.json"), "{}").unwrap();
let confine_to = canonical_dir(dir.path().to_str().unwrap());
let response = dyn_route_content(
"/does-not-exist",
dir.path().to_str().unwrap(),
&HeaderMap::new(),
confine_to.as_deref(),
&[],
)
.await
.unwrap();
assert_eq!(response.status(), hyper::StatusCode::NOT_FOUND);
}
}