1mod 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#[derive(Clone)]
40pub struct DirHandler {
41 options: FileOptions,
42}
43
44#[derive(Clone)]
46pub struct FileHandler {
47 options: FileOptions,
48}
49
50#[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 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 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 pub fn with_gzip(&mut self, gzip: bool) -> &mut Self {
105 self.gzip = gzip;
106 self
107 }
108
109 pub fn with_brotli(&mut self, brotli: bool) -> &mut Self {
112 self.brotli = brotli;
113 self
114 }
115
116 pub fn with_buffer_size(&mut self, buf_sz: usize) -> &mut Self {
119 self.buffer_size = Some(buf_sz);
120 self
121 }
122
123 pub fn build(&mut self) -> Self {
125 self.clone()
126 }
127}
128
129macro_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 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 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
212fn 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
290fn 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
338fn 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
370fn 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
401fn not_modified(metadata: &Metadata, headers: &HeaderMap) -> bool {
403 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#[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#[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 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 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 #[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 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 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 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 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}