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}