use hyper::HeaderMap;
use tokio::task;
use std::{
fs,
path::{Path, PathBuf},
};
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> {
dyn_route_content_traced(
url_path,
fallback_respond_dir,
request_headers,
confine_to,
cors_allow_credentials_origins,
)
.await
.0
}
pub(crate) async fn dyn_route_content_traced(
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>,
Option<String>,
) {
let base_dir = Path::new(fallback_respond_dir);
let relative = url_path.strip_prefix('/').unwrap_or(url_path);
let segments: Vec<&str> = relative.split('/').filter(|s| !s.is_empty()).collect();
let (file_name, parent_segments): (&str, &[&str]) = match segments.split_last() {
Some((&last, rest)) => (last, rest),
None => (
base_dir.file_name().and_then(|s| s.to_str()).unwrap_or(""),
&[],
),
};
let search_base_dir = if segments.is_empty() {
let Some(parent) = base_dir.parent() else {
return (
internal_server_error_response(
&format!("parent dir not found: url_path = {}", url_path),
request_headers,
cors_allow_credentials_origins,
),
None,
);
};
parent
} else {
base_dir
};
let mut naive_parent_dir = search_base_dir.to_owned();
for &segment in parent_segments {
naive_parent_dir.push(segment);
}
match resolve_final_segment(&naive_parent_dir, file_name).await {
Ok(Some(found)) => {
let resolved_file_path = found.to_string_lossy().into_owned();
return (
serve_found(
found,
request_headers,
confine_to,
cors_allow_credentials_origins,
)
.await,
Some(resolved_file_path),
);
}
Ok(None) => {}
Err(err) => {
return (
report_listing_failure(err, request_headers, cors_allow_credentials_origins),
None,
);
}
}
if parent_segments.is_empty() {
return (
not_found_response(request_headers, cors_allow_credentials_origins),
None,
);
}
let mut resolved_parent_dir = search_base_dir.to_owned();
for &segment in parent_segments {
match resolve_dir_segment(&resolved_parent_dir, segment).await {
Ok(Some(next)) => resolved_parent_dir = next,
Ok(None) => {
return (
not_found_response(request_headers, cors_allow_credentials_origins),
None,
);
}
Err(err) => {
return (
report_listing_failure(err, request_headers, cors_allow_credentials_origins),
None,
);
}
}
}
match resolve_final_segment(&resolved_parent_dir, file_name).await {
Ok(Some(found)) => {
let resolved_file_path = found.to_string_lossy().into_owned();
(
serve_found(
found,
request_headers,
confine_to,
cors_allow_credentials_origins,
)
.await,
Some(resolved_file_path),
)
}
Ok(None) => (
not_found_response(request_headers, cors_allow_credentials_origins),
None,
),
Err(err) => (
report_listing_failure(err, request_headers, cors_allow_credentials_origins),
None,
),
}
}
async fn serve_found(
found: PathBuf,
request_headers: &HeaderMap,
confine_to: Option<&Path>,
cors_allow_credentials_origins: &[String],
) -> Result<hyper::Response<BoxBody>, hyper::http::Error> {
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
}
fn report_listing_failure(
err: String,
request_headers: &HeaderMap,
cors_allow_credentials_origins: &[String],
) -> Result<hyper::Response<BoxBody>, hyper::http::Error> {
log::error!("{}", err);
internal_server_error_response(
"failed to read fallback directory",
request_headers,
cors_allow_credentials_origins,
)
}
async fn resolve_final_segment(dir: &Path, file_name: &str) -> Result<Option<PathBuf>, String> {
let candidate = dir.join(file_name);
if is_existing_file(&candidate).await {
return Ok(Some(candidate));
}
let inferred_names: Vec<String> = if Path::new(file_name).extension().is_none()
&& let Some(stem) = Path::new(file_name).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 {
return Ok(Some(candidate));
}
}
JSON_COMPATIBLE_EXTENSIONS
.iter()
.map(|ext| format!("{}.{}", stem, ext))
.collect()
} else {
Vec::new()
};
if !dir.exists() {
return Ok(None);
}
let mut candidate_names = Vec::with_capacity(1 + inferred_names.len());
candidate_names.push(file_name.to_owned());
candidate_names.extend(inferred_names);
find_by_case_insensitive_listing(dir, &candidate_names).await
}
async fn resolve_dir_segment(dir: &Path, segment: &str) -> Result<Option<PathBuf>, String> {
let candidate = dir.join(segment);
if is_existing_dir(&candidate).await {
return Ok(Some(candidate));
}
if !dir.exists() {
return Ok(None);
}
match find_by_case_insensitive_listing(dir, &[segment.to_owned()]).await? {
Some(found) if is_existing_dir(&found).await => Ok(Some(found)),
_ => Ok(None),
}
}
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 is_existing_dir(path: &Path) -> bool {
let path = path.to_owned();
task::spawn_blocking(move || path.is_dir())
.await
.unwrap_or(false)
}
async fn find_by_case_insensitive_listing(
dir: &Path,
candidate_names: &[String],
) -> Result<Option<std::path::PathBuf>, String> {
let dir_for_blocking_task = dir.to_owned();
let candidate_names_lower: Vec<String> =
candidate_names.iter().map(|n| n.to_lowercase()).collect();
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
)
})?
.map(|entry| {
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_lower = path
.file_name()
.unwrap_or_default()
.to_str()
.unwrap_or_default()
.to_lowercase();
Ok((name_lower, path))
})
.collect::<Result<Vec<(String, PathBuf)>, String>>()?;
for candidate_lower in &candidate_names_lower {
if let Some((_, path)) = entries.iter().find(|(name, _)| name == candidate_lower) {
return Ok(Some(path.clone()));
}
}
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);
}
}