apimock_server/dyn_route.rs
1use hyper::HeaderMap;
2use tokio::task;
3
4use std::{fs, path::Path};
5
6use crate::{
7 response::{
8 error_response::{internal_server_error_response, not_found_response},
9 file_response::FileResponse,
10 },
11 types::BoxBody,
12};
13use apimock_routing::util::json::JSON_COMPATIBLE_EXTENSIONS;
14
15/// Serve a request from the fallback `respond_dir` (the file-based "just
16/// drop JSON in a folder" mode).
17///
18/// # Why the matching is case-insensitive and extension-tolerant
19///
20/// This handler powers the zero-config experience where URL paths map
21/// onto files on disk. The two accommodations we make are:
22///
23/// 1. **Case-insensitive filename match** — browsers often canonicalize
24/// paths (`/Users` vs `/users`), and operators rarely care. If a file
25/// matches in a case-insensitive compare we use it.
26/// 2. **Extension inference** — a request to `/foo` with no extension
27/// looks for `foo.json`, `foo.json5`, `foo.csv` in that order, then
28/// `foo/index.*`. This means operators can drop a single JSON file
29/// and use the shortened URL, which matches how most REST APIs are
30/// described in docs.
31pub async fn dyn_route_content(
32 url_path: &str,
33 fallback_respond_dir: &str,
34 request_headers: &HeaderMap,
35 confine_to: Option<&Path>,
36 cors_allow_credentials_origins: &[String],
37) -> Result<hyper::Response<BoxBody>, hyper::http::Error> {
38 let request_path =
39 Path::new(fallback_respond_dir).join(url_path.strip_prefix("/").unwrap_or_default());
40
41 let request_file_name = request_path
42 .file_name()
43 .unwrap_or_default()
44 .to_str()
45 .unwrap_or_default();
46
47 // Locate the parent dir. No parent at all (e.g. path was empty) →
48 // 500, since that indicates a bug elsewhere. Missing-parent-as-404
49 // is checked below, only if the fast paths below don't resolve the
50 // request — no need to stat it twice.
51 let Some(parent) = request_path.parent() else {
52 return internal_server_error_response(
53 &format!("parent dir not found: url_path = {}", url_path),
54 request_headers,
55 cors_allow_credentials_origins,
56 );
57 };
58 let dir = parent.to_owned();
59
60 // RFC 077 P-06: resolve with bounded `stat`s before ever listing the
61 // directory. Before this fix, the whole directory was listed on
62 // every request — even the overwhelmingly common ones below, which
63 // never needed to see another file's name.
64 //
65 // # Deliberate precedence note
66 //
67 // The previous single-pass order tried a case-insensitive listing
68 // match before extension inference. This tries an exact-path stat,
69 // then extension inference, before falling back to the listing
70 // below. The two orders can only disagree when a directory holds
71 // both a bare, differently-cased file (e.g. `FOO`) *and* an
72 // extension match (`foo.json`) for the same request — a
73 // configuration nothing in this codebase's tests exercises. Plain
74 // case-insensitive matching is unchanged and still runs, below, for
75 // whatever neither stat resolves.
76 let mut found = if is_existing_file(&request_path).await {
77 Some(request_path.clone())
78 } else {
79 None
80 };
81
82 // Extension inference: `/foo` → `foo.json` / `foo.json5` / `foo.csv`.
83 // This is the shape the README's zero-config pitch relies on
84 // (`/hello` -> `hello.json`), and it was always a direct stat per
85 // candidate — it just never ran until after the listing above it.
86 if found.is_none()
87 && request_path.extension().is_none()
88 && let Some(stem) = request_path.file_stem().and_then(|s| s.to_str())
89 {
90 for ext in JSON_COMPATIBLE_EXTENSIONS {
91 let candidate = dir.join(format!("{}.{}", stem, ext));
92 if is_existing_file(&candidate).await {
93 found = Some(candidate);
94 break;
95 }
96 }
97 }
98
99 // Last resort: list the directory for a case-insensitive name match
100 // (e.g. a client-side path that canonicalised differently than the
101 // filesystem did). Only reached when neither stat above resolved
102 // the request.
103 if found.is_none() {
104 if !dir.exists() {
105 return not_found_response(request_headers, cors_allow_credentials_origins);
106 }
107
108 found = match find_by_case_insensitive_listing(&dir, request_file_name).await {
109 Ok(found) => found,
110 // Both `err` sources carry `dir`'s own filesystem path
111 // (RFC 065 D4) — logged in full, server-side; the client
112 // only ever sees a generic message naming the problem, not
113 // the server's directory layout.
114 Err(err) => {
115 log::error!("{}", err);
116 return internal_server_error_response(
117 "failed to read fallback directory",
118 request_headers,
119 cors_allow_credentials_origins,
120 );
121 }
122 };
123 }
124
125 let Some(found) = found else {
126 return not_found_response(request_headers, cors_allow_credentials_origins);
127 };
128
129 let file_path = found.to_str().unwrap_or_default();
130 FileResponse::new(
131 file_path,
132 None,
133 request_headers,
134 confine_to,
135 cors_allow_credentials_origins,
136 )
137 .file_content_response()
138 .await
139}
140
141/// `true` iff `path` exists and is a regular file — a single `stat`,
142/// off the async runtime like the directory listing it lets most
143/// requests skip (see `dyn_route_content`).
144async fn is_existing_file(path: &Path) -> bool {
145 let path = path.to_owned();
146 task::spawn_blocking(move || path.is_file())
147 .await
148 .unwrap_or(false)
149}
150
151/// The last resort in `dyn_route_content`: list `dir` and find
152/// `request_file_name` case-insensitively. Only reached when neither an
153/// exact-path stat nor extension inference resolved the request.
154///
155/// # Why `spawn_blocking` and not `tokio::fs`
156///
157/// `tokio::fs` is a thin async wrapper that internally calls
158/// `spawn_blocking` itself. Using it directly would add a layer of
159/// indirection while we iterate a `DirEntry` stream, so one
160/// `spawn_blocking` for the whole scan is simpler and uses the same
161/// thread pool underneath.
162async fn find_by_case_insensitive_listing(
163 dir: &Path,
164 request_file_name: &str,
165) -> Result<Option<std::path::PathBuf>, String> {
166 let dir_for_blocking_task = dir.to_owned();
167 let request_file_name = request_file_name.to_owned();
168 let read_dir_result =
169 task::spawn_blocking(move || -> Result<Option<std::path::PathBuf>, String> {
170 let entries = fs::read_dir(dir_for_blocking_task.as_path()).map_err(|err| {
171 format!(
172 "failed to get dir: {} ({})",
173 dir_for_blocking_task.to_string_lossy(),
174 err
175 )
176 })?;
177 for entry in entries {
178 let entry = entry.map_err(|err| {
179 format!(
180 "failed to get dir entry from dir: {} ({})",
181 dir_for_blocking_task.to_string_lossy(),
182 err
183 )
184 })?;
185 let path = entry.path();
186 let name = path
187 .file_name()
188 .unwrap_or_default()
189 .to_str()
190 .unwrap_or_default();
191 if name.eq_ignore_ascii_case(&request_file_name) {
192 return Ok(Some(path));
193 }
194 }
195 Ok(None)
196 })
197 .await;
198
199 match read_dir_result {
200 Ok(Ok(found)) => Ok(found),
201 Ok(Err(err)) => Err(err),
202 Err(err) => Err(format!(
203 "failed to get dir entries ({}): {}",
204 dir.to_string_lossy(),
205 err
206 )),
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use hyper::HeaderMap;
213
214 use crate::response::confine::canonical_dir;
215
216 use super::dyn_route_content;
217
218 /// RFC 063: this calls `dyn_route_content` directly with a `url_path`
219 /// that still carries `..` — bypassing `normalize_url_path`, the
220 /// other, independent defence (applied earlier, at request-parse
221 /// time). Confirms confinement alone, without that first layer,
222 /// still refuses the escape.
223 #[tokio::test]
224 async fn a_raw_dot_dot_is_refused_even_without_url_normalisation_first() {
225 let outer = tempfile::tempdir().unwrap();
226 let respond_dir = outer.path().join("respond_dir");
227 std::fs::create_dir(&respond_dir).unwrap();
228 std::fs::write(outer.path().join("outside.txt"), "SECRET-OUTSIDE-CONTENT").unwrap();
229
230 let confine_to = canonical_dir(respond_dir.to_str().unwrap());
231 assert!(confine_to.is_some(), "fixture directory must canonicalise");
232
233 let response = dyn_route_content(
234 "/../outside.txt",
235 respond_dir.to_str().unwrap(),
236 &HeaderMap::new(),
237 confine_to.as_deref(),
238 &[],
239 )
240 .await
241 .expect("dyn_route_content must not fail to build a response");
242
243 assert_eq!(response.status(), hyper::StatusCode::NOT_FOUND);
244 }
245
246 /// `confine_to: None` means "the base directory couldn't be
247 /// canonicalised at load time" (e.g. it doesn't exist), which must
248 /// fail closed — refuse everything — rather than skip the check.
249 /// Distinct from the pre-fix behaviour, which had no `confine_to`
250 /// parameter to be `None` in the first place; that comparison is
251 /// made by re-running this file's tests against the pre-fix source
252 /// (reported alongside this evidence), not by this test.
253 #[tokio::test]
254 async fn a_base_that_failed_to_canonicalise_refuses_every_candidate() {
255 let dir = tempfile::tempdir().unwrap();
256 std::fs::write(dir.path().join("hello.json"), "{}").unwrap();
257
258 let response = dyn_route_content(
259 "/hello.json",
260 dir.path().to_str().unwrap(),
261 &HeaderMap::new(),
262 None,
263 &[],
264 )
265 .await
266 .expect("dyn_route_content must not fail to build a response");
267
268 assert_eq!(response.status(), hyper::StatusCode::NOT_FOUND);
269 }
270
271 /// Read a response body to bytes, for tests that need to confirm
272 /// *which* file was served, not only that something 200'd.
273 async fn body_bytes(response: hyper::Response<crate::types::BoxBody>) -> Vec<u8> {
274 http_body_util::BodyExt::collect(response.into_body())
275 .await
276 .unwrap()
277 .to_bytes()
278 .to_vec()
279 }
280
281 /// RFC 077 P-06: the common case — request path matches a file's
282 /// name and case exactly — resolves via the fast stat path, never
283 /// touching the case-insensitive listing fallback.
284 #[tokio::test]
285 async fn exact_case_match_resolves_without_listing() {
286 let dir = tempfile::tempdir().unwrap();
287 std::fs::write(dir.path().join("hello.json"), "{\"ok\":true}").unwrap();
288 let confine_to = canonical_dir(dir.path().to_str().unwrap());
289
290 let response = dyn_route_content(
291 "/hello.json",
292 dir.path().to_str().unwrap(),
293 &HeaderMap::new(),
294 confine_to.as_deref(),
295 &[],
296 )
297 .await
298 .unwrap();
299
300 assert_eq!(response.status(), hyper::StatusCode::OK);
301 assert_eq!(body_bytes(response).await, b"{\"ok\":true}");
302 }
303
304 /// RFC 077 P-06: extension inference (`/hello` -> `hello.json`) — the
305 /// README's zero-config shape — still resolves, now via a direct
306 /// stat rather than a directory listing.
307 #[tokio::test]
308 async fn extension_inference_still_resolves() {
309 let dir = tempfile::tempdir().unwrap();
310 std::fs::write(dir.path().join("hello.json"), "{\"ok\":true}").unwrap();
311 let confine_to = canonical_dir(dir.path().to_str().unwrap());
312
313 let response = dyn_route_content(
314 "/hello",
315 dir.path().to_str().unwrap(),
316 &HeaderMap::new(),
317 confine_to.as_deref(),
318 &[],
319 )
320 .await
321 .unwrap();
322
323 assert_eq!(response.status(), hyper::StatusCode::OK);
324 assert_eq!(body_bytes(response).await, b"{\"ok\":true}");
325 }
326
327 /// RFC 077 P-06: a case mismatch (`/Hello.JSON` against a file
328 /// literally named `hello.json`) still resolves — the
329 /// case-insensitive listing fallback is unchanged, only deferred
330 /// until the cheap stats above it miss.
331 #[tokio::test]
332 async fn case_mismatch_still_resolves_via_the_listing_fallback() {
333 let dir = tempfile::tempdir().unwrap();
334 std::fs::write(dir.path().join("hello.json"), "{\"ok\":true}").unwrap();
335 let confine_to = canonical_dir(dir.path().to_str().unwrap());
336
337 let response = dyn_route_content(
338 "/Hello.JSON",
339 dir.path().to_str().unwrap(),
340 &HeaderMap::new(),
341 confine_to.as_deref(),
342 &[],
343 )
344 .await
345 .unwrap();
346
347 assert_eq!(response.status(), hyper::StatusCode::OK);
348 assert_eq!(body_bytes(response).await, b"{\"ok\":true}");
349 }
350
351 /// REVIEW-001 F-01: the disclosed P-06 precedence change (exact-path
352 /// stat + extension inference now run before the case-insensitive
353 /// listing) is platform-dependent, not universal — a directory
354 /// holding both a bare, differently-cased file (`FOO`) and an
355 /// extension match (`foo.json`) for the same extension-less request
356 /// (`/foo`) resolves differently depending on whether the
357 /// filesystem's own `stat` is case-sensitive:
358 ///
359 /// - Case-sensitive (Linux, the outlier): the exact-path stat for
360 /// the literal `foo` misses `FOO` entirely, so extension inference
361 /// (which now runs first) finds `foo.json` — the disclosed change.
362 /// - Case-insensitive (macOS APFS default, Windows NTFS default):
363 /// the exact-path stat for `foo` *is* `FOO` at the OS level, so the
364 /// bare file wins here too, same as before this fix — no change.
365 ///
366 /// Detected at runtime against this test's own directory (not
367 /// assumed from `cfg!(target_os)`, which can be wrong — a
368 /// case-sensitive APFS volume or a case-insensitive Linux mount both
369 /// exist) so this test states what actually happened rather than
370 /// what platform it ran on. CI's three-OS matrix passing on Linux,
371 /// macOS, and Windows is consistent with both branches occurring
372 /// (the latter two default to case-insensitive filesystems) — the
373 /// test itself, passing either way, doesn't print or otherwise
374 /// prove which branch a given runner actually took.
375 #[tokio::test]
376 async fn bare_differently_cased_file_vs_extension_match_resolves_per_filesystem_case_sensitivity()
377 {
378 let dir = tempfile::tempdir().unwrap();
379 std::fs::write(dir.path().join("FOO"), "BARE-FILE-CONTENT").unwrap();
380 std::fs::write(dir.path().join("foo.json"), "{\"extension\":\"inferred\"}").unwrap();
381
382 let filesystem_is_case_insensitive = dir.path().join("foo").is_file();
383
384 let confine_to = canonical_dir(dir.path().to_str().unwrap());
385
386 let response = dyn_route_content(
387 "/foo",
388 dir.path().to_str().unwrap(),
389 &HeaderMap::new(),
390 confine_to.as_deref(),
391 &[],
392 )
393 .await
394 .unwrap();
395
396 assert_eq!(response.status(), hyper::StatusCode::OK);
397 let body = body_bytes(response).await;
398
399 if filesystem_is_case_insensitive {
400 assert_eq!(
401 body, b"BARE-FILE-CONTENT",
402 "case-insensitive filesystem: the exact-path stat for \
403 \"foo\" already resolves to \"FOO\" at the OS level, so \
404 the bare file should still win, unchanged from before \
405 this fix"
406 );
407 } else {
408 assert_eq!(
409 body, b"{\"extension\":\"inferred\"}",
410 "case-sensitive filesystem: the exact-path stat for \
411 \"foo\" misses \"FOO\", so extension inference (which \
412 now runs before the listing) should resolve to \
413 \"foo.json\" — the disclosed precedence change"
414 );
415 }
416 }
417
418 /// A directory with no matching file at all — every path (exact
419 /// stat, extension inference, listing) misses — still 404s rather
420 /// than erroring.
421 #[tokio::test]
422 async fn no_match_anywhere_is_not_found() {
423 let dir = tempfile::tempdir().unwrap();
424 std::fs::write(dir.path().join("other.json"), "{}").unwrap();
425 let confine_to = canonical_dir(dir.path().to_str().unwrap());
426
427 let response = dyn_route_content(
428 "/does-not-exist",
429 dir.path().to_str().unwrap(),
430 &HeaderMap::new(),
431 confine_to.as_deref(),
432 &[],
433 )
434 .await
435 .unwrap();
436
437 assert_eq!(response.status(), hyper::StatusCode::NOT_FOUND);
438 }
439}