gotham/handler/assets/
mod.rs

1//! Defines handlers for static assets, used by `to_file` and `to_dir` routes.
2//! Both 'If-None-Match' (etags) and 'If-Modified-Since' are supported to check
3//! file modification.
4//! Side-by-side compressed files for gzip and brotli are supported if enabled
5//! See 'FileOptions' for more details.
6
7mod accepted_encoding;
8
9use bytes::{BufMut, Bytes, BytesMut};
10use futures_util::stream::{self, TryStream, TryStreamExt};
11use futures_util::{ready, FutureExt, TryFutureExt};
12use httpdate::parse_http_date;
13use hyper::header::*;
14use hyper::{Body, Response, StatusCode};
15use log::debug;
16use mime::{self, Mime};
17use mime_guess::from_path;
18use serde::Deserialize;
19use tokio::fs::File;
20use tokio::io::{AsyncRead, AsyncSeekExt, ReadBuf};
21
22use self::accepted_encoding::accepted_encodings;
23use crate::handler::{Handler, HandlerError, HandlerFuture, NewHandler};
24use crate::router::response::StaticResponseExtender;
25use crate::state::{FromState, State, StateData};
26
27use std::convert::From;
28use std::fs::Metadata;
29use std::io::{ErrorKind, SeekFrom};
30use std::iter::FromIterator;
31use std::mem::MaybeUninit;
32use std::path::{Component, Path, PathBuf};
33use std::pin::Pin;
34use std::task::Poll;
35use std::time::UNIX_EPOCH;
36use std::{cmp, io};
37
38/// Represents a handler for any files under a directory.
39#[derive(Clone)]
40pub struct DirHandler {
41    options: FileOptions,
42}
43
44/// Represents a handler for a single file.
45#[derive(Clone)]
46pub struct FileHandler {
47    options: FileOptions,
48}
49
50/// Options to pass to file or dir handlers.
51/// Allows overriding default behaviour for compression, cache control headers, etc.
52///
53/// `FileOptions` implements `From` for `String` and `PathBuf` (and related reference types) - so that a
54/// path can be passed to router builder methods if only default options are required.
55///
56/// For overridding default options, `FileOptions` provides builder methods. The default
57/// values and use of the builder methods are shown in the example below.
58///
59///
60/// ```rust
61/// # use gotham::handler::FileOptions;
62///
63/// let default_options = FileOptions::from("my_static_path");
64/// let from_builder = FileOptions::new("my_static_path")
65///     .with_cache_control("public")
66///     .with_gzip(false)
67///     .with_brotli(false)
68///     .build();
69///
70/// assert_eq!(default_options, from_builder);
71/// ```
72#[derive(Clone, Debug, Eq, PartialEq)]
73pub struct FileOptions {
74    path: PathBuf,
75    cache_control: String,
76    gzip: bool,
77    brotli: bool,
78    buffer_size: Option<usize>,
79}
80
81impl FileOptions {
82    /// Create a new `FileOptions` with default values.
83    pub fn new<P: AsRef<Path>>(path: P) -> Self
84    where
85        PathBuf: From<P>,
86    {
87        FileOptions {
88            path: PathBuf::from(path),
89            cache_control: "public".to_string(),
90            gzip: false,
91            brotli: false,
92            buffer_size: None,
93        }
94    }
95
96    /// Sets the "cache_control" header in static file responses to the given value.
97    pub fn with_cache_control(&mut self, cache_control: &str) -> &mut Self {
98        self.cache_control = cache_control.to_owned();
99        self
100    }
101
102    /// If `true`, given a request for FILE, serves FILE.gz if it exists in the static directory and
103    /// if the accept-encoding header is set to allow gzipped content (defaults to false).
104    pub fn with_gzip(&mut self, gzip: bool) -> &mut Self {
105        self.gzip = gzip;
106        self
107    }
108
109    /// If `true`, given a request for FILE, serves FILE.br if it exists in the static directory and
110    /// if the accept-encoding header is set to allow brotli content (defaults to false).
111    pub fn with_brotli(&mut self, brotli: bool) -> &mut Self {
112        self.brotli = brotli;
113        self
114    }
115
116    /// Sets the maximum buffer size to be used when serving the file.
117    /// If unset, the default maximum buffer size corresponding to file system block size will be used.
118    pub fn with_buffer_size(&mut self, buf_sz: usize) -> &mut Self {
119        self.buffer_size = Some(buf_sz);
120        self
121    }
122
123    /// Clones `self` to return an owned value for passing to a handler.
124    pub fn build(&mut self) -> Self {
125        self.clone()
126    }
127}
128
129/// Create a `FileOptions` from various types, used in
130/// the router builder `to_file` and `to_dir` implementations
131/// which have a constraint `FileOptions: From<P>` for default options.
132macro_rules! derive_from {
133    ($type:ty) => {
134        impl From<$type> for FileOptions {
135            fn from(t: $type) -> FileOptions {
136                FileOptions::new(t)
137            }
138        }
139    };
140}
141
142derive_from!(&Path);
143derive_from!(PathBuf);
144derive_from!(&str);
145derive_from!(&String);
146derive_from!(String);
147
148impl FileHandler {
149    /// Create a new `FileHandler` for the given path.
150    pub fn new<P>(path: P) -> FileHandler
151    where
152        FileOptions: From<P>,
153    {
154        FileHandler {
155            options: FileOptions::from(path),
156        }
157    }
158}
159
160impl DirHandler {
161    /// Create a new `DirHandler` with the given root path.
162    pub fn new<P>(path: P) -> DirHandler
163    where
164        FileOptions: From<P>,
165    {
166        DirHandler {
167            options: FileOptions::from(path),
168        }
169    }
170}
171
172impl NewHandler for FileHandler {
173    type Instance = Self;
174
175    fn new_handler(&self) -> anyhow::Result<Self::Instance> {
176        Ok(self.clone())
177    }
178}
179
180impl NewHandler for DirHandler {
181    type Instance = Self;
182
183    fn new_handler(&self) -> anyhow::Result<Self::Instance> {
184        Ok(self.clone())
185    }
186}
187
188impl Handler for DirHandler {
189    fn handle(self, state: State) -> Pin<Box<HandlerFuture>> {
190        let path = {
191            let mut base_path = self.options.path;
192            let file_path = PathBuf::from_iter(&FilePathExtractor::borrow_from(&state).parts);
193            base_path.extend(&normalize_path(&file_path));
194            base_path
195        };
196        create_file_response(
197            FileOptions {
198                path,
199                ..self.options
200            },
201            state,
202        )
203    }
204}
205
206impl Handler for FileHandler {
207    fn handle(self, state: State) -> Pin<Box<HandlerFuture>> {
208        create_file_response(self.options, state)
209    }
210}
211
212// Creates the `HandlerFuture` response based on the given `FileOptions`.
213fn create_file_response(options: FileOptions, state: State) -> Pin<Box<HandlerFuture>> {
214    let mime_type = mime_for_path(&options.path);
215    let headers = HeaderMap::borrow_from(&state).clone();
216
217    let (path, encoding) = check_compressed_options(&options, &headers);
218
219    let response_future = File::open(path).and_then(move |mut file| async move {
220        let meta = file.metadata().await?;
221        if not_modified(&meta, &headers) {
222            return Ok(hyper::Response::builder()
223                .status(StatusCode::NOT_MODIFIED)
224                .body(Body::empty())
225                .unwrap());
226        }
227        let buf_size = options
228            .buffer_size
229            .unwrap_or_else(|| optimal_buf_size(&meta));
230        let (len, range_start) = match resolve_range(meta.len(), &headers) {
231            Ok((len, range_start)) => (len, range_start),
232            Err(e) => {
233                return Ok(hyper::Response::builder()
234                    .status(StatusCode::RANGE_NOT_SATISFIABLE)
235                    .body(Body::from(e))
236                    .unwrap());
237            }
238        };
239        if let Some(seek_to) = range_start {
240            file.seek(SeekFrom::Start(seek_to)).await?;
241        };
242
243        let stream = file_stream(file, cmp::min(buf_size, len as usize), len);
244        let body = Body::wrap_stream(stream.into_stream());
245        let mut response = hyper::Response::builder()
246            .status(StatusCode::OK)
247            .header(CONTENT_LENGTH, len)
248            .header(CONTENT_TYPE, mime_type.as_ref())
249            .header(CACHE_CONTROL, options.cache_control);
250
251        if let Some(etag) = entity_tag(&meta) {
252            response = response.header(ETAG, etag);
253        }
254        if let Some(content_encoding) = encoding {
255            response = response.header(CONTENT_ENCODING, content_encoding);
256        }
257
258        if let Some(range_start) = range_start {
259            let val = format!(
260                "bytes {}-{}/{}",
261                range_start,
262                (range_start + len).saturating_sub(1),
263                meta.len()
264            );
265            response = response.status(StatusCode::PARTIAL_CONTENT).header(
266                CONTENT_RANGE,
267                HeaderValue::from_str(&val).map_err(|e| io::Error::new(ErrorKind::Other, e))?,
268            );
269        }
270
271        Ok(response.body(body).unwrap())
272    });
273
274    response_future
275        .map(|result| match result {
276            Ok(response) => Ok((state, response)),
277            Err(err) => {
278                let status = match err.kind() {
279                    io::ErrorKind::NotFound => StatusCode::NOT_FOUND,
280                    io::ErrorKind::PermissionDenied => StatusCode::FORBIDDEN,
281                    _ => StatusCode::INTERNAL_SERVER_ERROR,
282                };
283                let err: HandlerError = err.into();
284                Err((state, err.with_status(status)))
285            }
286        })
287        .boxed()
288}
289
290/// Checks for existence of "Range" header and whether it is in supported format
291/// This implementations only supports single part ranges.
292/// Returns a result of length and optional starting position, or an error if range value is invalid
293/// If range header does not exist or is unsupported the length is the whole file length and start position is none.
294fn resolve_range(len: u64, headers: &HeaderMap) -> Result<(u64, Option<u64>), &'static str> {
295    let Some(range_val) = headers.get(RANGE) else {
296        return Ok((len, None));
297    };
298    range_val
299        .to_str()
300        .ok()
301        .and_then(|range_val| {
302            regex::Regex::new(r"^bytes=(\d*)-(\d*)$")
303                .unwrap()
304                .captures(range_val)
305                .map(|captures| {
306                    let begin = captures
307                        .get(1)
308                        .and_then(|digits| digits.as_str().parse::<u64>().ok());
309                    let end = captures
310                        .get(2)
311                        .and_then(|digits| digits.as_str().parse::<u64>().ok());
312                    match (begin, end) {
313                        (Some(begin), Some(end)) => {
314                            let end = cmp::min(end, len.saturating_sub(1));
315                            if end < begin {
316                                Err("invalid range")
317                            } else {
318                                let begin = cmp::min(begin, end);
319                                Ok(((1 + end).saturating_sub(begin), Some(begin)))
320                            }
321                        }
322                        (Some(begin), None) => {
323                            let end = len.saturating_sub(1);
324                            let begin = cmp::min(begin, len);
325                            Ok((1 + end.saturating_sub(begin), Some(begin)))
326                        }
327                        (None, Some(end)) => {
328                            let begin = len.saturating_sub(end);
329                            Ok((end, Some(begin)))
330                        }
331                        (None, None) => Err("invalid range"),
332                    }
333                })
334        })
335        .unwrap_or(Ok((len, None)))
336}
337
338// Checks for existence of compressed files if `FileOptions` and
339// "Accept-Encoding" headers allow. Returns the final path to read,
340// along with an optional encoding to return as the "Content-Encoding".
341fn check_compressed_options(
342    options: &FileOptions,
343    headers: &HeaderMap,
344) -> (PathBuf, Option<String>) {
345    options
346        .path
347        .file_name()
348        .and_then(|filename| {
349            accepted_encodings(headers)
350                .iter()
351                .filter_map(|e| {
352                    get_extension(&e.encoding, options).map(|ext| (e.encoding.to_string(), ext))
353                })
354                .find_map(|(encoding, ext)| {
355                    let path = options.path.with_file_name(format!(
356                        "{}.{}",
357                        filename.to_string_lossy(),
358                        ext
359                    ));
360                    if path.exists() {
361                        Some((path, Some(encoding)))
362                    } else {
363                        None
364                    }
365                })
366        })
367        .unwrap_or((options.path.clone(), None))
368}
369
370// Gets the file extension for the compressed version of a file
371// for a given encoding, if allowed by `FileOptions`.
372fn get_extension(encoding: &str, options: &FileOptions) -> Option<String> {
373    if encoding == "gzip" && options.gzip {
374        return Some("gz".to_string());
375    }
376    if encoding == "br" && options.brotli {
377        return Some("br".to_string());
378    }
379    None
380}
381
382fn mime_for_path(path: &Path) -> Mime {
383    from_path(path).first_or_octet_stream()
384}
385
386fn normalize_path(path: &Path) -> PathBuf {
387    path.components()
388        .fold(PathBuf::new(), |mut result, p| match p {
389            Component::Normal(x) => {
390                result.push(x);
391                result
392            }
393            Component::ParentDir => {
394                result.pop();
395                result
396            }
397            _ => result,
398        })
399}
400
401// Checks whether a file is modified based on metadata and request headers.
402fn not_modified(metadata: &Metadata, headers: &HeaderMap) -> bool {
403    // If-None-Match header takes precedence over If-Modified-Since
404    match headers.get(IF_NONE_MATCH) {
405        Some(_) => entity_tag(metadata)
406            .map(|etag| headers.get_all(IF_NONE_MATCH).iter().any(|v| v == &etag))
407            .unwrap_or(false),
408        _ => headers
409            .get(IF_MODIFIED_SINCE)
410            .and_then(|v| v.to_str().ok())
411            .and_then(|v| parse_http_date(v).ok())
412            .and_then(|if_modified_time| {
413                metadata
414                    .modified()
415                    .map(|modified| modified <= if_modified_time)
416                    .ok()
417            })
418            .unwrap_or(false),
419    }
420}
421
422fn entity_tag(metadata: &Metadata) -> Option<String> {
423    metadata.modified().ok().and_then(|modified| {
424        modified.duration_since(UNIX_EPOCH).ok().map(|duration| {
425            format!(
426                "W/\"{0:x}-{1:x}.{2:x}\"",
427                metadata.len(),
428                duration.as_secs(),
429                duration.subsec_nanos()
430            )
431        })
432    })
433}
434
435/// Responsible for extracting the file path matched by the glob segment from the URL.
436#[derive(Debug, Deserialize)]
437pub struct FilePathExtractor {
438    #[serde(rename = "*")]
439    parts: Vec<String>,
440}
441
442impl StateData for FilePathExtractor {}
443
444impl StaticResponseExtender for FilePathExtractor {
445    type ResBody = Body;
446    fn extend(_state: &mut State, _res: &mut Response<Self::ResBody>) {}
447}
448
449// Creates a Stream from the given file, for streaming as part of the Response.
450// Inspired by Warp https://github.com/seanmonstar/warp/blob/master/src/filters/fs.rs
451// Inspired by tokio https://github.com/tokio-rs/tokio/blob/master/tokio/src/io/util/read_buf.rs
452// Thanks @seanmonstar and @carllerche.
453#[allow(unsafe_code)]
454fn file_stream(
455    mut f: File,
456    buf_size: usize,
457    mut len: u64,
458) -> impl TryStream<Ok = Bytes, Error = io::Error> + Send {
459    let mut buf = BytesMut::with_capacity(buf_size);
460    stream::poll_fn(move |cx| {
461        if len == 0 {
462            return Poll::Ready(None);
463        }
464        if buf.remaining_mut() < buf_size {
465            buf.reserve(buf_size);
466        }
467
468        let dst = buf.chunk_mut();
469        let dst = unsafe { &mut *(dst as *mut _ as *mut [MaybeUninit<u8>]) };
470        let mut read_buf = ReadBuf::uninit(dst);
471        let read = Pin::new(&mut f).poll_read(cx, &mut read_buf);
472        ready!(read).map_err(|err| {
473            debug!("file read error: {}", err);
474            err
475        })?;
476
477        if read_buf.filled().is_empty() {
478            debug!("file read found EOF before expected length");
479            return Poll::Ready(Some(Err(io::Error::new(
480                io::ErrorKind::UnexpectedEof,
481                "file read found EOF before expected length",
482            ))));
483        }
484
485        let n = read_buf.filled().len();
486        // Safety: This is guaranteed to be the number of initialized (and read)
487        // bytes due to the invariants provided by `ReadBuf::filled`.
488        unsafe {
489            buf.advance_mut(n);
490        }
491        let n = n as u64;
492
493        let chunk = if n > len {
494            let chunk = buf.split_to(len as usize);
495            len = 0;
496            chunk
497        } else {
498            len -= n;
499            buf.split()
500        };
501
502        Poll::Ready(Some(Ok(chunk.freeze())))
503    })
504}
505
506fn optimal_buf_size(metadata: &Metadata) -> usize {
507    let block_size = get_block_size(metadata);
508
509    // If file length is smaller than block size, don't waste space
510    // reserving a bigger-than-needed buffer.
511    cmp::min(block_size as u64, metadata.len()) as usize
512}
513
514#[cfg(unix)]
515fn get_block_size(metadata: &Metadata) -> usize {
516    use std::os::unix::fs::MetadataExt;
517    metadata.blksize() as usize
518}
519
520#[cfg(not(unix))]
521fn get_block_size(metadata: &Metadata) -> usize {
522    8_192
523}
524
525#[cfg(test)]
526mod tests {
527    use super::FileOptions;
528    use crate::router::builder::{build_simple_router, DefineSingleRoute, DrawRoutes};
529    use crate::router::Router;
530    use crate::test::TestServer;
531    use hyper::header::*;
532    use hyper::StatusCode;
533    use std::fs::File;
534    use std::io::{Read, Seek, SeekFrom};
535    use std::path::PathBuf;
536    use std::{fs, str};
537    #[test]
538    fn assets_guesses_content_type() {
539        let expected_docs = vec![
540            (
541                "doc.html",
542                HeaderValue::from_static("text/html"),
543                "<html>I am a doc.</html>",
544            ),
545            (
546                "file.txt",
547                HeaderValue::from_static("text/plain"),
548                "I am a file",
549            ),
550            (
551                "styles/style.css",
552                HeaderValue::from_static("text/css"),
553                ".styled { border: none; }",
554            ),
555            (
556                "scripts/script.js",
557                HeaderValue::from_static("application/javascript"),
558                "console.log('I am javascript!');",
559            ),
560        ];
561
562        for doc in expected_docs {
563            let response = test_server()
564                .client()
565                .get(&format!("http://localhost/{}", doc.0))
566                .perform()
567                .unwrap();
568
569            assert_eq!(response.status(), StatusCode::OK);
570            assert_eq!(response.headers().get(CONTENT_TYPE).unwrap(), doc.1);
571
572            let body = response.read_body().unwrap();
573            assert_eq!(&body[..], doc.2.as_bytes());
574        }
575    }
576
577    // Examples derived from https://www.owasp.org/index.php/Path_Traversal
578    #[test]
579    fn assets_path_traversal() {
580        let traversal_attempts = vec![
581            r"../private_files/secret.txt",
582            r"%2e%2e%2fprivate_files/secret.txt",
583            r"%2e%2e/private_files/secret.txt",
584            r"..%2fprivate_files/secret.txt",
585            r"%2e%2e%5cprivate_files/secret.txt",
586            r"%2e%2e/private_files/secret.txt",
587            r"..%5cprivate_files/secret.txt",
588            r"%252e%252e%255cprivate_files/secret.txt",
589            r"..%255cprivate_files/secret.txt",
590            r"..%c0%afprivate_files/secret.txt",
591            r"..%c1%9cprivate_files/secret.txt",
592            "/etc/passwd",
593        ];
594        for attempt in traversal_attempts {
595            let response = test_server()
596                .client()
597                .get(&format!("http://localhost/{}", attempt))
598                .perform()
599                .unwrap();
600
601            assert_eq!(response.status(), StatusCode::NOT_FOUND);
602        }
603    }
604
605    #[test]
606    fn assets_single_file() {
607        let test_server = TestServer::new(build_simple_router(|route| {
608            route.get("/").to_file("resources/test/assets/doc.html")
609        }))
610        .unwrap();
611
612        let response = test_server
613            .client()
614            .get("http://localhost/")
615            .perform()
616            .unwrap();
617
618        assert_eq!(response.status(), StatusCode::OK);
619        assert_eq!(response.headers().get(CONTENT_TYPE).unwrap(), "text/html");
620
621        let body = response.read_body().unwrap();
622        assert_eq!(&body[..], b"<html>I am a doc.</html>");
623    }
624
625    #[test]
626    fn assets_if_none_match_etag() {
627        use hyper::header::{ETAG, IF_NONE_MATCH};
628        use std::fs::File;
629
630        let path = "resources/test/assets/doc.html";
631        let test_server =
632            TestServer::new(build_simple_router(|route| route.get("/").to_file(path))).unwrap();
633
634        let etag = File::open(path)
635            .and_then(|file| file.metadata())
636            .map(|meta| super::entity_tag(&meta).expect("entity tag"))
637            .unwrap();
638
639        // matching etag
640        let response = test_server
641            .client()
642            .get("http://localhost/")
643            .with_header(
644                IF_NONE_MATCH,
645                HeaderValue::from_bytes(etag.as_bytes()).unwrap(),
646            )
647            .perform()
648            .unwrap();
649
650        assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
651
652        // not matching etag
653        let response = test_server
654            .client()
655            .get("http://localhost/")
656            .with_header(IF_NONE_MATCH, HeaderValue::from_bytes(b"bogus").unwrap())
657            .perform()
658            .unwrap();
659
660        assert_eq!(response.status(), StatusCode::OK);
661        assert_eq!(
662            response.headers().get(ETAG).unwrap().to_str().unwrap(),
663            etag
664        );
665    }
666
667    #[test]
668    fn assets_if_modified_since() {
669        use httpdate::fmt_http_date;
670        use hyper::header::IF_MODIFIED_SINCE;
671        use std::fs::File;
672        use std::time::Duration;
673
674        let path = "resources/test/assets/doc.html";
675        let test_server =
676            TestServer::new(build_simple_router(|route| route.get("/").to_file(path))).unwrap();
677
678        let modified = File::open(path)
679            .and_then(|file| file.metadata())
680            .and_then(|meta| meta.modified())
681            .unwrap();
682
683        // if-modified-since a newer date
684        let response = test_server
685            .client()
686            .get("http://localhost/")
687            .with_header(
688                IF_MODIFIED_SINCE,
689                HeaderValue::from_bytes(fmt_http_date(modified + Duration::new(5, 0)).as_bytes())
690                    .unwrap(),
691            )
692            .perform()
693            .unwrap();
694
695        assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
696
697        // if-modified-since a older date
698        let response = test_server
699            .client()
700            .get("http://localhost/")
701            .with_header(
702                IF_MODIFIED_SINCE,
703                HeaderValue::from_bytes(fmt_http_date(modified - Duration::new(5, 0)).as_bytes())
704                    .unwrap(),
705            )
706            .perform()
707            .unwrap();
708
709        assert_eq!(response.status(), StatusCode::OK);
710    }
711
712    #[test]
713    fn assets_with_cache_control() {
714        let router = build_simple_router(|route| {
715            route.get("/*").to_dir(
716                FileOptions::new("resources/test/assets")
717                    .with_cache_control("no-cache")
718                    .build(),
719            )
720        });
721        let server = TestServer::new(router).unwrap();
722
723        let response = server
724            .client()
725            .get("http://localhost/doc.html")
726            .perform()
727            .unwrap();
728
729        assert_eq!(
730            response
731                .headers()
732                .get(CACHE_CONTROL)
733                .unwrap()
734                .to_str()
735                .unwrap(),
736            "no-cache"
737        );
738    }
739
740    #[test]
741    fn assets_default_cache_control() {
742        let router = build_simple_router(|route| route.get("/*").to_dir("resources/test/assets"));
743        let server = TestServer::new(router).unwrap();
744
745        let response = server
746            .client()
747            .get("http://localhost/doc.html")
748            .perform()
749            .unwrap();
750
751        assert_eq!(
752            response
753                .headers()
754                .get(CACHE_CONTROL)
755                .unwrap()
756                .to_str()
757                .unwrap(),
758            "public"
759        );
760    }
761
762    #[test]
763    fn assets_compressed_if_accept_and_exists() {
764        let compressed_options = vec![
765            (
766                "gzip",
767                ".gz",
768                FileOptions::new("resources/test/assets")
769                    .with_gzip(true)
770                    .build(),
771            ),
772            (
773                "br",
774                ".br",
775                FileOptions::new("resources/test/assets")
776                    .with_brotli(true)
777                    .build(),
778            ),
779        ];
780
781        for (encoding, extension, options) in compressed_options {
782            let router = build_simple_router(|route| route.get("/*").to_dir(options));
783            let server = TestServer::new(router).unwrap();
784
785            let response = server
786                .client()
787                .get("http://localhost/doc.html")
788                .with_header(ACCEPT_ENCODING, HeaderValue::from_str(encoding).unwrap())
789                .perform()
790                .unwrap();
791
792            assert_eq!(
793                response
794                    .headers()
795                    .get(CONTENT_ENCODING)
796                    .unwrap()
797                    .to_str()
798                    .unwrap(),
799                encoding
800            );
801            assert_eq!(
802                response
803                    .headers()
804                    .get(CONTENT_TYPE)
805                    .unwrap()
806                    .to_str()
807                    .unwrap(),
808                "text/html"
809            );
810
811            let expected_body =
812                fs::read(format!("resources/test/assets/doc.html{}", extension)).unwrap();
813            assert_eq!(response.read_body().unwrap(), expected_body);
814        }
815    }
816
817    #[test]
818    fn assets_no_compression_if_not_accepted() {
819        let router = build_simple_router(|route| {
820            route.get("/*").to_dir(
821                FileOptions::new("resources/test/assets")
822                    .with_gzip(true)
823                    .with_brotli(true)
824                    .build(),
825            )
826        });
827        let server = TestServer::new(router).unwrap();
828
829        let response = server
830            .client()
831            .get("http://localhost/doc.html")
832            .with_header(ACCEPT_ENCODING, HeaderValue::from_str("identity").unwrap())
833            .perform()
834            .unwrap();
835
836        assert!(response.headers().get(CONTENT_ENCODING).is_none());
837        assert_eq!(
838            response
839                .headers()
840                .get(CONTENT_TYPE)
841                .unwrap()
842                .to_str()
843                .unwrap(),
844            "text/html"
845        );
846
847        let expected_body = fs::read("resources/test/assets/doc.html").unwrap();
848        assert_eq!(response.read_body().unwrap(), expected_body);
849    }
850
851    #[test]
852    fn assets_no_compression_if_not_exists() {
853        let router = build_simple_router(|route| {
854            route.get("/*").to_dir(
855                FileOptions::new("resources/test/assets_uncompressed")
856                    .with_gzip(true)
857                    .with_brotli(true)
858                    .build(),
859            )
860        });
861        let server = TestServer::new(router).unwrap();
862
863        let response = server
864            .client()
865            .get("http://localhost/doc.html")
866            .with_header(ACCEPT_ENCODING, HeaderValue::from_str("gzip").unwrap())
867            .with_header(ACCEPT_ENCODING, HeaderValue::from_str("brotli").unwrap())
868            .perform()
869            .unwrap();
870
871        assert!(response.headers().get(CONTENT_ENCODING).is_none());
872        assert_eq!(
873            response
874                .headers()
875                .get(CONTENT_TYPE)
876                .unwrap()
877                .to_str()
878                .unwrap(),
879            "text/html"
880        );
881
882        let expected_body = fs::read("resources/test/assets_uncompressed/doc.html").unwrap();
883        assert_eq!(response.read_body().unwrap(), expected_body);
884    }
885
886    #[test]
887    fn assets_weighted_accept_encoding() {
888        let router = build_simple_router(|route| {
889            route.get("/*").to_dir(
890                FileOptions::new("resources/test/assets")
891                    .with_gzip(true)
892                    .with_brotli(true)
893                    .build(),
894            )
895        });
896        let server = TestServer::new(router).unwrap();
897
898        let response = server
899            .client()
900            .get("http://localhost/doc.html")
901            .with_header(
902                ACCEPT_ENCODING,
903                HeaderValue::from_str("*;q=0.1, br;q=1.0, gzip;q=0.8").unwrap(),
904            )
905            .perform()
906            .unwrap();
907
908        assert_eq!(
909            response
910                .headers()
911                .get(CONTENT_TYPE)
912                .unwrap()
913                .to_str()
914                .unwrap(),
915            "text/html"
916        );
917
918        assert_eq!(
919            response
920                .headers()
921                .get(CONTENT_ENCODING)
922                .unwrap()
923                .to_str()
924                .unwrap(),
925            "br"
926        );
927        let expected_body = fs::read("resources/test/assets/doc.html.br").unwrap();
928        assert_eq!(response.read_body().unwrap(), expected_body);
929    }
930
931    #[test]
932    fn assets_range_request() {
933        let root = PathBuf::from("resources/test/assets");
934        let file_name = "doc.html";
935        let mut file = File::open(root.join(file_name)).unwrap();
936        let file_len = file.metadata().unwrap().len();
937        let router = build_simple_router(|route| route.get("/*").to_dir(root));
938        let server = TestServer::new(router).unwrap();
939
940        let tests = [
941            (Some(1), Some(123456789), 1, file_len - 1),
942            (None, Some(5), file_len - 5, 5),
943            (Some(5), None, 5, file_len - 5),
944            (Some(5), Some(5), 5, 1),
945            (Some(6), Some(5), 0, 0),
946        ];
947
948        for (range_begin, range_end, range_start, range_len) in tests {
949            let range_header = format!(
950                "bytes={}-{}",
951                range_begin.map(|i| i.to_string()).unwrap_or("".to_string()),
952                range_end.map(|i| i.to_string()).unwrap_or("".to_string())
953            );
954            let response = server
955                .client()
956                .get(format!("http://localhost/{file_name}"))
957                .with_header(RANGE, HeaderValue::from_str(&range_header).unwrap())
958                .perform()
959                .unwrap();
960            if range_start == 0 && range_len == 0 {
961                assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
962                break;
963            }
964            assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
965            file.seek(SeekFrom::Start(range_start)).unwrap();
966
967            let expected_content_range = format!(
968                "bytes {}-{}/{}",
969                range_start,
970                range_start + range_len - 1,
971                file_len
972            );
973            assert_eq!(
974                response
975                    .headers()
976                    .get(CONTENT_RANGE)
977                    .unwrap()
978                    .to_str()
979                    .unwrap(),
980                expected_content_range
981            );
982            let mut expected_body = vec![0; range_len as usize];
983            file.read_exact(&mut expected_body).unwrap();
984            assert_eq!(response.read_body().unwrap(), expected_body);
985        }
986    }
987
988    fn test_server() -> TestServer {
989        TestServer::new(static_router("/*", "resources/test/assets")).unwrap()
990    }
991
992    fn static_router(mount: &str, path: &str) -> Router {
993        build_simple_router(|route| route.get(mount).to_dir(path))
994    }
995}