Skip to main content

apimock_server/
dyn_route.rs

1use hyper::HeaderMap;
2use tokio::task;
3
4use std::{
5    fs,
6    path::{Path, PathBuf},
7};
8
9use crate::{
10    response::{
11        error_response::{internal_server_error_response, not_found_response},
12        file_response::FileResponse,
13    },
14    types::BoxBody,
15};
16use apimock_routing::util::json::JSON_COMPATIBLE_EXTENSIONS;
17
18/// Serve a request from the fallback `respond_dir` (the file-based "just
19/// drop JSON in a folder" mode).
20///
21/// # Why the matching is case-insensitive and extension-tolerant
22///
23/// This handler powers the zero-config experience where URL paths map
24/// onto files on disk. The two accommodations we make are:
25///
26/// 1. **Case-insensitive match, at every segment** (RFC 075 F-05) —
27///    browsers often canonicalize paths (`/Users` vs `/users`), and
28///    operators rarely care. apimock folds case itself rather than
29///    delegating to the filesystem, because filesystem case behaviour
30///    is not portable: Linux is case-sensitive, Windows and macOS
31///    (APFS default) are not, so the same config and request used to
32///    get different answers depending on which segment differed and
33///    which platform served it. Uniform, apimock-enforced folding is
34///    the only version of this that a committed rule set can rely on
35///    identically everywhere.
36/// 2. **Extension inference** — a request to `/foo` with no extension
37///    looks for `foo.json`, `foo.json5`, `foo.csv` in that order, then
38///    `foo/index.*`. This means operators can drop a single JSON file
39///    and use the shortened URL, which matches how most REST APIs are
40///    described in docs.
41pub async fn dyn_route_content(
42    url_path: &str,
43    fallback_respond_dir: &str,
44    request_headers: &HeaderMap,
45    confine_to: Option<&Path>,
46    cors_allow_credentials_origins: &[String],
47) -> Result<hyper::Response<BoxBody>, hyper::http::Error> {
48    dyn_route_content_traced(
49        url_path,
50        fallback_respond_dir,
51        request_headers,
52        confine_to,
53        cors_allow_credentials_origins,
54    )
55    .await
56    .0
57}
58
59/// Same resolution as [`dyn_route_content`], additionally returning the
60/// resolved file path when one was actually found — used only by
61/// `server.rs`'s trace-event emission (RFC 073 F-08's "fallback"
62/// outcome needs the file that was actually served, not just the
63/// request's own URL path). Kept separate from the public
64/// `dyn_route_content` (which `apimock get`, RFC 055, also calls, and
65/// whose signature is public API) so that function's signature and the
66/// public API baseline are untouched by this — the resolved path is an
67/// internal detail this crate's own tracing needs, not something every
68/// caller of the public function should have to receive.
69pub(crate) async fn dyn_route_content_traced(
70    url_path: &str,
71    fallback_respond_dir: &str,
72    request_headers: &HeaderMap,
73    confine_to: Option<&Path>,
74    cors_allow_credentials_origins: &[String],
75) -> (
76    Result<hyper::Response<BoxBody>, hyper::http::Error>,
77    Option<String>,
78) {
79    let base_dir = Path::new(fallback_respond_dir);
80    let relative = url_path.strip_prefix('/').unwrap_or(url_path);
81    let segments: Vec<&str> = relative.split('/').filter(|s| !s.is_empty()).collect();
82
83    // A bare "/" (or an all-slashes path) has no segment of its own to
84    // resolve — this is the pre-existing "index" mechanism, unchanged
85    // by RFC 075: search for the fallback directory *itself*, by name,
86    // in its own parent. That candidate is a directory, not a file, and
87    // gets handed to `FileResponse` unfiltered exactly like any other
88    // directory match below — `resolve_with_json_compatible_extensions`
89    // there is what actually finds `index.json`/`index.json5`/
90    // `index.html` inside it.
91    //
92    // `file_name()` is `None` when `fallback_respond_dir` is exactly
93    // `.` (the default) — `.` is a special "current directory" path
94    // component, not a named one. Falling back to `""` reproduces the
95    // pre-RFC-075 code's own behaviour there exactly (it used
96    // `.unwrap_or_default()` at this same spot): the search degenerates
97    // to an empty name that can never match a real directory entry, so
98    // it falls straight through to 404 rather than 500 — a case-free
99    // config still 404s correctly, it just doesn't have an "own name"
100    // to look for.
101    let (file_name, parent_segments): (&str, &[&str]) = match segments.split_last() {
102        Some((&last, rest)) => (last, rest),
103        None => (
104            base_dir.file_name().and_then(|s| s.to_str()).unwrap_or(""),
105            &[],
106        ),
107    };
108    let search_base_dir = if segments.is_empty() {
109        // Search in the fallback directory's own parent, not inside
110        // itself — see the comment above.
111        let Some(parent) = base_dir.parent() else {
112            return (
113                internal_server_error_response(
114                    &format!("parent dir not found: url_path = {}", url_path),
115                    request_headers,
116                    cors_allow_credentials_origins,
117                ),
118                None,
119            );
120        };
121        parent
122    } else {
123        base_dir
124    };
125
126    // RFC 077 P-06's fast path, preserved: try the whole remaining path
127    // as it was literally requested, one `stat` (plus bounded extension
128    // inference) against the *naive*, not-yet-case-corrected parent
129    // directory. On a case-insensitive filesystem this already resolves
130    // every segment correctly — the OS folds case for every segment at
131    // once, for free — so the common case still costs nothing beyond
132    // what P-06 already established. Falling through to the per-segment
133    // walk below is what makes Linux (case-sensitive) match what the
134    // other two platforms already gave for free here (RFC 075 F-05).
135    let mut naive_parent_dir = search_base_dir.to_owned();
136    for &segment in parent_segments {
137        naive_parent_dir.push(segment);
138    }
139    match resolve_final_segment(&naive_parent_dir, file_name).await {
140        Ok(Some(found)) => {
141            let resolved_file_path = found.to_string_lossy().into_owned();
142            return (
143                serve_found(
144                    found,
145                    request_headers,
146                    confine_to,
147                    cors_allow_credentials_origins,
148                )
149                .await,
150                Some(resolved_file_path),
151            );
152        }
153        Ok(None) => {}
154        Err(err) => {
155            return (
156                report_listing_failure(err, request_headers, cors_allow_credentials_origins),
157                None,
158            );
159        }
160    }
161
162    if parent_segments.is_empty() {
163        // No intermediate segment exists to have a case mismatch — the
164        // fast path above already checked the only possible location.
165        return (
166            not_found_response(request_headers, cors_allow_credentials_origins),
167            None,
168        );
169    }
170
171    // RFC 075 F-05: the fast path missed. Walk every intermediate
172    // segment through apimock's own case-insensitive comparison instead
173    // of the filesystem's, so Linux resolves a case-mismatched
174    // directory the same way macOS/Windows already did above.
175    let mut resolved_parent_dir = search_base_dir.to_owned();
176    for &segment in parent_segments {
177        match resolve_dir_segment(&resolved_parent_dir, segment).await {
178            Ok(Some(next)) => resolved_parent_dir = next,
179            Ok(None) => {
180                return (
181                    not_found_response(request_headers, cors_allow_credentials_origins),
182                    None,
183                );
184            }
185            Err(err) => {
186                return (
187                    report_listing_failure(err, request_headers, cors_allow_credentials_origins),
188                    None,
189                );
190            }
191        }
192    }
193
194    match resolve_final_segment(&resolved_parent_dir, file_name).await {
195        Ok(Some(found)) => {
196            let resolved_file_path = found.to_string_lossy().into_owned();
197            (
198                serve_found(
199                    found,
200                    request_headers,
201                    confine_to,
202                    cors_allow_credentials_origins,
203                )
204                .await,
205                Some(resolved_file_path),
206            )
207        }
208        Ok(None) => (
209            not_found_response(request_headers, cors_allow_credentials_origins),
210            None,
211        ),
212        Err(err) => (
213            report_listing_failure(err, request_headers, cors_allow_credentials_origins),
214            None,
215        ),
216    }
217}
218
219/// Build the response for a resolved file — the one place `dyn_route_content`
220/// actually reads and serves content, shared by both the fast path and
221/// the per-segment-resolved fallback.
222async fn serve_found(
223    found: PathBuf,
224    request_headers: &HeaderMap,
225    confine_to: Option<&Path>,
226    cors_allow_credentials_origins: &[String],
227) -> Result<hyper::Response<BoxBody>, hyper::http::Error> {
228    let file_path = found.to_str().unwrap_or_default();
229    FileResponse::new(
230        file_path,
231        None,
232        request_headers,
233        confine_to,
234        cors_allow_credentials_origins,
235    )
236    .file_content_response()
237    .await
238}
239
240/// A directory listing failed to read (as opposed to simply not
241/// containing a match) — both `resolve_final_segment` and
242/// `resolve_dir_segment`'s error carries `dir`'s own filesystem path
243/// (RFC 065 D4): logged in full, server-side; the client only ever sees
244/// a generic message naming the problem, not the server's directory
245/// layout.
246fn report_listing_failure(
247    err: String,
248    request_headers: &HeaderMap,
249    cors_allow_credentials_origins: &[String],
250) -> Result<hyper::Response<BoxBody>, hyper::http::Error> {
251    log::error!("{}", err);
252    internal_server_error_response(
253        "failed to read fallback directory",
254        request_headers,
255        cors_allow_credentials_origins,
256    )
257}
258
259/// Resolve the final path segment (`file_name`) inside `dir`, using the
260/// same three-tier strategy regardless of whether `dir` is the naive
261/// (requested-case) parent or one already resolved case-insensitively:
262/// exact stat, extension inference (only when `file_name` has no
263/// extension), then a case-insensitive directory listing as the last
264/// resort.
265///
266/// # The listing tier's result is *not* filtered to files
267///
268/// A resolved candidate here can be a directory — deliberately: this is
269/// the pre-existing mechanism (unchanged by RFC 075) behind both `/`
270/// resolving to `index.json` and `/subdir` resolving to
271/// `subdir/index.json5`. `dyn_route_content` hands whatever this
272/// returns straight to `FileResponse`, whose own
273/// `resolve_with_json_compatible_extensions` recognises a directory
274/// candidate and looks for `index.*` inside it. Filtering the listing
275/// tier to files-only here would silently break both of those.
276async fn resolve_final_segment(dir: &Path, file_name: &str) -> Result<Option<PathBuf>, String> {
277    let candidate = dir.join(file_name);
278    if is_existing_file(&candidate).await {
279        return Ok(Some(candidate));
280    }
281
282    // Extension inference: `/foo` → `foo.json` / `foo.json5` / `foo.csv`.
283    // This is the shape the README's zero-config pitch relies on
284    // (`/hello` -> `hello.json`), and it's always been a direct stat per
285    // candidate, independent of the listing below.
286    let inferred_names: Vec<String> = if Path::new(file_name).extension().is_none()
287        && let Some(stem) = Path::new(file_name).file_stem().and_then(|s| s.to_str())
288    {
289        for ext in JSON_COMPATIBLE_EXTENSIONS {
290            let candidate = dir.join(format!("{}.{}", stem, ext));
291            if is_existing_file(&candidate).await {
292                return Ok(Some(candidate));
293            }
294        }
295        JSON_COMPATIBLE_EXTENSIONS
296            .iter()
297            .map(|ext| format!("{}.{}", stem, ext))
298            .collect()
299    } else {
300        Vec::new()
301    };
302
303    if !dir.exists() {
304        return Ok(None);
305    }
306
307    // RFC 075 F-05: case-folding and extension inference must combine,
308    // not just each work alone — a request for `/CaseDir/file` must
309    // still resolve `File.json` even though neither the exact-stat tier
310    // above (case matched, no extension) nor the extension-inference
311    // tier above (extension matched, case didn't) can find it alone.
312    // Try the bare name first, then each extension-inferred name, all
313    // case-insensitively, in the same priority order the exact-stat
314    // tiers above already used.
315    let mut candidate_names = Vec::with_capacity(1 + inferred_names.len());
316    candidate_names.push(file_name.to_owned());
317    candidate_names.extend(inferred_names);
318    find_by_case_insensitive_listing(dir, &candidate_names).await
319}
320
321/// Resolve one intermediate directory segment inside `dir`: exact stat
322/// (as a directory), then a case-insensitive listing match that is also
323/// a directory. No extension inference — that's a final-segment concept
324/// only.
325async fn resolve_dir_segment(dir: &Path, segment: &str) -> Result<Option<PathBuf>, String> {
326    let candidate = dir.join(segment);
327    if is_existing_dir(&candidate).await {
328        return Ok(Some(candidate));
329    }
330
331    if !dir.exists() {
332        return Ok(None);
333    }
334    match find_by_case_insensitive_listing(dir, &[segment.to_owned()]).await? {
335        Some(found) if is_existing_dir(&found).await => Ok(Some(found)),
336        _ => Ok(None),
337    }
338}
339
340/// `true` iff `path` exists and is a regular file — a single `stat`,
341/// off the async runtime like the directory listing it lets most
342/// requests skip (see `dyn_route_content`).
343async fn is_existing_file(path: &Path) -> bool {
344    let path = path.to_owned();
345    task::spawn_blocking(move || path.is_file())
346        .await
347        .unwrap_or(false)
348}
349
350/// `true` iff `path` exists and is a directory — `is_existing_file`'s
351/// counterpart, used to resolve intermediate path segments (RFC 075
352/// F-05) rather than the final one.
353async fn is_existing_dir(path: &Path) -> bool {
354    let path = path.to_owned();
355    task::spawn_blocking(move || path.is_dir())
356        .await
357        .unwrap_or(false)
358}
359
360/// The last resort in `resolve_final_segment` and `resolve_dir_segment`:
361/// list `dir` and find `name` case-insensitively. Only reached when an
362/// exact-path stat (and, for a final segment, extension inference)
363/// didn't resolve the request.
364///
365/// # Unicode-aware, not ASCII-only (RFC 075 § 2a)
366///
367/// Tranche 3's fast path (`is_existing_file`/`is_existing_dir` against
368/// the literal requested path) delegates to the OS's own `stat`, which
369/// on a case-insensitive filesystem folds *Unicode* case (a request for
370/// `CAFÉ.json` resolves `café.json` there for free). If this listing
371/// compared ASCII-only (`eq_ignore_ascii_case`), Linux would refuse the
372/// exact same request the fast path already accepts on macOS/Windows —
373/// reintroducing F-05's own defect for non-ASCII names specifically,
374/// through the fix meant to remove it. Comparing with `to_lowercase()`
375/// instead means this listing accepts everything the fast path's OS
376/// delegation already does, so the two can't disagree with each other:
377/// whichever one runs for a given request answers the same way the
378/// other would have. Full Unicode case-folding equivalence with any
379/// specific OS's own tables is not claimed or needed here — only that
380/// this project's two paths never contradict each other; the same
381/// class of limitation this project's own docs already carry for
382/// Unicode *normalisation* (RFC 075 § 4's NFC/NFD scope-out), just
383/// applied to folding instead of encoding.
384///
385/// # Why `spawn_blocking` and not `tokio::fs`
386///
387/// `tokio::fs` is a thin async wrapper that internally calls
388/// `spawn_blocking` itself. Using it directly would add a layer of
389/// indirection while we iterate a `DirEntry` stream, so one
390/// `spawn_blocking` for the whole scan is simpler and uses the same
391/// thread pool underneath.
392/// `candidate_names` is checked in order — the first name (not the
393/// first directory entry) with a case-insensitive match wins, so a
394/// caller combining a bare name with extension-inferred variants (see
395/// `resolve_final_segment`) gets the same priority the exact-stat tiers
396/// ahead of this one already use, regardless of the OS's own (arbitrary)
397/// listing order.
398async fn find_by_case_insensitive_listing(
399    dir: &Path,
400    candidate_names: &[String],
401) -> Result<Option<std::path::PathBuf>, String> {
402    let dir_for_blocking_task = dir.to_owned();
403    let candidate_names_lower: Vec<String> =
404        candidate_names.iter().map(|n| n.to_lowercase()).collect();
405    let read_dir_result =
406        task::spawn_blocking(move || -> Result<Option<std::path::PathBuf>, String> {
407            let entries = fs::read_dir(dir_for_blocking_task.as_path())
408                .map_err(|err| {
409                    format!(
410                        "failed to get dir: {} ({})",
411                        dir_for_blocking_task.to_string_lossy(),
412                        err
413                    )
414                })?
415                .map(|entry| {
416                    let entry = entry.map_err(|err| {
417                        format!(
418                            "failed to get dir entry from dir: {} ({})",
419                            dir_for_blocking_task.to_string_lossy(),
420                            err
421                        )
422                    })?;
423                    let path = entry.path();
424                    let name_lower = path
425                        .file_name()
426                        .unwrap_or_default()
427                        .to_str()
428                        .unwrap_or_default()
429                        .to_lowercase();
430                    Ok((name_lower, path))
431                })
432                .collect::<Result<Vec<(String, PathBuf)>, String>>()?;
433
434            for candidate_lower in &candidate_names_lower {
435                if let Some((_, path)) = entries.iter().find(|(name, _)| name == candidate_lower) {
436                    return Ok(Some(path.clone()));
437                }
438            }
439            Ok(None)
440        })
441        .await;
442
443    match read_dir_result {
444        Ok(Ok(found)) => Ok(found),
445        Ok(Err(err)) => Err(err),
446        Err(err) => Err(format!(
447            "failed to get dir entries ({}): {}",
448            dir.to_string_lossy(),
449            err
450        )),
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use hyper::HeaderMap;
457
458    use crate::response::confine::canonical_dir;
459
460    use super::dyn_route_content;
461
462    /// RFC 063: this calls `dyn_route_content` directly with a `url_path`
463    /// that still carries `..` — bypassing `normalize_url_path`, the
464    /// other, independent defence (applied earlier, at request-parse
465    /// time). Confirms confinement alone, without that first layer,
466    /// still refuses the escape.
467    #[tokio::test]
468    async fn a_raw_dot_dot_is_refused_even_without_url_normalisation_first() {
469        let outer = tempfile::tempdir().unwrap();
470        let respond_dir = outer.path().join("respond_dir");
471        std::fs::create_dir(&respond_dir).unwrap();
472        std::fs::write(outer.path().join("outside.txt"), "SECRET-OUTSIDE-CONTENT").unwrap();
473
474        let confine_to = canonical_dir(respond_dir.to_str().unwrap());
475        assert!(confine_to.is_some(), "fixture directory must canonicalise");
476
477        let response = dyn_route_content(
478            "/../outside.txt",
479            respond_dir.to_str().unwrap(),
480            &HeaderMap::new(),
481            confine_to.as_deref(),
482            &[],
483        )
484        .await
485        .expect("dyn_route_content must not fail to build a response");
486
487        assert_eq!(response.status(), hyper::StatusCode::NOT_FOUND);
488    }
489
490    /// `confine_to: None` means "the base directory couldn't be
491    /// canonicalised at load time" (e.g. it doesn't exist), which must
492    /// fail closed — refuse everything — rather than skip the check.
493    /// Distinct from the pre-fix behaviour, which had no `confine_to`
494    /// parameter to be `None` in the first place; that comparison is
495    /// made by re-running this file's tests against the pre-fix source
496    /// (reported alongside this evidence), not by this test.
497    #[tokio::test]
498    async fn a_base_that_failed_to_canonicalise_refuses_every_candidate() {
499        let dir = tempfile::tempdir().unwrap();
500        std::fs::write(dir.path().join("hello.json"), "{}").unwrap();
501
502        let response = dyn_route_content(
503            "/hello.json",
504            dir.path().to_str().unwrap(),
505            &HeaderMap::new(),
506            None,
507            &[],
508        )
509        .await
510        .expect("dyn_route_content must not fail to build a response");
511
512        assert_eq!(response.status(), hyper::StatusCode::NOT_FOUND);
513    }
514
515    /// Read a response body to bytes, for tests that need to confirm
516    /// *which* file was served, not only that something 200'd.
517    async fn body_bytes(response: hyper::Response<crate::types::BoxBody>) -> Vec<u8> {
518        http_body_util::BodyExt::collect(response.into_body())
519            .await
520            .unwrap()
521            .to_bytes()
522            .to_vec()
523    }
524
525    /// RFC 077 P-06: the common case — request path matches a file's
526    /// name and case exactly — resolves via the fast stat path, never
527    /// touching the case-insensitive listing fallback.
528    #[tokio::test]
529    async fn exact_case_match_resolves_without_listing() {
530        let dir = tempfile::tempdir().unwrap();
531        std::fs::write(dir.path().join("hello.json"), "{\"ok\":true}").unwrap();
532        let confine_to = canonical_dir(dir.path().to_str().unwrap());
533
534        let response = dyn_route_content(
535            "/hello.json",
536            dir.path().to_str().unwrap(),
537            &HeaderMap::new(),
538            confine_to.as_deref(),
539            &[],
540        )
541        .await
542        .unwrap();
543
544        assert_eq!(response.status(), hyper::StatusCode::OK);
545        assert_eq!(body_bytes(response).await, b"{\"ok\":true}");
546    }
547
548    /// RFC 077 P-06: extension inference (`/hello` -> `hello.json`) — the
549    /// README's zero-config shape — still resolves, now via a direct
550    /// stat rather than a directory listing.
551    #[tokio::test]
552    async fn extension_inference_still_resolves() {
553        let dir = tempfile::tempdir().unwrap();
554        std::fs::write(dir.path().join("hello.json"), "{\"ok\":true}").unwrap();
555        let confine_to = canonical_dir(dir.path().to_str().unwrap());
556
557        let response = dyn_route_content(
558            "/hello",
559            dir.path().to_str().unwrap(),
560            &HeaderMap::new(),
561            confine_to.as_deref(),
562            &[],
563        )
564        .await
565        .unwrap();
566
567        assert_eq!(response.status(), hyper::StatusCode::OK);
568        assert_eq!(body_bytes(response).await, b"{\"ok\":true}");
569    }
570
571    /// RFC 077 P-06: a case mismatch (`/Hello.JSON` against a file
572    /// literally named `hello.json`) still resolves — the
573    /// case-insensitive listing fallback is unchanged, only deferred
574    /// until the cheap stats above it miss.
575    #[tokio::test]
576    async fn case_mismatch_still_resolves_via_the_listing_fallback() {
577        let dir = tempfile::tempdir().unwrap();
578        std::fs::write(dir.path().join("hello.json"), "{\"ok\":true}").unwrap();
579        let confine_to = canonical_dir(dir.path().to_str().unwrap());
580
581        let response = dyn_route_content(
582            "/Hello.JSON",
583            dir.path().to_str().unwrap(),
584            &HeaderMap::new(),
585            confine_to.as_deref(),
586            &[],
587        )
588        .await
589        .unwrap();
590
591        assert_eq!(response.status(), hyper::StatusCode::OK);
592        assert_eq!(body_bytes(response).await, b"{\"ok\":true}");
593    }
594
595    /// REVIEW-001 F-01: the disclosed P-06 precedence change (exact-path
596    /// stat + extension inference now run before the case-insensitive
597    /// listing) is platform-dependent, not universal — a directory
598    /// holding both a bare, differently-cased file (`FOO`) and an
599    /// extension match (`foo.json`) for the same extension-less request
600    /// (`/foo`) resolves differently depending on whether the
601    /// filesystem's own `stat` is case-sensitive:
602    ///
603    /// - Case-sensitive (Linux, the outlier): the exact-path stat for
604    ///   the literal `foo` misses `FOO` entirely, so extension inference
605    ///   (which now runs first) finds `foo.json` — the disclosed change.
606    /// - Case-insensitive (macOS APFS default, Windows NTFS default):
607    ///   the exact-path stat for `foo` *is* `FOO` at the OS level, so the
608    ///   bare file wins here too, same as before this fix — no change.
609    ///
610    /// Detected at runtime against this test's own directory (not
611    /// assumed from `cfg!(target_os)`, which can be wrong — a
612    /// case-sensitive APFS volume or a case-insensitive Linux mount both
613    /// exist) so this test states what actually happened rather than
614    /// what platform it ran on. CI's three-OS matrix passing on Linux,
615    /// macOS, and Windows is consistent with both branches occurring
616    /// (the latter two default to case-insensitive filesystems) — the
617    /// test itself, passing either way, doesn't print or otherwise
618    /// prove which branch a given runner actually took.
619    #[tokio::test]
620    async fn bare_differently_cased_file_vs_extension_match_resolves_per_filesystem_case_sensitivity()
621     {
622        let dir = tempfile::tempdir().unwrap();
623        std::fs::write(dir.path().join("FOO"), "BARE-FILE-CONTENT").unwrap();
624        std::fs::write(dir.path().join("foo.json"), "{\"extension\":\"inferred\"}").unwrap();
625
626        let filesystem_is_case_insensitive = dir.path().join("foo").is_file();
627
628        let confine_to = canonical_dir(dir.path().to_str().unwrap());
629
630        let response = dyn_route_content(
631            "/foo",
632            dir.path().to_str().unwrap(),
633            &HeaderMap::new(),
634            confine_to.as_deref(),
635            &[],
636        )
637        .await
638        .unwrap();
639
640        assert_eq!(response.status(), hyper::StatusCode::OK);
641        let body = body_bytes(response).await;
642
643        if filesystem_is_case_insensitive {
644            assert_eq!(
645                body, b"BARE-FILE-CONTENT",
646                "case-insensitive filesystem: the exact-path stat for \
647                 \"foo\" already resolves to \"FOO\" at the OS level, so \
648                 the bare file should still win, unchanged from before \
649                 this fix"
650            );
651        } else {
652            assert_eq!(
653                body, b"{\"extension\":\"inferred\"}",
654                "case-sensitive filesystem: the exact-path stat for \
655                 \"foo\" misses \"FOO\", so extension inference (which \
656                 now runs before the listing) should resolve to \
657                 \"foo.json\" — the disclosed precedence change"
658            );
659        }
660    }
661
662    /// A directory with no matching file at all — every path (exact
663    /// stat, extension inference, listing) misses — still 404s rather
664    /// than erroring.
665    #[tokio::test]
666    async fn no_match_anywhere_is_not_found() {
667        let dir = tempfile::tempdir().unwrap();
668        std::fs::write(dir.path().join("other.json"), "{}").unwrap();
669        let confine_to = canonical_dir(dir.path().to_str().unwrap());
670
671        let response = dyn_route_content(
672            "/does-not-exist",
673            dir.path().to_str().unwrap(),
674            &HeaderMap::new(),
675            confine_to.as_deref(),
676            &[],
677        )
678        .await
679        .unwrap();
680
681        assert_eq!(response.status(), hyper::StatusCode::NOT_FOUND);
682    }
683}