Skip to main content

rama_http/service/fs/serve_dir/
mod.rs

1use crate::headers::encoding::{SupportedEncodings, parse_accept_encoding_headers};
2use crate::layer::set_status::SetStatus;
3use crate::mime::Mime;
4use crate::service::web::response::IntoResponse;
5use crate::{Body, Method, Request, Response, StatusCode, StreamingBody, header};
6use rama_core::Service;
7use rama_core::bytes::Bytes;
8use rama_core::error::BoxError;
9use rama_core::error::BoxErrorExt as _;
10use rama_core::telemetry::tracing;
11use rama_net::uri::util::percent_encoding::percent_decode;
12use rama_utils::include_dir::Dir;
13use std::fmt;
14use std::str::FromStr;
15use std::{
16    convert::Infallible,
17    path::{Path, PathBuf},
18};
19
20pub(crate) mod future;
21mod headers;
22mod open_file;
23
24#[cfg(test)]
25mod tests;
26
27/// Source of directory content - either filesystem or embedded.
28#[derive(Clone, Debug)]
29enum DirSource {
30    Filesystem(PathBuf),
31    Embedded(Dir<'static>),
32}
33
34// default capacity 64KiB
35const DEFAULT_CAPACITY: usize = 65536;
36
37/// Service that serves files from a given directory and all its sub directories.
38///
39/// The `Content-Type` will be guessed from the file extension.
40///
41/// An empty response with status `404 Not Found` will be returned if:
42///
43/// - The file doesn't exist
44/// - Any segment of the path contains `..`
45/// - Any segment of the path contains a backslash
46/// - On unix, any segment of the path referenced as directory is actually an
47///   existing file (`/file.html/something`)
48/// - We don't have necessary permissions to read the file
49#[derive(Clone, Debug)]
50pub struct ServeDir<F = DefaultServeDirFallback> {
51    base: DirSource,
52    buf_chunk_size: usize,
53    symlink_policy: ServeDirSymlinkPolicy,
54    precompressed_variants: Option<PrecompressedVariants>,
55    // This is used to specialise implementation for
56    // single files
57    variant: ServeVariant,
58    fallback: Option<F>,
59    call_fallback_on_method_not_allowed: bool,
60}
61
62impl ServeDir<DefaultServeDirFallback> {
63    /// Create a new [`ServeDir`].
64    pub fn new<P>(path: P) -> Self
65    where
66        P: AsRef<Path>,
67    {
68        let mut base = PathBuf::from(".");
69        base.push(path.as_ref());
70
71        Self::new_with_base(DirSource::Filesystem(base))
72    }
73
74    /// Create a new [`ServeDir`] that serves files from embedded content.
75    #[must_use]
76    pub fn new_embedded(path: Dir<'static>) -> Self {
77        Self::new_with_base(DirSource::Embedded(path))
78    }
79
80    /// Create a new [`ServeDir`] with the specified directory source and default settings.
81    fn new_with_base(base: DirSource) -> Self {
82        Self {
83            base,
84            buf_chunk_size: DEFAULT_CAPACITY,
85            symlink_policy: ServeDirSymlinkPolicy::default(),
86            precompressed_variants: None,
87            variant: ServeVariant::Directory {
88                serve_mode: Default::default(),
89                html_as_default_extension: false,
90            },
91            fallback: None,
92            call_fallback_on_method_not_allowed: false,
93        }
94    }
95
96    /// Create a new [`ServeDir`] configured to serve a single file.
97    pub(crate) fn new_single_file<P>(path: P, mime: Mime) -> Self
98    where
99        P: AsRef<Path>,
100    {
101        Self {
102            base: DirSource::Filesystem(path.as_ref().to_path_buf()),
103            buf_chunk_size: DEFAULT_CAPACITY,
104            symlink_policy: ServeDirSymlinkPolicy::default(),
105            precompressed_variants: None,
106            variant: ServeVariant::SingleFile { mime },
107            fallback: None,
108            call_fallback_on_method_not_allowed: false,
109        }
110    }
111}
112
113impl<F> ServeDir<F> {
114    rama_utils::macros::generate_set_and_with! {
115        /// Set the [`DirectoryServeMode`].
116        pub fn directory_serve_mode(mut self, mode: DirectoryServeMode) -> Self {
117            match &mut self.variant {
118                ServeVariant::Directory { serve_mode, .. } => {
119                    *serve_mode = mode;
120                    self
121                }
122                ServeVariant::SingleFile { mime: _ } => self,
123            }
124        }
125    }
126
127    rama_utils::macros::generate_set_and_with! {
128        /// If the requested path doesn't specify a file extension, append `.html`
129        /// to it before trying to open it. Useful for serving static sites that
130        /// link to bare filenames (e.g. `/about` → `/about.html`).
131        ///
132        /// Has no effect for [`ServeFile`](super::ServeFile) (single-file mode).
133        ///
134        /// Defaults to `false`.
135        pub fn html_as_default_extension(mut self, html_as_default_extension: bool) -> Self {
136            match &mut self.variant {
137                ServeVariant::Directory { html_as_default_extension: dst, .. } => {
138                    *dst = html_as_default_extension;
139                    self
140                }
141                ServeVariant::SingleFile { mime: _ } => self,
142            }
143        }
144    }
145
146    rama_utils::macros::generate_set_and_with! {
147        /// Set a specific read buffer chunk size.
148        ///
149        /// The default capacity is 64kb.
150        pub fn buf_chunk_size(mut self, chunk_size: usize) -> Self {
151            self.buf_chunk_size = chunk_size;
152            self
153        }
154    }
155
156    rama_utils::macros::generate_set_and_with! {
157        /// Set the filesystem symlink policy.
158        ///
159        /// Defaults to [`ServeDirSymlinkPolicy::RejectAll`].
160        /// This only applies to filesystem-backed services; embedded services
161        /// do not resolve filesystem symlinks.
162        pub fn symlink_policy(mut self, policy: ServeDirSymlinkPolicy) -> Self {
163            self.symlink_policy = policy;
164            self
165        }
166    }
167
168    rama_utils::macros::generate_set_and_with! {
169        /// Informs the service that it should also look for a precompressed gzip
170        /// version of _any_ file in the directory.
171        ///
172        /// Assuming the `dir` directory is being served and `dir/foo.txt` is requested,
173        /// a client with an `Accept-Encoding` header that allows the gzip encoding
174        /// will receive the file `dir/foo.txt.gz` instead of `dir/foo.txt`.
175        /// If the precompressed file is not available, or the client doesn't support it,
176        /// the uncompressed version will be served instead.
177        /// Both the precompressed version and the uncompressed version are expected
178        /// to be present in the directory. Different precompressed variants can be combined.
179        pub fn precompressed_gzip(mut self) -> Self {
180            self.precompressed_variants
181                .get_or_insert(Default::default())
182                .gzip = true;
183            self
184        }
185    }
186
187    rama_utils::macros::generate_set_and_with! {
188        /// Informs the service that it should also look for a precompressed brotli
189        /// version of _any_ file in the directory.
190        ///
191        /// Assuming the `dir` directory is being served and `dir/foo.txt` is requested,
192        /// a client with an `Accept-Encoding` header that allows the brotli encoding
193        /// will receive the file `dir/foo.txt.br` instead of `dir/foo.txt`.
194        /// If the precompressed file is not available, or the client doesn't support it,
195        /// the uncompressed version will be served instead.
196        /// Both the precompressed version and the uncompressed version are expected
197        /// to be present in the directory. Different precompressed variants can be combined.
198        pub fn precompressed_br(mut self) -> Self {
199            self.precompressed_variants
200                .get_or_insert_default()
201                .br = true;
202            self
203        }
204    }
205
206    rama_utils::macros::generate_set_and_with! {
207        /// Informs the service that it should also look for a precompressed deflate
208        /// version of _any_ file in the directory.
209        ///
210        /// Assuming the `dir` directory is being served and `dir/foo.txt` is requested,
211        /// a client with an `Accept-Encoding` header that allows the deflate encoding
212        /// will receive the file `dir/foo.txt.zz` instead of `dir/foo.txt`.
213        /// If the precompressed file is not available, or the client doesn't support it,
214        /// the uncompressed version will be served instead.
215        /// Both the precompressed version and the uncompressed version are expected
216        /// to be present in the directory. Different precompressed variants can be combined.
217        pub fn precompressed_deflate(mut self) -> Self {
218            self.precompressed_variants
219                .get_or_insert_default()
220                .deflate = true;
221            self
222        }
223    }
224
225    rama_utils::macros::generate_set_and_with! {
226        /// Informs the service that it should also look for a precompressed zstd
227        /// version of _any_ file in the directory.
228        ///
229        /// Assuming the `dir` directory is being served and `dir/foo.txt` is requested,
230        /// a client with an `Accept-Encoding` header that allows the zstd encoding
231        /// will receive the file `dir/foo.txt.zst` instead of `dir/foo.txt`.
232        /// If the precompressed file is not available, or the client doesn't support it,
233        /// the uncompressed version will be served instead.
234        /// Both the precompressed version and the uncompressed version are expected
235        /// to be present in the directory. Different precompressed variants can be combined.
236        pub fn precompressed_zstd(mut self) -> Self {
237            self.precompressed_variants
238                .get_or_insert_default()
239                .zstd = true;
240            self
241        }
242    }
243
244    /// Set the fallback service.
245    ///
246    /// This service will be called if there is no file at the path of the request.
247    ///
248    /// The status code returned by the fallback will not be altered. Use
249    /// [`ServeDir::not_found_service`] to set a fallback and always respond with `404 Not Found`.
250    pub fn fallback<F2>(self, new_fallback: F2) -> ServeDir<F2> {
251        ServeDir {
252            base: self.base,
253            buf_chunk_size: self.buf_chunk_size,
254            symlink_policy: self.symlink_policy,
255            precompressed_variants: self.precompressed_variants,
256            variant: self.variant,
257            fallback: Some(new_fallback),
258            call_fallback_on_method_not_allowed: self.call_fallback_on_method_not_allowed,
259        }
260    }
261
262    /// Set the fallback service and override the fallback's status code to `404 Not Found`.
263    ///
264    /// This service will be called if there is no file at the path of the request.
265    #[must_use]
266    pub fn not_found_service<F2>(self, new_fallback: F2) -> ServeDir<SetStatus<F2>> {
267        self.fallback(SetStatus::new(new_fallback, StatusCode::NOT_FOUND))
268    }
269
270    rama_utils::macros::generate_set_and_with! {
271        /// Customize whether or not to call the fallback for requests that aren't `GET` or `HEAD`.
272        ///
273        /// Defaults to not calling the fallback and instead returning `405 Method Not Allowed`.
274        pub fn call_fallback_on_method_not_allowed(mut self, call_fallback: bool) -> Self {
275            self.call_fallback_on_method_not_allowed = call_fallback;
276            self
277        }
278    }
279
280    /// Call the service and get a future that contains any `std::io::Error` that might have
281    /// happened.
282    ///
283    /// This only returns I/O errors encountered while serving the request. Invalid request paths
284    /// and unsupported methods are represented as responses. When a fallback is configured,
285    /// errors that the default service would convert to `404 Not Found` call the fallback instead
286    /// of being returned.
287    ///
288    /// By default `<ServeDir as Service<_>>::serve` will handle IO errors and convert them into
289    /// responses. It does that by converting [`std::io::ErrorKind::NotFound`] and
290    /// [`std::io::ErrorKind::PermissionDenied`] to `404 Not Found`. On Unix, errors indicating
291    /// that a path component is not a directory are also converted to `404 Not Found`. Any other
292    /// error is converted to `500 Internal Server Error` and will also be logged with `tracing`.
293    ///
294    /// If you want to manually control how the error response is generated you can make a new
295    /// service that wraps a `ServeDir` and calls `try_call` instead of `serve`.
296    pub async fn try_call<ReqBody, FResBody>(
297        &self,
298        req: Request<ReqBody>,
299    ) -> Result<Response, std::io::Error>
300    where
301        F: Service<Request<ReqBody>, Output = Response<FResBody>, Error = Infallible> + Clone,
302        FResBody: StreamingBody<Data = Bytes, Error: Into<BoxError>> + Send + Sync + 'static,
303    {
304        if req.method() != Method::GET && req.method() != Method::HEAD {
305            if self.call_fallback_on_method_not_allowed
306                && let Some(fallback) = self.fallback.as_ref()
307            {
308                return future::serve_fallback(fallback, req).await;
309            }
310
311            return Ok(future::method_not_allowed());
312        }
313
314        // `ServeDir` doesn't care about the request body but the fallback might. So move out the
315        // body and pass it to the fallback, leaving an empty body in its place
316        //
317        // this is necessary because we cannot clone bodies
318        let (mut parts, body) = req.into_parts();
319        // same goes for extensions
320        let extensions = std::mem::take(&mut parts.extensions);
321        let req = Request::from_parts(parts, Body::empty());
322
323        let fallback_and_request = self.fallback.as_ref().map(|fallback| {
324            let mut fallback_req = Request::new(body);
325            *fallback_req.method_mut() = req.method().clone();
326            *fallback_req.uri_mut() = req.uri().clone();
327            *fallback_req.headers_mut() = req.headers().clone();
328            // Carry the ORIGINAL request's full extensions (including any
329            // parent chain) onto the fallback request. `extend` would copy
330            // only the top-level store and silently drop a parent chain.
331            let (mut fallback_parts, fallback_body) = fallback_req.into_parts();
332            fallback_parts.extensions = extensions;
333            let fallback_req = Request::from_parts(fallback_parts, fallback_body);
334
335            (fallback, fallback_req)
336        });
337
338        // Canonicalize per RFC 3986 first so `.`/`..` segments (including
339        // percent-encoded ones) are resolved and clamped to the path root
340        // before mapping to the filesystem; `build_and_validate_path` then
341        // rejects anything that survives as a defensive backstop.
342        let canonical_uri = req.uri().clone().canonicalize();
343
344        let requested_path = canonical_uri.path_or_root();
345        let Some(path_to_file) = self
346            .variant
347            .build_and_validate_path(&self.base, requested_path.as_ref())
348        else {
349            return if let Some((fallback, request)) = fallback_and_request {
350                future::serve_fallback(fallback, request).await
351            } else {
352                Ok(future::not_found())
353            };
354        };
355
356        let buf_chunk_size = self.buf_chunk_size;
357        let range_header = req
358            .headers()
359            .get(header::RANGE)
360            .and_then(|value| value.to_str().ok())
361            .map(|s| s.to_owned());
362
363        let precompression_configured = self.precompressed_variants.is_some();
364        let negotiated_encodings: Vec<_> = parse_accept_encoding_headers(
365            req.headers(),
366            self.precompressed_variants.unwrap_or_default(),
367        )
368        .collect();
369
370        let variant = self.variant.clone();
371        let open_file_result = open_file::open_file(
372            variant,
373            path_to_file,
374            req,
375            negotiated_encodings,
376            range_header.as_deref(),
377            buf_chunk_size,
378            &self.base,
379            precompression_configured,
380            self.symlink_policy,
381        )
382        .await;
383
384        future::consume_open_file_result(open_file_result, fallback_and_request).await
385    }
386}
387
388impl<ReqBody, F, FResBody> Service<Request<ReqBody>> for ServeDir<F>
389where
390    ReqBody: Send + 'static,
391    F: Service<Request<ReqBody>, Output = Response<FResBody>, Error = Infallible> + Clone,
392    FResBody: StreamingBody<Data = Bytes, Error: Into<BoxError>> + Send + Sync + 'static,
393{
394    type Output = Response;
395    type Error = Infallible;
396
397    async fn serve(&self, req: Request<ReqBody>) -> Result<Self::Output, Self::Error> {
398        let result = self.try_call(req).await;
399        Ok(result.unwrap_or_else(|err| {
400            if future::should_return_not_found(&err) {
401                StatusCode::NOT_FOUND.into_response()
402            } else {
403                tracing::error!("Failed to read file: {err:?}");
404                StatusCode::INTERNAL_SERVER_ERROR.into_response()
405            }
406        }))
407    }
408}
409
410/// Controls whether [`ServeDir`]/[`ServeFile`](super::ServeFile) follow filesystem
411/// symlinks when resolving a requested path.
412///
413/// The policy governs the **request-supplied** path components below the
414/// configured root: those are the path-traversal escape vector. The configured
415/// root (or single-file target) is operator-trusted and is served even if it is
416/// itself a symlink (e.g. a blue-green `current -> releases/N` deploy).
417///
418/// # Security caveat
419///
420/// Symlink rejection is **best-effort**: it is enforced by stat-ing each path
421/// component and then opening the file, which is two separate resolutions
422/// (a TOCTOU window). If the served tree is writable by untrusted parties a
423/// component could be swapped for a symlink between the check and the open.
424/// `RejectAll` is therefore not a substitute for serving from a root that is
425/// not writable by untrusted parties.
426#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
427pub enum ServeDirSymlinkPolicy {
428    #[default]
429    /// Reject any requested path component that is a symlink (final or intermediate).
430    RejectAll,
431    /// Allow the final requested path component to be a symlink, but reject symlinked
432    /// intermediate directory components.
433    AllowFinalComponent,
434    /// Allow filesystem symlinks. This restores the historical follow-symlinks behavior.
435    AllowAll,
436}
437
438impl fmt::Display for ServeDirSymlinkPolicy {
439    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
440        write!(
441            f,
442            "{}",
443            match self {
444                Self::RejectAll => "reject-all",
445                Self::AllowFinalComponent => "allow-final-component",
446                Self::AllowAll => "allow-all",
447            }
448        )
449    }
450}
451
452impl FromStr for ServeDirSymlinkPolicy {
453    type Err = BoxError;
454
455    fn from_str(s: &str) -> Result<Self, Self::Err> {
456        rama_utils::macros::match_ignore_ascii_case_str! {
457            match(s) {
458                "reject-all" | "reject_all" => Ok(Self::RejectAll),
459                "allow-final-component" | "allow_final_component" => Ok(Self::AllowFinalComponent),
460                "allow-all" | "allow_all" => Ok(Self::AllowAll),
461                _ => Err(BoxError::from_static_str("invalid ServeDirSymlinkPolicy str")),
462            }
463        }
464    }
465}
466
467impl TryFrom<&str> for ServeDirSymlinkPolicy {
468    type Error = BoxError;
469
470    #[inline]
471    fn try_from(value: &str) -> Result<Self, Self::Error> {
472        value.parse()
473    }
474}
475
476#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
477pub enum DirectoryServeMode {
478    #[default]
479    /// If the requested path is a directory append `index.html`.
480    ///
481    /// This is useful for static sites
482    AppendIndexHtml,
483    /// If the requested path is a directory
484    /// handle it as resource "not found" (404).
485    NotFound,
486    /// Show the file tree of the directory as file tree
487    /// which can be navigated.
488    #[cfg(feature = "html")]
489    HtmlFileList,
490}
491
492impl fmt::Display for DirectoryServeMode {
493    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
494        write!(
495            f,
496            "{}",
497            match self {
498                Self::AppendIndexHtml => "append-index",
499                Self::NotFound => "not-found",
500                #[cfg(feature = "html")]
501                Self::HtmlFileList => "html-file-list",
502            }
503        )
504    }
505}
506
507impl FromStr for DirectoryServeMode {
508    type Err = BoxError;
509
510    fn from_str(s: &str) -> Result<Self, Self::Err> {
511        rama_utils::macros::match_ignore_ascii_case_str! {
512            match(s) {
513                "append-index" | "append_index" => Ok(Self::AppendIndexHtml),
514                "not-found" | "not_found" => Ok(Self::NotFound),
515                "html-file-list" | "html_file_list" => html_file_list_from_str(),
516                _ => Err(BoxError::from_static_str("invalid DirectoryServeMode str")),
517            }
518        }
519    }
520}
521
522#[inline(always)]
523fn html_file_list_from_str() -> Result<DirectoryServeMode, BoxError> {
524    #[cfg(feature = "html")]
525    {
526        Ok(DirectoryServeMode::HtmlFileList)
527    }
528    #[cfg(not(feature = "html"))]
529    {
530        Err(BoxError::from_static_str(
531            "invalid DirectoryServeMode str: html file list requires html feature",
532        ))
533    }
534}
535
536impl TryFrom<&str> for DirectoryServeMode {
537    type Error = BoxError;
538
539    #[inline]
540    fn try_from(value: &str) -> Result<Self, Self::Error> {
541        value.parse()
542    }
543}
544
545// Allow the ServeDir service to be used in the ServeFile service
546// with almost no overhead
547#[derive(Clone, Debug)]
548enum ServeVariant {
549    Directory {
550        serve_mode: DirectoryServeMode,
551        /// If true, requests for a path without a file extension that doesn't
552        /// resolve to anything will be retried with `.html` appended.
553        html_as_default_extension: bool,
554    },
555    SingleFile {
556        mime: Mime,
557    },
558}
559
560impl ServeVariant {
561    /// Build and validate the file path based on the serve variant and requested path.
562    /// Returns None if the path is invalid or unsafe.
563    fn build_and_validate_path(&self, source: &DirSource, requested_path: &str) -> Option<PathBuf> {
564        match self {
565            Self::Directory { .. } => {
566                let path = requested_path.trim_start_matches('/');
567
568                let path_decoded = percent_decode(path.as_ref()).decode_utf8().ok()?;
569
570                // Reject path-traversal (`..`), absolute paths, smuggled
571                // prefixes (`/foo/c:/bar`, #204) and reserved device names via
572                // the shared utils primitive, then map the cleaned relative path
573                // under the configured root.
574                let relative = rama_utils::fs::sanitize_relative_path(&*path_decoded).ok()?;
575
576                let mut path_to_file = match source {
577                    DirSource::Filesystem(base_path) => base_path.clone(),
578                    DirSource::Embedded(_) => PathBuf::new(), // For embedded files, we don't need a filesystem path
579                };
580                path_to_file.push(relative);
581                Some(path_to_file)
582            }
583            Self::SingleFile { mime: _ } => match source {
584                DirSource::Filesystem(base_path) => Some(base_path.clone()),
585                DirSource::Embedded(_) => Some(PathBuf::new()), // For embedded single file
586            },
587        }
588    }
589}
590
591/// The default fallback service used with [`ServeDir`].
592#[derive(Debug, Clone, Copy)]
593pub struct DefaultServeDirFallback(Infallible);
594
595impl<ReqBody> Service<Request<ReqBody>> for DefaultServeDirFallback
596where
597    ReqBody: Send + 'static,
598{
599    type Output = Response;
600    type Error = Infallible;
601
602    async fn serve(&self, _req: Request<ReqBody>) -> Result<Self::Output, Self::Error> {
603        match self.0 {}
604    }
605}
606
607#[derive(Clone, Copy, Debug, Default)]
608struct PrecompressedVariants {
609    gzip: bool,
610    deflate: bool,
611    br: bool,
612    zstd: bool,
613}
614
615impl SupportedEncodings for PrecompressedVariants {
616    fn gzip(&self) -> bool {
617        self.gzip
618    }
619
620    fn deflate(&self) -> bool {
621        self.deflate
622    }
623
624    fn br(&self) -> bool {
625        self.br
626    }
627
628    fn zstd(&self) -> bool {
629        self.zstd
630    }
631}