Skip to main content

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}