rama_http/service/fs/serve_dir/
mod.rs

1use crate::dep::http_body::{self, Body as HttpBody};
2use crate::headers::encoding::{SupportedEncodings, parse_accept_encoding_headers};
3use crate::layer::set_status::SetStatus;
4use crate::{Body, HeaderValue, Method, Request, Response, StatusCode, header};
5use bytes::Bytes;
6use percent_encoding::percent_decode;
7use rama_core::error::{BoxError, OpaqueError};
8use rama_core::{Context, Service};
9use std::fmt;
10use std::str::FromStr;
11use std::{
12    convert::Infallible,
13    path::{Component, Path, PathBuf},
14};
15
16pub(crate) mod future;
17mod headers;
18mod open_file;
19
20#[cfg(test)]
21mod tests;
22
23// default capacity 64KiB
24const DEFAULT_CAPACITY: usize = 65536;
25
26/// Service that serves files from a given directory and all its sub directories.
27///
28/// The `Content-Type` will be guessed from the file extension.
29///
30/// An empty response with status `404 Not Found` will be returned if:
31///
32/// - The file doesn't exist
33/// - Any segment of the path contains `..`
34/// - Any segment of the path contains a backslash
35/// - On unix, any segment of the path referenced as directory is actually an
36///   existing file (`/file.html/something`)
37/// - We don't have necessary permissions to read the file
38#[derive(Clone, Debug)]
39pub struct ServeDir<F = DefaultServeDirFallback> {
40    base: PathBuf,
41    buf_chunk_size: usize,
42    precompressed_variants: Option<PrecompressedVariants>,
43    // This is used to specialise implementation for
44    // single files
45    variant: ServeVariant,
46    fallback: Option<F>,
47    call_fallback_on_method_not_allowed: bool,
48}
49
50impl ServeDir<DefaultServeDirFallback> {
51    /// Create a new [`ServeDir`].
52    pub fn new<P>(path: P) -> Self
53    where
54        P: AsRef<Path>,
55    {
56        let mut base = PathBuf::from(".");
57        base.push(path.as_ref());
58
59        Self {
60            base,
61            buf_chunk_size: DEFAULT_CAPACITY,
62            precompressed_variants: None,
63            variant: ServeVariant::Directory {
64                serve_mode: Default::default(),
65            },
66            fallback: None,
67            call_fallback_on_method_not_allowed: false,
68        }
69    }
70
71    pub(crate) fn new_single_file<P>(path: P, mime: HeaderValue) -> Self
72    where
73        P: AsRef<Path>,
74    {
75        Self {
76            base: path.as_ref().to_owned(),
77            buf_chunk_size: DEFAULT_CAPACITY,
78            precompressed_variants: None,
79            variant: ServeVariant::SingleFile { mime },
80            fallback: None,
81            call_fallback_on_method_not_allowed: false,
82        }
83    }
84}
85
86impl<F> ServeDir<F> {
87    /// Set the [`DirectoryServeMode`].
88    pub fn with_directory_serve_mode(mut self, mode: DirectoryServeMode) -> Self {
89        match &mut self.variant {
90            ServeVariant::Directory { serve_mode } => {
91                *serve_mode = mode;
92                self
93            }
94            ServeVariant::SingleFile { mime: _ } => self,
95        }
96    }
97
98    /// Set the [`DirectoryServeMode`].
99    pub fn set_directory_serve_mode(&mut self, mode: DirectoryServeMode) -> &mut Self {
100        match &mut self.variant {
101            ServeVariant::Directory { serve_mode } => {
102                *serve_mode = mode;
103                self
104            }
105            ServeVariant::SingleFile { mime: _ } => self,
106        }
107    }
108
109    /// Set a specific read buffer chunk size.
110    ///
111    /// The default capacity is 64kb.
112    pub fn with_buf_chunk_size(mut self, chunk_size: usize) -> Self {
113        self.buf_chunk_size = chunk_size;
114        self
115    }
116
117    /// Set a specific read buffer chunk size.
118    ///
119    /// The default capacity is 64kb.
120    pub fn set_buf_chunk_size(&mut self, chunk_size: usize) -> &mut Self {
121        self.buf_chunk_size = chunk_size;
122        self
123    }
124
125    /// Informs the service that it should also look for a precompressed gzip
126    /// version of _any_ file in the directory.
127    ///
128    /// Assuming the `dir` directory is being served and `dir/foo.txt` is requested,
129    /// a client with an `Accept-Encoding` header that allows the gzip encoding
130    /// will receive the file `dir/foo.txt.gz` instead of `dir/foo.txt`.
131    /// If the precompressed file is not available, or the client doesn't support it,
132    /// the uncompressed version will be served instead.
133    /// Both the precompressed version and the uncompressed version are expected
134    /// to be present in the directory. Different precompressed variants can be combined.
135    pub fn precompressed_gzip(mut self) -> Self {
136        self.precompressed_variants
137            .get_or_insert(Default::default())
138            .gzip = true;
139        self
140    }
141
142    /// Informs the service that it should also look for a precompressed gzip
143    /// version of _any_ file in the directory.
144    ///
145    /// Assuming the `dir` directory is being served and `dir/foo.txt` is requested,
146    /// a client with an `Accept-Encoding` header that allows the gzip encoding
147    /// will receive the file `dir/foo.txt.gz` instead of `dir/foo.txt`.
148    /// If the precompressed file is not available, or the client doesn't support it,
149    /// the uncompressed version will be served instead.
150    /// Both the precompressed version and the uncompressed version are expected
151    /// to be present in the directory. Different precompressed variants can be combined.
152    pub fn set_precompressed_gzip(&mut self) -> &mut Self {
153        self.precompressed_variants
154            .get_or_insert(Default::default())
155            .gzip = true;
156        self
157    }
158
159    /// Informs the service that it should also look for a precompressed brotli
160    /// version of _any_ file in the directory.
161    ///
162    /// Assuming the `dir` directory is being served and `dir/foo.txt` is requested,
163    /// a client with an `Accept-Encoding` header that allows the brotli encoding
164    /// will receive the file `dir/foo.txt.br` instead of `dir/foo.txt`.
165    /// If the precompressed file is not available, or the client doesn't support it,
166    /// the uncompressed version will be served instead.
167    /// Both the precompressed version and the uncompressed version are expected
168    /// to be present in the directory. Different precompressed variants can be combined.
169    pub fn precompressed_br(mut self) -> Self {
170        self.precompressed_variants
171            .get_or_insert(Default::default())
172            .br = true;
173        self
174    }
175
176    /// Informs the service that it should also look for a precompressed brotli
177    /// version of _any_ file in the directory.
178    ///
179    /// Assuming the `dir` directory is being served and `dir/foo.txt` is requested,
180    /// a client with an `Accept-Encoding` header that allows the brotli encoding
181    /// will receive the file `dir/foo.txt.br` instead of `dir/foo.txt`.
182    /// If the precompressed file is not available, or the client doesn't support it,
183    /// the uncompressed version will be served instead.
184    /// Both the precompressed version and the uncompressed version are expected
185    /// to be present in the directory. Different precompressed variants can be combined.
186    pub fn set_precompressed_br(&mut self) -> &mut Self {
187        self.precompressed_variants
188            .get_or_insert(Default::default())
189            .br = true;
190        self
191    }
192
193    /// Informs the service that it should also look for a precompressed deflate
194    /// version of _any_ file in the directory.
195    ///
196    /// Assuming the `dir` directory is being served and `dir/foo.txt` is requested,
197    /// a client with an `Accept-Encoding` header that allows the deflate encoding
198    /// will receive the file `dir/foo.txt.zz` instead of `dir/foo.txt`.
199    /// If the precompressed file is not available, or the client doesn't support it,
200    /// the uncompressed version will be served instead.
201    /// Both the precompressed version and the uncompressed version are expected
202    /// to be present in the directory. Different precompressed variants can be combined.
203    pub fn precompressed_deflate(mut self) -> Self {
204        self.precompressed_variants
205            .get_or_insert(Default::default())
206            .deflate = true;
207        self
208    }
209
210    /// Informs the service that it should also look for a precompressed deflate
211    /// version of _any_ file in the directory.
212    ///
213    /// Assuming the `dir` directory is being served and `dir/foo.txt` is requested,
214    /// a client with an `Accept-Encoding` header that allows the deflate encoding
215    /// will receive the file `dir/foo.txt.zz` instead of `dir/foo.txt`.
216    /// If the precompressed file is not available, or the client doesn't support it,
217    /// the uncompressed version will be served instead.
218    /// Both the precompressed version and the uncompressed version are expected
219    /// to be present in the directory. Different precompressed variants can be combined.
220    pub fn set_precompressed_deflate(&mut self) -> &mut Self {
221        self.precompressed_variants
222            .get_or_insert(Default::default())
223            .deflate = true;
224        self
225    }
226
227    /// Informs the service that it should also look for a precompressed zstd
228    /// version of _any_ file in the directory.
229    ///
230    /// Assuming the `dir` directory is being served and `dir/foo.txt` is requested,
231    /// a client with an `Accept-Encoding` header that allows the zstd encoding
232    /// will receive the file `dir/foo.txt.zst` instead of `dir/foo.txt`.
233    /// If the precompressed file is not available, or the client doesn't support it,
234    /// the uncompressed version will be served instead.
235    /// Both the precompressed version and the uncompressed version are expected
236    /// to be present in the directory. Different precompressed variants can be combined.
237    pub fn precompressed_zstd(mut self) -> Self {
238        self.precompressed_variants
239            .get_or_insert(Default::default())
240            .zstd = true;
241        self
242    }
243
244    /// Informs the service that it should also look for a precompressed zstd
245    /// version of _any_ file in the directory.
246    ///
247    /// Assuming the `dir` directory is being served and `dir/foo.txt` is requested,
248    /// a client with an `Accept-Encoding` header that allows the zstd encoding
249    /// will receive the file `dir/foo.txt.zst` instead of `dir/foo.txt`.
250    /// If the precompressed file is not available, or the client doesn't support it,
251    /// the uncompressed version will be served instead.
252    /// Both the precompressed version and the uncompressed version are expected
253    /// to be present in the directory. Different precompressed variants can be combined.
254    pub fn set_precompressed_zstd(&mut self) -> &mut Self {
255        self.precompressed_variants
256            .get_or_insert(Default::default())
257            .zstd = true;
258        self
259    }
260
261    /// Set the fallback service.
262    ///
263    /// This service will be called if there is no file at the path of the request.
264    ///
265    /// The status code returned by the fallback will not be altered. Use
266    /// [`ServeDir::not_found_service`] to set a fallback and always respond with `404 Not Found`.
267    ///
268    /// # Example
269    ///
270    /// This can be used to respond with a different file:
271    ///
272    /// ```rust,no_run
273    /// use rama_core::{
274    ///     rt::Executor,
275    ///     Layer, layer::TraceErrLayer,
276    /// };
277    /// use rama_tcp::server::TcpListener;
278    /// use rama_http_backend::server::HttpServer;
279    /// use rama_http::service::fs::{ServeDir, ServeFile};
280    ///
281    /// #[tokio::main]
282    /// async fn main() {
283    ///     let exec = Executor::default();
284    ///
285    ///     let listener = TcpListener::bind("127.0.0.1:8080")
286    ///         .await
287    ///         .expect("bind TCP Listener");
288    ///
289    ///     // This will serve files in the "assets" directory and
290    ///     // its subdirectories, and use assets/not_found.html as the fallback page
291    ///     let fs_server = ServeDir::new("assets").fallback(ServeFile::new("assets/not_found.html"));
292    ///     let http_fs_server = HttpServer::auto(exec).service(fs_server);
293    ///
294    ///     // Serve the HTTP server over TCP
295    ///     listener
296    ///         .serve(TraceErrLayer::new().into_layer(http_fs_server))
297    ///         .await;
298    /// }
299    /// ```
300    pub fn fallback<F2>(self, new_fallback: F2) -> ServeDir<F2> {
301        ServeDir {
302            base: self.base,
303            buf_chunk_size: self.buf_chunk_size,
304            precompressed_variants: self.precompressed_variants,
305            variant: self.variant,
306            fallback: Some(new_fallback),
307            call_fallback_on_method_not_allowed: self.call_fallback_on_method_not_allowed,
308        }
309    }
310
311    /// Set the fallback service and override the fallback's status code to `404 Not Found`.
312    ///
313    /// This service will be called if there is no file at the path of the request.
314    ///
315    /// # Example
316    ///
317    /// This can be used to respond with a different file:
318    ///
319    /// ```rust,no_run
320    /// use rama_core::{
321    ///     rt::Executor,
322    ///     layer::TraceErrLayer,
323    ///     Layer,
324    /// };
325    /// use rama_tcp::server::TcpListener;
326    /// use rama_http_backend::server::HttpServer;
327    /// use rama_http::service::fs::{ServeDir, ServeFile};
328    ///
329    /// #[tokio::main]
330    /// async fn main() {
331    ///     let exec = Executor::default();
332    ///
333    ///     let listener = TcpListener::bind("127.0.0.1:8080")
334    ///         .await
335    ///         .expect("bind TCP Listener");
336    ///
337    ///     // This will serve files in the "assets" directory and
338    ///     // its subdirectories, and use assets/not_found.html as the not_found page
339    ///     let fs_server = ServeDir::new("assets").not_found_service(ServeFile::new("assets/not_found.html"));
340    ///     let http_fs_server = HttpServer::auto(exec).service(fs_server);
341    ///
342    ///     // Serve the HTTP server over TCP
343    ///     listener
344    ///         .serve(TraceErrLayer::new().into_layer(http_fs_server))
345    ///         .await;
346    /// }
347    /// ```
348    ///
349    /// Setups like this are often found in single page applications.
350    pub fn not_found_service<F2>(self, new_fallback: F2) -> ServeDir<SetStatus<F2>> {
351        self.fallback(SetStatus::new(new_fallback, StatusCode::NOT_FOUND))
352    }
353
354    /// Customize whether or not to call the fallback for requests that aren't `GET` or `HEAD`.
355    ///
356    /// Defaults to not calling the fallback and instead returning `405 Method Not Allowed`.
357    pub fn call_fallback_on_method_not_allowed(mut self, call_fallback: bool) -> Self {
358        self.call_fallback_on_method_not_allowed = call_fallback;
359        self
360    }
361
362    /// Customize whether or not to call the fallback for requests that aren't `GET` or `HEAD`.
363    ///
364    /// Defaults to not calling the fallback and instead returning `405 Method Not Allowed`.
365    pub fn set_call_fallback_on_method_not_allowed(&mut self, call_fallback: bool) -> &mut Self {
366        self.call_fallback_on_method_not_allowed = call_fallback;
367        self
368    }
369
370    /// Call the service and get a future that contains any `std::io::Error` that might have
371    /// happened.
372    ///
373    /// By default `<ServeDir as Service<_>>::call` will handle IO errors and convert them into
374    /// responses. It does that by converting [`std::io::ErrorKind::NotFound`] and
375    /// [`std::io::ErrorKind::PermissionDenied`] to `404 Not Found` and any other error to `500
376    /// Internal Server Error`. The error will also be logged with `tracing`.
377    ///
378    /// If you want to manually control how the error response is generated you can make a new
379    /// service that wraps a `ServeDir` and calls `try_call` instead of `call`.
380    ///
381    /// # Example
382    ///
383    /// ```rust,no_run
384    /// use rama_core::{
385    ///     rt::Executor,
386    ///     service::service_fn,
387    ///     layer::TraceErrLayer,
388    ///     Context, Layer,
389    /// };
390    /// use rama_tcp::server::TcpListener;
391    /// use rama_http_backend::server::HttpServer;
392    /// use rama_http::service::fs::ServeDir;
393    /// use rama_http::{Body, Request, Response, StatusCode};
394    /// use std::convert::Infallible;
395    ///
396    /// #[tokio::main]
397    /// async fn main() {
398    ///     let exec = Executor::default();
399    ///
400    ///     let listener = TcpListener::bind("127.0.0.1:8080")
401    ///         .await
402    ///         .expect("bind TCP Listener");
403    ///
404    ///     // This will serve files in the "assets" directory and
405    ///     // its subdirectories, and use assets/not_found.html as the fallback page
406    ///     let http_fs_server = HttpServer::auto(exec).service(service_fn(serve_dir));
407    ///
408    ///     // Serve the HTTP server over TCP
409    ///     listener
410    ///         .serve(TraceErrLayer::new().into_layer(http_fs_server))
411    ///         .await;
412    /// }
413    ///
414    /// async fn serve_dir<State>(
415    ///     ctx: Context<State>,
416    ///     request: Request,
417    /// ) -> Result<Response<Body>, Infallible>
418    /// where
419    ///     State: Clone + Send + Sync + 'static,
420    /// {
421    ///     let service = ServeDir::new("assets");
422    ///
423    ///     match service.try_call(ctx, request).await {
424    ///         Ok(response) => Ok(response),
425    ///         Err(_) => {
426    ///             let body = Body::from("Something went wrong...");
427    ///             let response = Response::builder()
428    ///                 .status(StatusCode::INTERNAL_SERVER_ERROR)
429    ///                 .body(body)
430    ///                 .unwrap();
431    ///             Ok(response)
432    ///         }
433    ///     }
434    /// }
435    /// ```
436    pub async fn try_call<State, ReqBody, FResBody>(
437        &self,
438        ctx: Context<State>,
439        req: Request<ReqBody>,
440    ) -> Result<Response, std::io::Error>
441    where
442        State: Clone + Send + Sync + 'static,
443        F: Service<State, Request<ReqBody>, Response = Response<FResBody>, Error = Infallible>
444            + Clone,
445        FResBody: http_body::Body<Data = Bytes, Error: Into<BoxError>> + Send + Sync + 'static,
446    {
447        if req.method() != Method::GET && req.method() != Method::HEAD {
448            if self.call_fallback_on_method_not_allowed {
449                if let Some(fallback) = self.fallback.as_ref() {
450                    return future::serve_fallback(fallback, ctx, req).await;
451                }
452            } else {
453                return Ok(future::method_not_allowed());
454            }
455        }
456
457        // `ServeDir` doesn't care about the request body but the fallback might. So move out the
458        // body and pass it to the fallback, leaving an empty body in its place
459        //
460        // this is necessary because we cannot clone bodies
461        let (mut parts, body) = req.into_parts();
462        // same goes for extensions
463        let extensions = std::mem::take(&mut parts.extensions);
464        let req = Request::from_parts(parts, Body::empty());
465
466        let fallback_and_request = self.fallback.as_ref().map(|fallback| {
467            let mut fallback_req = Request::new(body);
468            *fallback_req.method_mut() = req.method().clone();
469            *fallback_req.uri_mut() = req.uri().clone();
470            *fallback_req.headers_mut() = req.headers().clone();
471            *fallback_req.extensions_mut() = extensions;
472
473            (fallback, ctx, fallback_req)
474        });
475
476        let path_to_file = match self
477            .variant
478            .build_and_validate_path(&self.base, req.uri().path())
479        {
480            Some(path_to_file) => path_to_file,
481            None => {
482                return if let Some((fallback, ctx, request)) = fallback_and_request {
483                    future::serve_fallback(fallback, ctx, request).await
484                } else {
485                    Ok(future::not_found())
486                };
487            }
488        };
489
490        let buf_chunk_size = self.buf_chunk_size;
491        let range_header = req
492            .headers()
493            .get(header::RANGE)
494            .and_then(|value| value.to_str().ok())
495            .map(|s| s.to_owned());
496
497        let negotiated_encodings: Vec<_> = parse_accept_encoding_headers(
498            req.headers(),
499            self.precompressed_variants.unwrap_or_default(),
500        )
501        .collect();
502
503        let variant = self.variant.clone();
504
505        let open_file_result = open_file::open_file(
506            variant,
507            path_to_file,
508            req,
509            negotiated_encodings,
510            range_header,
511            buf_chunk_size,
512        )
513        .await;
514
515        future::consume_open_file_result(open_file_result, fallback_and_request).await
516    }
517}
518
519impl<State, ReqBody, F, FResBody> Service<State, Request<ReqBody>> for ServeDir<F>
520where
521    State: Clone + Send + Sync + 'static,
522    ReqBody: Send + 'static,
523    F: Service<State, Request<ReqBody>, Response = Response<FResBody>, Error = Infallible> + Clone,
524    FResBody: HttpBody<Data = Bytes, Error: Into<BoxError>> + Send + Sync + 'static,
525{
526    type Response = Response;
527    type Error = Infallible;
528
529    async fn serve(
530        &self,
531        ctx: Context<State>,
532        req: Request<ReqBody>,
533    ) -> Result<Self::Response, Self::Error> {
534        let result = self.try_call(ctx, req).await;
535        Ok(result.unwrap_or_else(|err| {
536            tracing::error!(error = %err, "Failed to read file");
537
538            let body = Body::empty();
539            Response::builder()
540                .status(StatusCode::INTERNAL_SERVER_ERROR)
541                .body(body)
542                .unwrap()
543        }))
544    }
545}
546
547#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
548pub enum DirectoryServeMode {
549    #[default]
550    /// If the requested path is a directory append `index.html`.
551    ///
552    /// This is useful for static sites
553    AppendIndexHtml,
554    /// If the requested path is a directory
555    /// handle it as resource "not found" (404).
556    NotFound,
557    /// Show the file tree of the directory as file tree
558    /// which can be navigated.
559    HtmlFileList,
560}
561
562impl fmt::Display for DirectoryServeMode {
563    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
564        write!(
565            f,
566            "{}",
567            match self {
568                DirectoryServeMode::AppendIndexHtml => "append-index",
569                DirectoryServeMode::NotFound => "not-found",
570                DirectoryServeMode::HtmlFileList => "html-file-list",
571            }
572        )
573    }
574}
575
576impl FromStr for DirectoryServeMode {
577    type Err = OpaqueError;
578
579    fn from_str(s: &str) -> Result<Self, Self::Err> {
580        rama_utils::macros::match_ignore_ascii_case_str! {
581            match(s) {
582                "append-index" | "append_index" => Ok(Self::AppendIndexHtml),
583                "not-found" | "not_found" => Ok(Self::NotFound),
584                "html-file-list" | "html_file_list" => Ok(Self::HtmlFileList),
585                _ => Err(OpaqueError::from_display("invalid DirectoryServeMode str")),
586            }
587        }
588    }
589}
590
591impl TryFrom<&str> for DirectoryServeMode {
592    type Error = OpaqueError;
593
594    #[inline]
595    fn try_from(value: &str) -> Result<Self, Self::Error> {
596        value.parse()
597    }
598}
599
600// Allow the ServeDir service to be used in the ServeFile service
601// with almost no overhead
602#[derive(Clone, Debug)]
603enum ServeVariant {
604    Directory { serve_mode: DirectoryServeMode },
605    SingleFile { mime: HeaderValue },
606}
607
608impl ServeVariant {
609    fn build_and_validate_path(&self, base_path: &Path, requested_path: &str) -> Option<PathBuf> {
610        match self {
611            ServeVariant::Directory { serve_mode: _ } => {
612                let path = requested_path.trim_start_matches('/');
613
614                let path_decoded = percent_decode(path.as_ref()).decode_utf8().ok()?;
615                let path_decoded = Path::new(&*path_decoded);
616
617                let mut path_to_file = base_path.to_path_buf();
618                for component in path_decoded.components() {
619                    match component {
620                        Component::Normal(comp) => {
621                            // protect against paths like `/foo/c:/bar/baz` (#204)
622                            if Path::new(&comp)
623                                .components()
624                                .all(|c| matches!(c, Component::Normal(_)))
625                            {
626                                path_to_file.push(comp)
627                            } else {
628                                return None;
629                            }
630                        }
631                        Component::CurDir => {}
632                        Component::Prefix(_) | Component::RootDir | Component::ParentDir => {
633                            return None;
634                        }
635                    }
636                }
637                Some(path_to_file)
638            }
639            ServeVariant::SingleFile { mime: _ } => Some(base_path.to_path_buf()),
640        }
641    }
642}
643
644/// The default fallback service used with [`ServeDir`].
645#[derive(Debug, Clone, Copy)]
646pub struct DefaultServeDirFallback(Infallible);
647
648impl<State, ReqBody> Service<State, Request<ReqBody>> for DefaultServeDirFallback
649where
650    State: Clone + Send + Sync + 'static,
651    ReqBody: Send + 'static,
652{
653    type Response = Response;
654    type Error = Infallible;
655
656    async fn serve(
657        &self,
658        _ctx: Context<State>,
659        _req: Request<ReqBody>,
660    ) -> Result<Self::Response, Self::Error> {
661        match self.0 {}
662    }
663}
664
665#[derive(Clone, Copy, Debug, Default)]
666struct PrecompressedVariants {
667    gzip: bool,
668    deflate: bool,
669    br: bool,
670    zstd: bool,
671}
672
673impl SupportedEncodings for PrecompressedVariants {
674    fn gzip(&self) -> bool {
675        self.gzip
676    }
677
678    fn deflate(&self) -> bool {
679        self.deflate
680    }
681
682    fn br(&self) -> bool {
683        self.br
684    }
685
686    fn zstd(&self) -> bool {
687        self.zstd
688    }
689}