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    /// By default `<ServeDir as Service<_>>::call` will handle IO errors and convert them into
284    /// responses. It does that by converting [`std::io::ErrorKind::NotFound`] and
285    /// [`std::io::ErrorKind::PermissionDenied`] to `404 Not Found` and any other error to `500
286    /// Internal Server Error`. The error will also be logged with `tracing`.
287    ///
288    /// If you want to manually control how the error response is generated you can make a new
289    /// service that wraps a `ServeDir` and calls `try_call` instead of `call`.
290    pub async fn try_call<ReqBody, FResBody>(
291        &self,
292        req: Request<ReqBody>,
293    ) -> Result<Response, std::io::Error>
294    where
295        F: Service<Request<ReqBody>, Output = Response<FResBody>, Error = Infallible> + Clone,
296        FResBody: StreamingBody<Data = Bytes, Error: Into<BoxError>> + Send + Sync + 'static,
297    {
298        if req.method() != Method::GET && req.method() != Method::HEAD {
299            if self.call_fallback_on_method_not_allowed
300                && let Some(fallback) = self.fallback.as_ref()
301            {
302                return future::serve_fallback(fallback, req).await;
303            }
304
305            return Ok(future::method_not_allowed());
306        }
307
308        // `ServeDir` doesn't care about the request body but the fallback might. So move out the
309        // body and pass it to the fallback, leaving an empty body in its place
310        //
311        // this is necessary because we cannot clone bodies
312        let (mut parts, body) = req.into_parts();
313        // same goes for extensions
314        let extensions = std::mem::take(&mut parts.extensions);
315        let req = Request::from_parts(parts, Body::empty());
316
317        let fallback_and_request = self.fallback.as_ref().map(|fallback| {
318            let mut fallback_req = Request::new(body);
319            *fallback_req.method_mut() = req.method().clone();
320            *fallback_req.uri_mut() = req.uri().clone();
321            *fallback_req.headers_mut() = req.headers().clone();
322            // Carry the ORIGINAL request's full extensions (including any
323            // parent chain) onto the fallback request. `extend` would copy
324            // only the top-level store and silently drop a parent chain.
325            let (mut fallback_parts, fallback_body) = fallback_req.into_parts();
326            fallback_parts.extensions = extensions;
327            let fallback_req = Request::from_parts(fallback_parts, fallback_body);
328
329            (fallback, fallback_req)
330        });
331
332        // Canonicalize per RFC 3986 first so `.`/`..` segments (including
333        // percent-encoded ones) are resolved and clamped to the path root
334        // before mapping to the filesystem; `build_and_validate_path` then
335        // rejects anything that survives as a defensive backstop.
336        let canonical_uri = req.uri().clone().canonicalize();
337
338        let requested_path = canonical_uri.path_or_root();
339        let Some(path_to_file) = self
340            .variant
341            .build_and_validate_path(&self.base, requested_path.as_ref())
342        else {
343            return if let Some((fallback, request)) = fallback_and_request {
344                future::serve_fallback(fallback, request).await
345            } else {
346                Ok(future::not_found())
347            };
348        };
349
350        let buf_chunk_size = self.buf_chunk_size;
351        let range_header = req
352            .headers()
353            .get(header::RANGE)
354            .and_then(|value| value.to_str().ok())
355            .map(|s| s.to_owned());
356
357        let precompression_configured = self.precompressed_variants.is_some();
358        let negotiated_encodings: Vec<_> = parse_accept_encoding_headers(
359            req.headers(),
360            self.precompressed_variants.unwrap_or_default(),
361        )
362        .collect();
363
364        let variant = self.variant.clone();
365        let open_file_result = open_file::open_file(
366            variant,
367            path_to_file,
368            req,
369            negotiated_encodings,
370            range_header.as_deref(),
371            buf_chunk_size,
372            &self.base,
373            precompression_configured,
374            self.symlink_policy,
375        )
376        .await;
377
378        future::consume_open_file_result(open_file_result, fallback_and_request).await
379    }
380}
381
382impl<ReqBody, F, FResBody> Service<Request<ReqBody>> for ServeDir<F>
383where
384    ReqBody: Send + 'static,
385    F: Service<Request<ReqBody>, Output = Response<FResBody>, Error = Infallible> + Clone,
386    FResBody: StreamingBody<Data = Bytes, Error: Into<BoxError>> + Send + Sync + 'static,
387{
388    type Output = Response;
389    type Error = Infallible;
390
391    async fn serve(&self, req: Request<ReqBody>) -> Result<Self::Output, Self::Error> {
392        let result = self.try_call(req).await;
393        Ok(result.unwrap_or_else(|err| {
394            tracing::error!("Failed to read file: {err:?}");
395            StatusCode::INTERNAL_SERVER_ERROR.into_response()
396        }))
397    }
398}
399
400/// Controls whether [`ServeDir`]/[`ServeFile`](super::ServeFile) follow filesystem
401/// symlinks when resolving a requested path.
402///
403/// The policy governs the **request-supplied** path components below the
404/// configured root: those are the path-traversal escape vector. The configured
405/// root (or single-file target) is operator-trusted and is served even if it is
406/// itself a symlink (e.g. a blue-green `current -> releases/N` deploy).
407///
408/// # Security caveat
409///
410/// Symlink rejection is **best-effort**: it is enforced by stat-ing each path
411/// component and then opening the file, which is two separate resolutions
412/// (a TOCTOU window). If the served tree is writable by untrusted parties a
413/// component could be swapped for a symlink between the check and the open.
414/// `RejectAll` is therefore not a substitute for serving from a root that is
415/// not writable by untrusted parties.
416#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
417pub enum ServeDirSymlinkPolicy {
418    #[default]
419    /// Reject any requested path component that is a symlink (final or intermediate).
420    RejectAll,
421    /// Allow the final requested path component to be a symlink, but reject symlinked
422    /// intermediate directory components.
423    AllowFinalComponent,
424    /// Allow filesystem symlinks. This restores the historical follow-symlinks behavior.
425    AllowAll,
426}
427
428impl fmt::Display for ServeDirSymlinkPolicy {
429    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
430        write!(
431            f,
432            "{}",
433            match self {
434                Self::RejectAll => "reject-all",
435                Self::AllowFinalComponent => "allow-final-component",
436                Self::AllowAll => "allow-all",
437            }
438        )
439    }
440}
441
442impl FromStr for ServeDirSymlinkPolicy {
443    type Err = BoxError;
444
445    fn from_str(s: &str) -> Result<Self, Self::Err> {
446        rama_utils::macros::match_ignore_ascii_case_str! {
447            match(s) {
448                "reject-all" | "reject_all" => Ok(Self::RejectAll),
449                "allow-final-component" | "allow_final_component" => Ok(Self::AllowFinalComponent),
450                "allow-all" | "allow_all" => Ok(Self::AllowAll),
451                _ => Err(BoxError::from_static_str("invalid ServeDirSymlinkPolicy str")),
452            }
453        }
454    }
455}
456
457impl TryFrom<&str> for ServeDirSymlinkPolicy {
458    type Error = BoxError;
459
460    #[inline]
461    fn try_from(value: &str) -> Result<Self, Self::Error> {
462        value.parse()
463    }
464}
465
466#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
467pub enum DirectoryServeMode {
468    #[default]
469    /// If the requested path is a directory append `index.html`.
470    ///
471    /// This is useful for static sites
472    AppendIndexHtml,
473    /// If the requested path is a directory
474    /// handle it as resource "not found" (404).
475    NotFound,
476    /// Show the file tree of the directory as file tree
477    /// which can be navigated.
478    #[cfg(feature = "html")]
479    HtmlFileList,
480}
481
482impl fmt::Display for DirectoryServeMode {
483    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
484        write!(
485            f,
486            "{}",
487            match self {
488                Self::AppendIndexHtml => "append-index",
489                Self::NotFound => "not-found",
490                #[cfg(feature = "html")]
491                Self::HtmlFileList => "html-file-list",
492            }
493        )
494    }
495}
496
497impl FromStr for DirectoryServeMode {
498    type Err = BoxError;
499
500    fn from_str(s: &str) -> Result<Self, Self::Err> {
501        rama_utils::macros::match_ignore_ascii_case_str! {
502            match(s) {
503                "append-index" | "append_index" => Ok(Self::AppendIndexHtml),
504                "not-found" | "not_found" => Ok(Self::NotFound),
505                "html-file-list" | "html_file_list" => html_file_list_from_str(),
506                _ => Err(BoxError::from_static_str("invalid DirectoryServeMode str")),
507            }
508        }
509    }
510}
511
512#[inline(always)]
513fn html_file_list_from_str() -> Result<DirectoryServeMode, BoxError> {
514    #[cfg(feature = "html")]
515    {
516        Ok(DirectoryServeMode::HtmlFileList)
517    }
518    #[cfg(not(feature = "html"))]
519    {
520        Err(BoxError::from_static_str(
521            "invalid DirectoryServeMode str: html file list requires html feature",
522        ))
523    }
524}
525
526impl TryFrom<&str> for DirectoryServeMode {
527    type Error = BoxError;
528
529    #[inline]
530    fn try_from(value: &str) -> Result<Self, Self::Error> {
531        value.parse()
532    }
533}
534
535// Allow the ServeDir service to be used in the ServeFile service
536// with almost no overhead
537#[derive(Clone, Debug)]
538enum ServeVariant {
539    Directory {
540        serve_mode: DirectoryServeMode,
541        /// If true, requests for a path without a file extension that doesn't
542        /// resolve to anything will be retried with `.html` appended.
543        html_as_default_extension: bool,
544    },
545    SingleFile {
546        mime: Mime,
547    },
548}
549
550impl ServeVariant {
551    /// Build and validate the file path based on the serve variant and requested path.
552    /// Returns None if the path is invalid or unsafe.
553    fn build_and_validate_path(&self, source: &DirSource, requested_path: &str) -> Option<PathBuf> {
554        match self {
555            Self::Directory { .. } => {
556                let path = requested_path.trim_start_matches('/');
557
558                let path_decoded = percent_decode(path.as_ref()).decode_utf8().ok()?;
559
560                // Reject path-traversal (`..`), absolute paths, smuggled
561                // prefixes (`/foo/c:/bar`, #204) and reserved device names via
562                // the shared utils primitive, then map the cleaned relative path
563                // under the configured root.
564                let relative = rama_utils::fs::sanitize_relative_path(&*path_decoded).ok()?;
565
566                let mut path_to_file = match source {
567                    DirSource::Filesystem(base_path) => base_path.clone(),
568                    DirSource::Embedded(_) => PathBuf::new(), // For embedded files, we don't need a filesystem path
569                };
570                path_to_file.push(relative);
571                Some(path_to_file)
572            }
573            Self::SingleFile { mime: _ } => match source {
574                DirSource::Filesystem(base_path) => Some(base_path.clone()),
575                DirSource::Embedded(_) => Some(PathBuf::new()), // For embedded single file
576            },
577        }
578    }
579}
580
581/// The default fallback service used with [`ServeDir`].
582#[derive(Debug, Clone, Copy)]
583pub struct DefaultServeDirFallback(Infallible);
584
585impl<ReqBody> Service<Request<ReqBody>> for DefaultServeDirFallback
586where
587    ReqBody: Send + 'static,
588{
589    type Output = Response;
590    type Error = Infallible;
591
592    async fn serve(&self, _req: Request<ReqBody>) -> Result<Self::Output, Self::Error> {
593        match self.0 {}
594    }
595}
596
597#[derive(Clone, Copy, Debug, Default)]
598struct PrecompressedVariants {
599    gzip: bool,
600    deflate: bool,
601    br: bool,
602    zstd: bool,
603}
604
605impl SupportedEncodings for PrecompressedVariants {
606    fn gzip(&self) -> bool {
607        self.gzip
608    }
609
610    fn deflate(&self) -> bool {
611        self.deflate
612    }
613
614    fn br(&self) -> bool {
615        self.br
616    }
617
618    fn zstd(&self) -> bool {
619        self.zstd
620    }
621}