Skip to main content

static_web_server/
static_files.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// This file is part of Static Web Server.
3// See https://static-web-server.net/ for more information
4// Copyright (C) 2019-present Jose Quintana <joseluisq.net>
5
6//! The static file module which powers the web server.
7//!
8
9// Part of the file is borrowed and adapted at a convenience from
10// https://github.com/seanmonstar/warp/blob/master/src/filters/fs.rs
11
12use headers::{AcceptRanges, HeaderMap, HeaderMapExt, HeaderValue};
13use hyper::{Body, Method, Response, StatusCode, header::CONTENT_ENCODING, header::CONTENT_LENGTH};
14use std::cell::RefCell;
15use std::collections::HashSet;
16use std::fs::{File, Metadata};
17use std::path::{Path, PathBuf};
18
19use crate::Result;
20use crate::conditional_headers::ConditionalHeaders;
21use crate::fs::meta::{FileMetadata, try_file_open, try_metadata, try_metadata_with_html_suffix};
22use crate::fs::path::{PathExt, sanitize_path};
23use crate::http_ext::{HTTP_SUPPORTED_METHODS, MethodExt};
24use crate::response::response_body;
25
26#[cfg(feature = "experimental")]
27use crate::mem_cache::{cache, cache::MemCacheOpts};
28
29use crate::compression_static;
30
31#[cfg(feature = "directory-listing")]
32use crate::{
33    directory_listing,
34    directory_listing::{DirListFmt, DirListOpts},
35};
36
37#[cfg(feature = "directory-listing-download")]
38use crate::directory_listing_download::{
39    DOWNLOAD_PARAM_KEY, DirDownloadFmt, DirDownloadOpts, archive_reply,
40};
41
42const DEFAULT_INDEX_FILES: &[&str; 1] = &["index.html"];
43
44/// Maximum number of containment "OK" decisions cached per worker thread.
45/// Sized for typical static-file workloads where the distinct request paths
46/// are small. When the cap is reached the cache is dropped wholesale; the
47/// next requests pay the `canonicalize` syscall again.
48const CONTAINMENT_CACHE_CAP: usize = 1024;
49
50#[cfg(test)]
51thread_local! {
52    /// Runs once after the selected response file has been opened, at the point
53    /// where the old response path would open it again by pathname.
54    static FILE_OPEN_RACE_HOOK: RefCell<Option<Box<dyn FnOnce()>>> = RefCell::new(None);
55
56    /// Runs once after the containment and symlink policies have been enforced
57    /// but *before* the selected response file is opened. Used to simulate an
58    /// attacker retargeting a symlink inside the check-then-open window.
59    static PRE_FILE_OPEN_RACE_HOOK: RefCell<Option<Box<dyn FnOnce()>>> = RefCell::new(None);
60}
61
62thread_local! {
63    /// Per-thread set of `probe` paths that have previously been proven
64    /// to live inside the canonical base directory.
65    ///
66    /// Profiling showed the containment check (and its `Path::canonicalize`
67    /// syscall) was the single largest CPU cost on the static-file fast
68    /// path. A workload that repeatedly serves the same documents reaches
69    /// a steady state with effectively no `canonicalize` syscalls. The
70    /// cache is keyed by `PathBuf` so the lookup is a single hash + byte
71    /// compare.
72    ///
73    /// Cache validity: an entry is added only after the slow path has
74    /// proven the probe is contained within `base_path`. Callers only
75    /// reuse entries while enforcing the no-symlink policy on every request.
76    static CONTAINMENT_CACHE: RefCell<HashSet<PathBuf>> =
77        RefCell::new(HashSet::with_capacity(64));
78}
79
80// Setters and counters are only exercised by the Unix-only test module below.
81// Keep the run hooks and the counter increment on all `cfg(test)` builds so the
82// production paths still compile under `cargo test` on non-Unix hosts.
83#[cfg(all(test, unix))]
84fn set_file_open_race_hook(hook: impl FnOnce() + 'static) {
85    FILE_OPEN_RACE_HOOK.with(|slot| *slot.borrow_mut() = Some(Box::new(hook)));
86}
87
88#[cfg(test)]
89fn run_file_open_race_hook() {
90    FILE_OPEN_RACE_HOOK.with(|slot| {
91        if let Some(hook) = slot.borrow_mut().take() {
92            hook();
93        }
94    });
95}
96
97#[cfg(all(test, unix))]
98fn set_pre_file_open_race_hook(hook: impl FnOnce() + 'static) {
99    PRE_FILE_OPEN_RACE_HOOK.with(|slot| *slot.borrow_mut() = Some(Box::new(hook)));
100}
101
102#[cfg(test)]
103fn run_pre_file_open_race_hook() {
104    PRE_FILE_OPEN_RACE_HOOK.with(|slot| {
105        if let Some(hook) = slot.borrow_mut().take() {
106            hook();
107        }
108    });
109}
110
111#[cfg(test)]
112thread_local! {
113    /// Counts `Path::canonicalize` calls issued by [`enforce_containment`] on
114    /// the current thread. Used by the tests to assert that the positive
115    /// containment cache is bypassed whenever symlinks are followed.
116    static CANONICALIZE_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
117}
118
119#[cfg(all(test, unix))]
120fn reset_canonicalize_calls() {
121    CANONICALIZE_CALLS.with(|counter| counter.set(0));
122}
123
124#[cfg(all(test, unix))]
125fn canonicalize_calls() -> usize {
126    CANONICALIZE_CALLS.with(std::cell::Cell::get)
127}
128
129/// Records `probe` as previously-verified-safe in the per-thread
130/// containment cache. When the cache fills, the entire set is dropped
131/// rather than performing per-entry LRU bookkeeping, since the working
132/// set is expected to fit well within `CONTAINMENT_CACHE_CAP`.
133#[inline]
134fn cache_safe_probe(probe: &Path) {
135    CONTAINMENT_CACHE.with(|c| {
136        let mut set = c.borrow_mut();
137        if set.len() >= CONTAINMENT_CACHE_CAP {
138            set.clear();
139        }
140        set.insert(probe.to_path_buf());
141    });
142}
143
144/// Defines all options needed by the static-files handler.
145pub struct HandleOpts<'a> {
146    /// Request method.
147    pub method: &'a Method,
148    /// In-memory files cache feature (experimental).
149    #[cfg(feature = "experimental")]
150    pub memory_cache: Option<&'a MemCacheOpts>,
151    /// Request headers.
152    pub headers: &'a HeaderMap<HeaderValue>,
153    /// Request base path.
154    pub base_path: &'a PathBuf,
155    /// Request base path.
156    pub uri_path: &'a str,
157    /// Index files.
158    pub index_files: &'a [&'a str],
159    /// Request URI query.
160    pub uri_query: Option<&'a str>,
161    /// Directory listing feature.
162    #[cfg(feature = "directory-listing")]
163    #[cfg_attr(docsrs, doc(cfg(feature = "directory-listing")))]
164    pub dir_listing: bool,
165    /// Directory listing order feature.
166    #[cfg(feature = "directory-listing")]
167    #[cfg_attr(docsrs, doc(cfg(feature = "directory-listing")))]
168    pub dir_listing_order: u8,
169    /// Directory listing format feature.
170    #[cfg(feature = "directory-listing")]
171    #[cfg_attr(docsrs, doc(cfg(feature = "directory-listing")))]
172    pub dir_listing_format: &'a DirListFmt,
173    /// Directory listing download feature.
174    #[cfg(feature = "directory-listing-download")]
175    #[cfg_attr(docsrs, doc(cfg(feature = "directory-listing-download")))]
176    pub dir_listing_download: &'a [DirDownloadFmt],
177    /// Redirect trailing slash feature.
178    pub redirect_trailing_slash: bool,
179    /// Compression static feature.
180    pub compression_static: bool,
181    /// Ignore hidden files feature.
182    pub ignore_hidden_files: bool,
183    /// Prevent following symlinks for files and directories.
184    pub disable_symlinks: bool,
185}
186
187/// The path that is safe to open once the security checks have passed.
188///
189/// Opening the *canonical* path rather than the originally requested one
190/// narrows the check-then-open (TOCTOU) window: a canonical path contains no
191/// symlink components, so a symlink retargeted between the containment check
192/// and the `open(2)` can no longer redirect the read outside the web root.
193enum SafeOpenPath {
194    /// A fully-resolved, symlink-free path that is safe to open directly.
195    Canonical(PathBuf),
196    /// No canonical path is available, so the caller must open the requested
197    /// path. This is returned when either:
198    /// * the containment decision was reused from the per-thread cache, which
199    ///   only happens while the no-symlink policy re-validates every path
200    ///   component on every request, or
201    /// * the checked path is a directory-listing placeholder that is never
202    ///   opened as a file.
203    Requested,
204}
205
206impl SafeOpenPath {
207    /// Returns the path to open, falling back to `requested` when no canonical
208    /// path was resolved.
209    #[inline]
210    fn as_path<'a>(&'a self, requested: &'a Path) -> &'a Path {
211        match self {
212            Self::Canonical(path) => path.as_path(),
213            Self::Requested => requested,
214        }
215    }
216}
217
218/// Verifies that `file_path` is safe to serve under the current `opts` and
219/// returns the path that is safe to open.
220///
221/// `file_path` must be the path that will actually be opened and streamed. For
222/// directory requests that resolved an index file this is the index file
223/// itself, so a symlinked index file is covered by the containment and symlink
224/// checks as well. When a directory request did *not* resolve an index file
225/// (`resolved_exists == false`) the trailing segment is a directory-listing
226/// placeholder that does not exist on disk, so it is stripped before checking
227/// (canonicalizing a non-existent path always fails).
228fn enforce_path_security(
229    file_path: &Path,
230    is_dir: bool,
231    resolved_exists: bool,
232    opts: &HandleOpts<'_>,
233    use_containment_cache: bool,
234) -> Result<SafeOpenPath, StatusCode> {
235    let is_placeholder = is_dir && !resolved_exists;
236    let mut probe = file_path.to_path_buf();
237    if is_placeholder {
238        probe.pop();
239    }
240
241    let relative = probe.strip_prefix(opts.base_path).map_err(|err| {
242        tracing::error!(
243            "unable to strip prefix from file path '{}': {}",
244            file_path.display(),
245            err,
246        );
247        StatusCode::NOT_FOUND
248    })?;
249
250    let contained = enforce_containment(&probe, opts.base_path, use_containment_cache)?;
251
252    if opts.disable_symlinks {
253        enforce_symlink_policy(relative, opts.base_path, file_path)?;
254    }
255
256    // Check for a hidden file/directory (dotfile) and ignore it if feature enabled.
257    // The appended index-file segment of a directory request is server-configured
258    // rather than client-supplied, so it is excluded from this check.
259    let hidden_relative = if is_dir && resolved_exists {
260        relative.parent().unwrap_or(relative)
261    } else {
262        relative
263    };
264    if opts.ignore_hidden_files && hidden_relative.is_hidden() {
265        tracing::trace!(
266            "considering hidden file {} as not found",
267            file_path.display()
268        );
269        return Err(StatusCode::NOT_FOUND);
270    }
271
272    // A placeholder path is only used to build a directory listing, never
273    // opened, and its canonical form points at the parent directory. Never hand
274    // it back as an open target.
275    if is_placeholder {
276        return Ok(SafeOpenPath::Requested);
277    }
278
279    Ok(match contained {
280        Some(resolved) => SafeOpenPath::Canonical(resolved),
281        None => SafeOpenPath::Requested,
282    })
283}
284
285/// Canonicalizes `probe` and verifies that it resolves within `base_path`.
286///
287/// Returns the resolved (symlink-free) path on success, or `None` when the
288/// decision was reused from the per-thread containment cache.
289///
290/// `use_cache` must only be enabled when the caller independently checks every
291/// path component for symlinks on each request. Otherwise a symlink retargeted
292/// after a cached decision could resolve outside `base_path`.
293fn enforce_containment(
294    probe: &Path,
295    base_path: &Path,
296    use_cache: bool,
297) -> Result<Option<PathBuf>, StatusCode> {
298    if use_cache && CONTAINMENT_CACHE.with(|cache| cache.borrow().contains(probe)) {
299        return Ok(None);
300    }
301
302    #[cfg(test)]
303    CANONICALIZE_CALLS.with(|counter| counter.set(counter.get() + 1));
304
305    let resolved = probe.canonicalize().map_err(|err| {
306        tracing::error!(
307            "unable to resolve '{}' symlink path: {}",
308            probe.display(),
309            err,
310        );
311        StatusCode::NOT_FOUND
312    })?;
313
314    // Fast path: when `base_path` is already canonical (the production case),
315    // the resolved file path will share its prefix and we avoid canonicalizing
316    // the base directory again.
317    if resolved.starts_with(base_path) {
318        if use_cache {
319            cache_safe_probe(probe);
320        }
321        return Ok(Some(resolved));
322    }
323
324    // Fallback for callers that provide a non-canonical base path.
325    let base_path = base_path.canonicalize().map_err(|err| {
326        tracing::error!(
327            "unable to resolve '{}' base path: {}",
328            base_path.display(),
329            err,
330        );
331        StatusCode::NOT_FOUND
332    })?;
333
334    if !resolved.starts_with(&base_path) {
335        tracing::error!(
336            "file path '{}' resolves outside of the base path, access denied",
337            resolved.display()
338        );
339        return Err(StatusCode::NOT_FOUND);
340    }
341
342    if use_cache {
343        cache_safe_probe(probe);
344    }
345    Ok(Some(resolved))
346}
347
348/// Rejects `file_path` when it or any relative path component is a symlink.
349fn enforce_symlink_policy(
350    relative: &Path,
351    base_path: &Path,
352    file_path: &Path,
353) -> Result<(), StatusCode> {
354    let has_symlink = relative.contains_symlink(base_path).map_err(|err| {
355        tracing::error!(
356            "unable to check if file path '{}' contains symlink: {}",
357            relative.display(),
358            err,
359        );
360        StatusCode::NOT_FOUND
361    })?;
362
363    if has_symlink {
364        tracing::warn!(
365            "file path '{}' contains a symlink, access denied",
366            file_path.display()
367        );
368        return Err(StatusCode::FORBIDDEN);
369    }
370
371    Ok(())
372}
373
374/// Static file response type with additional data.
375pub struct StaticFileResponse {
376    /// Inner HTTP response.
377    pub resp: Response<Body>,
378    /// The file path of the inner HTTP response.
379    pub file_path: PathBuf,
380}
381
382/// The server entry point to handle incoming requests which map to specific files
383/// on file system and return a file response.
384pub async fn handle(opts: &HandleOpts<'_>) -> Result<StaticFileResponse, StatusCode> {
385    let method = opts.method;
386    // Check if current HTTP method for incoming request is supported
387    if !method.is_allowed() {
388        return Err(StatusCode::METHOD_NOT_ALLOWED);
389    }
390
391    let uri_path = opts.uri_path;
392    let mut file_path = sanitize_path(opts.base_path, uri_path)?;
393
394    let headers_opt = opts.headers;
395
396    // In-memory file cache feature with eviction policy
397    #[cfg(feature = "experimental")]
398    if opts.memory_cache.is_some() {
399        // NOTE: we only support a default auto index for directory requests
400        // when working on a memory-cache context.
401        if opts.redirect_trailing_slash && uri_path.ends_with('/') {
402            file_path.push("index.html");
403        }
404
405        if let Some(result) = cache::get_or_acquire(file_path.as_path(), headers_opt).await {
406            match result {
407                cache::CacheResult::Hit(result) => {
408                    return Ok(StaticFileResponse {
409                        resp: result?,
410                        file_path,
411                    });
412                }
413                cache::CacheResult::Error(status) => {
414                    return Err(status);
415                }
416                cache::CacheResult::Miss(_permit) => {
417                    // Permit is held while we proceed to read the file below.
418                    // It will be dropped at the end of this scope, after the
419                    // MemCacheFileStream inserts the data into the cache store.
420                }
421            }
422        }
423    }
424
425    let FileMetadata {
426        file_path,
427        is_dir,
428        resolved_exists,
429        precompressed_variant,
430    } = get_composed_file_metadata(
431        &mut file_path,
432        headers_opt,
433        opts.compression_static,
434        opts.index_files,
435    )?;
436
437    // The positive containment cache may only be reused when the no-symlink
438    // policy re-validates every path component on every request. When symlinks
439    // are allowed, a symlink retargeted after a cached decision could resolve
440    // outside the web root, so the cache is bypassed and each request pays the
441    // `canonicalize` syscall again. This trades throughput for containment
442    // correctness on the default (symlink-following) configuration.
443    let use_containment_cache = opts.disable_symlinks;
444
445    let safe_path = enforce_path_security(
446        file_path,
447        is_dir,
448        resolved_exists,
449        opts,
450        use_containment_cache,
451    )?;
452
453    // Variant selection changes the file that will be opened, so the selected
454    // pre-compressed path must pass the very same containment and symlink
455    // checks as the originally requested path before it can be served.
456    let safe_precompressed_path = match precompressed_variant.as_ref() {
457        Some((precompressed_path, _)) => Some(enforce_path_security(
458            precompressed_path,
459            false,
460            true,
461            opts,
462            use_containment_cache,
463        )?),
464        None => None,
465    };
466
467    let resp_file_path = file_path.to_owned();
468
469    // Check for a trailing slash on the current directory path
470    // and redirect if that path doesn't end with the slash char
471    if is_dir && opts.redirect_trailing_slash && !uri_path.ends_with('/') {
472        let query = opts.uri_query.map_or(String::new(), |s| ["?", s].concat());
473        let uri = [uri_path, "/", query.as_str()].concat();
474        let loc = match HeaderValue::from_str(uri.as_str()) {
475            Ok(val) => val,
476            Err(err) => {
477                tracing::error!("invalid header value from current uri: {:?}", err);
478                return Err(StatusCode::INTERNAL_SERVER_ERROR);
479            }
480        };
481
482        let mut resp = Response::new(Body::empty());
483        resp.headers_mut().insert(hyper::header::LOCATION, loc);
484        *resp.status_mut() = StatusCode::PERMANENT_REDIRECT;
485
486        tracing::trace!("uri doesn't end with a slash so redirecting permanently");
487        return Ok(StaticFileResponse {
488            resp,
489            file_path: resp_file_path,
490        });
491    }
492
493    // Respond with the permitted communication methods
494    if method.is_options() {
495        let mut resp = Response::new(Body::empty());
496        *resp.status_mut() = StatusCode::NO_CONTENT;
497        resp.headers_mut()
498            .typed_insert(headers::Allow::from_iter(HTTP_SUPPORTED_METHODS.clone()));
499        resp.headers_mut().typed_insert(AcceptRanges::bytes());
500
501        return Ok(StaticFileResponse {
502            resp,
503            file_path: resp_file_path,
504        });
505    }
506
507    // Directory listing
508    // Check if "directory listing" feature is enabled,
509    // if current path is a valid directory and
510    // if it does not contain an `index.html` file (if a proper auto index is generated)
511    #[cfg(feature = "directory-listing")]
512    if is_dir && opts.dir_listing && !resolved_exists {
513        // Directory listing download
514        // Check if "directory listing download" feature is enabled,
515        // if current path is a valid directory and
516        // if query string has parameter "download" set
517        #[cfg(feature = "directory-listing-download")]
518        if !opts.dir_listing_download.is_empty()
519            && let Some((_k, _dl_archive_opt)) =
520                form_urlencoded::parse(opts.uri_query.unwrap_or("").as_bytes())
521                    .find(|(k, _v)| k == DOWNLOAD_PARAM_KEY)
522        {
523            // file path is index.html, need pop
524            let mut fp = file_path.clone();
525            fp.pop();
526            if let Some(filename) = fp.file_name() {
527                let resp = archive_reply(
528                    filename,
529                    &fp,
530                    DirDownloadOpts {
531                        method,
532                        disable_symlinks: opts.disable_symlinks,
533                        ignore_hidden_files: opts.ignore_hidden_files,
534                    },
535                );
536                return Ok(StaticFileResponse {
537                    resp,
538                    file_path: resp_file_path,
539                });
540            } else {
541                tracing::error!("Unable to get filename from {}", fp.to_string_lossy());
542                return Err(StatusCode::INTERNAL_SERVER_ERROR);
543            }
544        }
545
546        let resp = directory_listing::auto_index(DirListOpts {
547            root_path: opts.base_path.as_path(),
548            method,
549            current_path: uri_path,
550            uri_query: opts.uri_query,
551            filepath: file_path,
552            dir_listing_order: opts.dir_listing_order,
553            dir_listing_format: opts.dir_listing_format,
554            ignore_hidden_files: opts.ignore_hidden_files,
555            disable_symlinks: opts.disable_symlinks,
556            #[cfg(feature = "directory-listing-download")]
557            dir_listing_download: opts.dir_listing_download,
558        })?;
559
560        return Ok(StaticFileResponse {
561            resp,
562            file_path: resp_file_path,
563        });
564    }
565
566    // Check for a pre-compressed file variant if present under the `opts.compression_static` context
567    if let Some((precomp_path, precomp_encoding)) = precompressed_variant {
568        // Open the canonical (symlink-free) path resolved by the containment
569        // check so a symlink retargeted between the check and this `open(2)`
570        // cannot redirect the read outside the web root.
571        let open_path = safe_precompressed_path
572            .as_ref()
573            .map_or(precomp_path.as_path(), |safe| safe.as_path(&precomp_path));
574
575        #[cfg(test)]
576        run_pre_file_open_race_hook();
577
578        let (file, precomp_meta) = try_file_open(open_path)?;
579
580        #[cfg(test)]
581        run_file_open_race_hook();
582
583        let mut resp = file_reply(
584            headers_opt,
585            file_path,
586            &precomp_meta,
587            file,
588            // Never insert a pre-compressed body into the in-memory cache: the
589            // cache is keyed by the *original* path and its lookup runs before
590            // content negotiation, so a cached variant body would be served
591            // (undecodable) to clients that did not accept that encoding.
592            #[cfg(feature = "experimental")]
593            None,
594        )?;
595
596        // Prepare corresponding headers to let know how to decode the payload
597        resp.headers_mut().remove(CONTENT_LENGTH);
598        let encoding = match HeaderValue::from_str(precomp_encoding.as_str()) {
599            Ok(val) => val,
600            Err(err) => {
601                tracing::error!(
602                    "unable to parse header value from content encoding: {:?}",
603                    err
604                );
605                return Err(StatusCode::INTERNAL_SERVER_ERROR);
606            }
607        };
608        resp.headers_mut().insert(CONTENT_ENCODING, encoding);
609
610        return Ok(StaticFileResponse {
611            resp,
612            file_path: resp_file_path,
613        });
614    }
615
616    // Same rationale as the pre-compressed branch: open the canonical path
617    // proven contained by the security checks above.
618    #[cfg(test)]
619    run_pre_file_open_race_hook();
620
621    let (file, serve_meta) = try_file_open(safe_path.as_path(file_path))?;
622
623    #[cfg(test)]
624    run_file_open_race_hook();
625
626    #[cfg(feature = "experimental")]
627    let resp = file_reply(headers_opt, file_path, &serve_meta, file, opts.memory_cache)?;
628
629    #[cfg(not(feature = "experimental"))]
630    let resp = file_reply(headers_opt, file_path, &serve_meta, file)?;
631
632    Ok(StaticFileResponse {
633        resp,
634        file_path: resp_file_path,
635    })
636}
637
638/// Returns the final composed metadata containing
639/// the current `file_path` with its file metadata
640/// as well as its optional pre-compressed variant.
641///
642/// This resolver only *probes* the file system to determine which path should
643/// be served. It deliberately never hands an open file handle to the caller:
644/// the file that is streamed must be opened only after the containment and
645/// symlink checks have run, and from the canonical path they resolved. See
646/// [`enforce_path_security`].
647fn get_composed_file_metadata<'a>(
648    mut file_path: &'a mut PathBuf,
649    headers: &'a HeaderMap<HeaderValue>,
650    compression_static: bool,
651    mut index_files: &'a [&'a str],
652) -> Result<FileMetadata<'a>, StatusCode> {
653    tracing::trace!("getting metadata for file {}", file_path.display());
654
655    // Try to find the file path on the file system
656    match try_metadata(file_path) {
657        Ok((_, is_dir)) => {
658            // Whether the resolved `file_path` points to an existing file.
659            // For non-directory requests this is always true.
660            // For directory requests it becomes true only when an index file (or its
661            // `.html` suffix sibling) was successfully resolved. Used to
662            // gate the pre-compressed variant probe so we never issue
663            // `stat(2)` for `.br`/`.gz`/`.zst` siblings of a non-existent
664            // index (see issue #617).
665            let mut resolved_exists = !is_dir;
666            if is_dir {
667                // Try every index file variant in order
668                if index_files.is_empty() {
669                    index_files = DEFAULT_INDEX_FILES;
670                }
671                for index in index_files {
672                    // Append a HTML index page by default if it's a directory path (`autoindex`)
673                    tracing::debug!("dir: appending {} to the directory path", index);
674                    file_path.push(index);
675
676                    if matches!(try_metadata(file_path), Ok((_, false))) {
677                        resolved_exists = true;
678                        break;
679                    }
680
681                    // We remove only the appended index file
682                    file_path.pop();
683                    let new_meta: Option<Metadata>;
684                    (file_path, new_meta) = try_metadata_with_html_suffix(file_path);
685                    if new_meta.is_some() {
686                        resolved_exists = true;
687                        break;
688                    }
689                }
690
691                // In case no index was found then we append the last index
692                // of the list to preserve the previous behavior
693                if !resolved_exists && !index_files.is_empty() {
694                    file_path.push(index_files.last().unwrap());
695                }
696            }
697
698            // Only probe for pre-compressed siblings when the resolved file
699            // actually exists. Probing for `.br`/`.gz`/`.zst` of a path that
700            // was never confirmed on disk wastes one `stat(2)` per
701            // configured encoding on the request hot path
702            // (see issue #617).
703            let precompressed_variant = (compression_static && resolved_exists)
704                .then(|| compression_static::precompressed_variant(file_path, headers))
705                .flatten()
706                .map(|p| (p.file_path, p.encoding));
707
708            Ok(FileMetadata {
709                file_path,
710                is_dir,
711                resolved_exists,
712                precompressed_variant,
713            })
714        }
715        Err(err) => {
716            // If the file path doesn't exist, then try the `.html`-suffixed path
717            // first. For example: `/posts/article` falls back to
718            // `/posts/article.html`.
719            //
720            // We intentionally do *not* probe for pre-compressed siblings
721            // of the original (non-existent) path. Doing so would waste
722            // one `stat(2)` per configured encoding for every truly
723            // missing path (see issue #617).
724            let new_meta: Option<Metadata>;
725            (file_path, new_meta) = try_metadata_with_html_suffix(file_path);
726
727            if new_meta.is_none() {
728                // Neither the original path nor its `.html` sibling exists.
729                // Return the original error without probing for compressed
730                // variants of non-existent files.
731                return Err(err);
732            }
733
734            // The `.html` sibling exists. Only now is it worth probing for
735            // its pre-compressed sibling (`/article.html.br`, etc.).
736            let precompressed_variant = compression_static
737                .then(|| compression_static::precompressed_variant(file_path, headers))
738                .flatten()
739                .map(|p| (p.file_path, p.encoding));
740
741            Ok(FileMetadata {
742                file_path,
743                is_dir: false,
744                resolved_exists: true,
745                precompressed_variant,
746            })
747        }
748    }
749}
750
751/// Builds a response from an already-opened and validated file handle.
752fn file_reply<'a>(
753    headers: &'a HeaderMap<HeaderValue>,
754    path: &'a Path,
755    meta: &'a Metadata,
756    file: File,
757    #[cfg(feature = "experimental")] memory_cache: Option<&'a MemCacheOpts>,
758) -> Result<Response<Body>, StatusCode> {
759    let conditionals = ConditionalHeaders::new(headers);
760
761    #[cfg(feature = "experimental")]
762    let resp = response_body(file, path, meta, conditionals, memory_cache);
763
764    #[cfg(not(feature = "experimental"))]
765    let resp = response_body(file, path, meta, conditionals);
766
767    resp
768}
769
770#[cfg(all(test, unix))]
771mod tests {
772    use super::{
773        HandleOpts, StaticFileResponse, canonicalize_calls, handle, reset_canonicalize_calls,
774        set_file_open_race_hook, set_pre_file_open_race_hook,
775    };
776    use headers::HeaderMap;
777    use hyper::{Method, StatusCode};
778    use std::fs;
779    use std::os::unix::fs::symlink;
780    use std::path::{Path, PathBuf};
781    use std::sync::atomic::{AtomicU64, Ordering};
782    use std::time::{SystemTime, UNIX_EPOCH};
783
784    struct TempDir {
785        path: PathBuf,
786    }
787
788    impl TempDir {
789        fn new(tag: &str) -> Self {
790            static COUNTER: AtomicU64 = AtomicU64::new(0);
791            let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
792            let nanos = SystemTime::now()
793                .duration_since(UNIX_EPOCH)
794                .map(|duration| duration.as_nanos())
795                .unwrap_or(0);
796            let path = std::env::temp_dir().join(format!(
797                "sws-static-files-{tag}-{}-{nanos}-{seq}",
798                std::process::id(),
799            ));
800            fs::create_dir_all(&path).expect("unexpected error creating temporary directory");
801            Self { path }
802        }
803
804        fn path(&self) -> &Path {
805            &self.path
806        }
807    }
808
809    impl Drop for TempDir {
810        fn drop(&mut self) {
811            let _ = fs::remove_dir_all(&self.path);
812        }
813    }
814
815    /// Creates a temporary web root containing an `app.js` source file and
816    /// returns the temporary directory guard alongside the web root path.
817    fn web_root(tag: &str) -> (TempDir, PathBuf) {
818        let temp_dir = TempDir::new(tag);
819        let root_dir = temp_dir.path().join("public");
820        fs::create_dir(&root_dir).expect("unexpected error creating web root");
821        (temp_dir, root_dir)
822    }
823
824    struct RequestOpts<'a> {
825        uri_path: &'a str,
826        encoding: &'a str,
827        disable_symlinks: bool,
828        ignore_hidden_files: bool,
829        index_files: &'a [&'a str],
830    }
831
832    impl Default for RequestOpts<'_> {
833        fn default() -> Self {
834            Self {
835                uri_path: "/app.js",
836                encoding: "identity",
837                disable_symlinks: false,
838                ignore_hidden_files: false,
839                index_files: &[],
840            }
841        }
842    }
843
844    async fn request_with(
845        root_dir: &PathBuf,
846        opts: RequestOpts<'_>,
847    ) -> Result<StaticFileResponse, StatusCode> {
848        let mut headers = HeaderMap::new();
849        headers.insert(
850            http::header::ACCEPT_ENCODING,
851            opts.encoding.parse().expect("unexpected invalid encoding"),
852        );
853
854        handle(&HandleOpts {
855            method: &Method::GET,
856            headers: &headers,
857            base_path: root_dir,
858            uri_path: opts.uri_path,
859            uri_query: None,
860            #[cfg(feature = "experimental")]
861            memory_cache: None,
862            #[cfg(feature = "directory-listing")]
863            dir_listing: false,
864            #[cfg(feature = "directory-listing")]
865            dir_listing_order: 6,
866            #[cfg(feature = "directory-listing")]
867            dir_listing_format: &crate::directory_listing::DirListFmt::Html,
868            #[cfg(feature = "directory-listing-download")]
869            dir_listing_download: &[],
870            redirect_trailing_slash: true,
871            compression_static: true,
872            ignore_hidden_files: opts.ignore_hidden_files,
873            disable_symlinks: opts.disable_symlinks,
874            index_files: opts.index_files,
875        })
876        .await
877    }
878
879    async fn request(
880        root_dir: &PathBuf,
881        encoding: &str,
882        disable_symlinks: bool,
883    ) -> Result<StaticFileResponse, StatusCode> {
884        request_with(
885            root_dir,
886            RequestOpts {
887                encoding,
888                disable_symlinks,
889                ..Default::default()
890            },
891        )
892        .await
893    }
894
895    async fn body_of(result: StaticFileResponse) -> Vec<u8> {
896        hyper::body::to_bytes(result.resp.into_body())
897            .await
898            .expect("unexpected bytes error during body conversion")
899            .to_vec()
900    }
901
902    #[tokio::test]
903    async fn opened_precompressed_file_retargeted_before_response_uses_opened_handle() {
904        for (extension, encoding) in [("gz", "gzip"), ("br", "br"), ("zst", "zstd")] {
905            for disable_symlinks in [false, true] {
906                let (temp_dir, root_dir) = web_root("open-race");
907                fs::write(root_dir.join("app.js"), "source")
908                    .expect("unexpected error writing source file");
909                let inside_marker = root_dir.join("inside-marker");
910                fs::write(&inside_marker, "inside")
911                    .expect("unexpected error writing inside marker");
912                let outside_marker = temp_dir.path().join("outside-marker");
913                fs::write(&outside_marker, "beyond")
914                    .expect("unexpected error writing outside marker");
915                let precompressed_path = root_dir.join(format!("app.js.{extension}"));
916
917                if disable_symlinks {
918                    fs::write(&precompressed_path, "inside")
919                        .expect("unexpected error writing pre-compressed file");
920                } else {
921                    symlink(&inside_marker, &precompressed_path)
922                        .expect("unexpected error creating contained pre-compressed symlink");
923                }
924
925                let race_path = precompressed_path.clone();
926                set_file_open_race_hook(move || {
927                    fs::remove_file(&race_path)
928                        .expect("unexpected error removing pre-compressed path");
929                    symlink(&outside_marker, &race_path)
930                        .expect("unexpected error retargeting pre-compressed path");
931                });
932
933                let result = request(&root_dir, encoding, disable_symlinks)
934                    .await
935                    .expect("expected pre-compressed response");
936
937                assert_eq!(
938                    body_of(result).await,
939                    b"inside",
940                    "served retargeted {encoding} path with disable_symlinks={disable_symlinks}"
941                );
942            }
943        }
944    }
945
946    #[tokio::test]
947    async fn precompressed_file_retargeted_within_check_open_window_is_not_served() {
948        for (extension, encoding) in [("gz", "gzip"), ("br", "br"), ("zst", "zstd")] {
949            let (temp_dir, root_dir) = web_root("precomp-check-open-race");
950            fs::write(root_dir.join("app.js"), "source")
951                .expect("unexpected error writing source file");
952            let inside_marker = root_dir.join("inside-marker");
953            fs::write(&inside_marker, "inside").expect("unexpected error writing inside marker");
954            let outside_marker = temp_dir.path().join("outside-marker");
955            fs::write(&outside_marker, "beyond").expect("unexpected error writing outside marker");
956
957            let precompressed_path = root_dir.join(format!("app.js.{extension}"));
958            symlink(&inside_marker, &precompressed_path)
959                .expect("unexpected error creating contained pre-compressed symlink");
960
961            // Retarget the symlink outside the web root *after* the containment
962            // check has passed but *before* the file is opened.
963            let race_path = precompressed_path.clone();
964            set_pre_file_open_race_hook(move || {
965                fs::remove_file(&race_path).expect("unexpected error removing pre-compressed path");
966                symlink(&outside_marker, &race_path)
967                    .expect("unexpected error retargeting pre-compressed path");
968            });
969
970            let result = request(&root_dir, encoding, false)
971                .await
972                .expect("expected pre-compressed response");
973
974            assert_eq!(
975                body_of(result).await,
976                b"inside",
977                "served {encoding} variant retargeted inside the check-then-open window"
978            );
979        }
980    }
981
982    #[tokio::test]
983    async fn requested_file_retargeted_within_check_open_window_is_not_served() {
984        let (temp_dir, root_dir) = web_root("requested-check-open-race");
985        let inside_marker = root_dir.join("inside-marker");
986        fs::write(&inside_marker, "inside").expect("unexpected error writing inside marker");
987        let outside_marker = temp_dir.path().join("outside-marker");
988        fs::write(&outside_marker, "beyond").expect("unexpected error writing outside marker");
989
990        let requested_path = root_dir.join("app.js");
991        symlink(&inside_marker, &requested_path)
992            .expect("unexpected error creating contained requested symlink");
993
994        let race_path = requested_path.clone();
995        set_pre_file_open_race_hook(move || {
996            fs::remove_file(&race_path).expect("unexpected error removing requested path");
997            symlink(&outside_marker, &race_path)
998                .expect("unexpected error retargeting requested path");
999        });
1000
1001        let result = request(&root_dir, "identity", false)
1002            .await
1003            .expect("expected requested-file response");
1004
1005        assert_eq!(
1006            body_of(result).await,
1007            b"inside",
1008            "served requested file retargeted inside the check-then-open window"
1009        );
1010    }
1011
1012    #[tokio::test]
1013    async fn index_file_symlink_escaping_root_is_rejected() {
1014        let (temp_dir, root_dir) = web_root("index-symlink-outside");
1015        let outside_marker = temp_dir.path().join("outside-marker");
1016        fs::write(&outside_marker, "beyond").expect("unexpected error writing outside marker");
1017        symlink(&outside_marker, root_dir.join("index.html"))
1018            .expect("unexpected error creating escaping index symlink");
1019
1020        match request_with(
1021            &root_dir,
1022            RequestOpts {
1023                uri_path: "/",
1024                ..Default::default()
1025            },
1026        )
1027        .await
1028        {
1029            Ok(result) => panic!(
1030                "expected escaping index symlink to be rejected, got {}",
1031                result.resp.status()
1032            ),
1033            Err(status) => assert_eq!(status, StatusCode::NOT_FOUND),
1034        }
1035    }
1036
1037    #[tokio::test]
1038    async fn index_file_symlink_is_rejected_when_symlinks_are_disabled() {
1039        let (_temp_dir, root_dir) = web_root("index-symlink-disabled");
1040        let inside_marker = root_dir.join("inside-marker");
1041        fs::write(&inside_marker, "inside").expect("unexpected error writing inside marker");
1042        symlink(&inside_marker, root_dir.join("index.html"))
1043            .expect("unexpected error creating contained index symlink");
1044
1045        match request_with(
1046            &root_dir,
1047            RequestOpts {
1048                uri_path: "/",
1049                disable_symlinks: true,
1050                ..Default::default()
1051            },
1052        )
1053        .await
1054        {
1055            Ok(result) => panic!(
1056                "expected index symlink to be rejected, got {}",
1057                result.resp.status()
1058            ),
1059            Err(status) => assert_eq!(status, StatusCode::FORBIDDEN),
1060        }
1061    }
1062
1063    #[tokio::test]
1064    async fn index_file_symlink_inside_root_is_served_when_symlinks_are_enabled() {
1065        let (_temp_dir, root_dir) = web_root("index-symlink-allowed");
1066        let inside_marker = root_dir.join("inside-marker");
1067        fs::write(&inside_marker, "inside").expect("unexpected error writing inside marker");
1068        symlink(&inside_marker, root_dir.join("index.html"))
1069            .expect("unexpected error creating contained index symlink");
1070
1071        let result = request_with(
1072            &root_dir,
1073            RequestOpts {
1074                uri_path: "/",
1075                ..Default::default()
1076            },
1077        )
1078        .await
1079        .expect("expected contained index symlink to be served");
1080
1081        assert_eq!(body_of(result).await, b"inside");
1082    }
1083
1084    #[tokio::test]
1085    async fn precompressed_index_variant_escaping_root_is_rejected() {
1086        for (extension, encoding) in [("gz", "gzip"), ("br", "br"), ("zst", "zstd")] {
1087            let (temp_dir, root_dir) = web_root("index-precomp-outside");
1088            fs::write(root_dir.join("index.html"), "source")
1089                .expect("unexpected error writing index file");
1090            let outside_marker = temp_dir.path().join("outside-marker");
1091            fs::write(&outside_marker, "beyond").expect("unexpected error writing outside marker");
1092            symlink(
1093                &outside_marker,
1094                root_dir.join(format!("index.html.{extension}")),
1095            )
1096            .expect("unexpected error creating escaping variant symlink");
1097
1098            match request_with(
1099                &root_dir,
1100                RequestOpts {
1101                    uri_path: "/",
1102                    encoding,
1103                    ..Default::default()
1104                },
1105            )
1106            .await
1107            {
1108                Ok(result) => panic!(
1109                    "expected escaping {encoding} index variant to be rejected, got {}",
1110                    result.resp.status()
1111                ),
1112                Err(status) => assert_eq!(status, StatusCode::NOT_FOUND),
1113            }
1114        }
1115    }
1116
1117    #[tokio::test]
1118    async fn index_file_in_hidden_directory_is_served_when_hidden_files_are_ignored() {
1119        let (_temp_dir, root_dir) = web_root("hidden-index");
1120        fs::write(root_dir.join(".hidden-index.html"), "hidden-index")
1121            .expect("unexpected error writing hidden index file");
1122
1123        // The appended index file is server-configured, not client-supplied, so
1124        // it must not be filtered out by the hidden-files policy.
1125        let result = request_with(
1126            &root_dir,
1127            RequestOpts {
1128                uri_path: "/",
1129                ignore_hidden_files: true,
1130                index_files: &[".hidden-index.html"],
1131                ..Default::default()
1132            },
1133        )
1134        .await
1135        .expect("expected configured hidden index file to be served");
1136
1137        assert_eq!(body_of(result).await, b"hidden-index");
1138    }
1139
1140    #[tokio::test]
1141    async fn hidden_directory_request_is_still_rejected_when_hidden_files_are_ignored() {
1142        let (_temp_dir, root_dir) = web_root("hidden-dir");
1143        let hidden_dir = root_dir.join(".hidden");
1144        fs::create_dir(&hidden_dir).expect("unexpected error creating hidden directory");
1145        fs::write(hidden_dir.join("index.html"), "hidden-dir-index")
1146            .expect("unexpected error writing hidden directory index");
1147
1148        match request_with(
1149            &root_dir,
1150            RequestOpts {
1151                uri_path: "/.hidden/",
1152                ignore_hidden_files: true,
1153                ..Default::default()
1154            },
1155        )
1156        .await
1157        {
1158            Ok(result) => panic!(
1159                "expected hidden directory request to be rejected, got {}",
1160                result.resp.status()
1161            ),
1162            Err(status) => assert_eq!(status, StatusCode::NOT_FOUND),
1163        }
1164    }
1165
1166    #[cfg(feature = "experimental")]
1167    #[tokio::test]
1168    async fn precompressed_variant_is_not_inserted_into_memory_cache() {
1169        use crate::mem_cache::cache::{CACHE_STORE, MemCacheOpts};
1170        use compact_str::CompactString;
1171
1172        let (_temp_dir, root_dir) = web_root("precomp-mem-cache");
1173        fs::write(root_dir.join("app.js"), "source").expect("unexpected error writing source file");
1174        let precompressed_body = b"pre-compressed-bytes";
1175        fs::write(root_dir.join("app.js.gz"), precompressed_body)
1176            .expect("unexpected error writing pre-compressed file");
1177
1178        // `CACHE_STORE` is process-global and may already be initialized by
1179        // another test; either way we only need a live store to assert against.
1180        let _ = CACHE_STORE.set(
1181            mini_moka::sync::Cache::builder()
1182                .max_capacity(8)
1183                .time_to_live(std::time::Duration::from_secs(60))
1184                .build(),
1185        );
1186        let store = CACHE_STORE
1187            .get()
1188            .expect("expected an in-memory cache store");
1189
1190        let memory_cache = MemCacheOpts::new(8);
1191        let mut headers = HeaderMap::new();
1192        headers.insert(
1193            http::header::ACCEPT_ENCODING,
1194            "gzip".parse().expect("unexpected invalid encoding"),
1195        );
1196
1197        let result = handle(&HandleOpts {
1198            method: &Method::GET,
1199            headers: &headers,
1200            base_path: &root_dir,
1201            uri_path: "/app.js",
1202            uri_query: None,
1203            memory_cache: Some(&memory_cache),
1204            #[cfg(feature = "directory-listing")]
1205            dir_listing: false,
1206            #[cfg(feature = "directory-listing")]
1207            dir_listing_order: 6,
1208            #[cfg(feature = "directory-listing")]
1209            dir_listing_format: &crate::directory_listing::DirListFmt::Html,
1210            #[cfg(feature = "directory-listing-download")]
1211            dir_listing_download: &[],
1212            redirect_trailing_slash: true,
1213            compression_static: true,
1214            ignore_hidden_files: false,
1215            disable_symlinks: false,
1216            index_files: &[],
1217        })
1218        .await
1219        .expect("expected pre-compressed response");
1220
1221        assert_eq!(body_of(result).await, precompressed_body);
1222
1223        // The cache is keyed by the *original* path and its lookup runs before
1224        // content negotiation, so a cached pre-compressed body would be served
1225        // to clients that never accepted that encoding.
1226        let key = CompactString::from(
1227            root_dir
1228                .join("app.js")
1229                .to_str()
1230                .expect("unexpected non-UTF-8 web root"),
1231        );
1232        assert!(
1233            store.get(&key).is_none(),
1234            "pre-compressed body was inserted into the in-memory cache store"
1235        );
1236    }
1237
1238    #[tokio::test]
1239    async fn containment_cache_is_bypassed_when_symlinks_are_followed() {
1240        let (_temp_dir, root_dir) = web_root("containment-cache-bypassed");
1241        fs::write(root_dir.join("app.js"), "source").expect("unexpected error writing source file");
1242
1243        // With symlinks followed, every request must re-canonicalize so a
1244        // symlink retargeted after an earlier "contained" decision is caught.
1245        reset_canonicalize_calls();
1246        for _ in 0..3 {
1247            request(&root_dir, "identity", false)
1248                .await
1249                .expect("expected a successful response");
1250        }
1251        assert_eq!(
1252            canonicalize_calls(),
1253            3,
1254            "containment decisions were reused while symlinks are followed"
1255        );
1256    }
1257
1258    #[tokio::test]
1259    async fn containment_cache_is_reused_when_symlinks_are_disabled() {
1260        let (_temp_dir, root_dir) = web_root("containment-cache-reused");
1261        fs::write(root_dir.join("app.js"), "source").expect("unexpected error writing source file");
1262
1263        // With symlinks disabled, `enforce_symlink_policy` re-validates every
1264        // path component on every request, so reusing a positive containment
1265        // decision cannot mask a retargeted symlink.
1266        reset_canonicalize_calls();
1267        for _ in 0..3 {
1268            request(&root_dir, "identity", true)
1269                .await
1270                .expect("expected a successful response");
1271        }
1272        assert_eq!(
1273            canonicalize_calls(),
1274            1,
1275            "containment decisions were not reused while symlinks are disabled"
1276        );
1277    }
1278}