use std::{ fs, future::Future, io::{Cursor, Error as IoError, Read, Write}, process::Command, str, time::{Duration, SystemTime}, }; use http::{header, Request, StatusCode}; use http_body_util::BodyExt; use httpdate::fmt_http_date; use hyper::body::Buf; use hyper_staticfile::{ vfs::{FileAccess, MemoryFs}, AcceptEncoding, Body, Encoding, Static, }; use tempfile::TempDir; type Response = hyper::Response; type ResponseResult = Result; struct Harness { dir: TempDir, static_: Static, } impl Harness { fn new(files: Vec<(&str, &str)>) -> Harness { let dir = Self::create_temp_dir(files); let mut static_ = Static::new(dir.path()); static_ .cache_headers(Some(3600)) .allowed_encodings(AcceptEncoding::all()); Harness { dir, static_ } } fn create_temp_dir(files: Vec<(&str, &str)>) -> TempDir { let dir = TempDir::new().unwrap(); for (subpath, contents) in files { let fullpath = dir.path().join(subpath); fs::create_dir_all(fullpath.parent().unwrap()) .and_then(|_| fs::File::create(fullpath)) .and_then(|mut file| file.write_all(contents.as_bytes())) .expect("failed to write fixtures"); } dir } fn append(&self, subpath: &str, content: &str) { let path = self.dir.path().join(subpath); let mut f = fs::File::options() .append(true) .open(path) .expect("failed to append to fixture"); f.write_all(content.as_bytes()) .expect("failed to append to fixture"); } fn request(&self, req: Request) -> impl Future { self.static_.clone().serve(req) } fn get(&self, path: &str) -> impl Future { let req = Request::builder() .uri(path) .body(()) .expect("unable to build request"); self.request(req) } } async fn read_body(res: hyper::Response>) -> String { let mut body = String::new(); res.into_body() .collect() .await .unwrap() .aggregate() .reader() .read_to_string(&mut body) .unwrap(); body } #[tokio::test] async fn serves_non_default_file_from_absolute_root_path() { let harness = Harness::new(vec![("file1.html", "this is file1")]); let res = harness.get("/file1.html").await.unwrap(); assert_eq!(read_body(res).await, "this is file1"); } #[tokio::test] async fn serves_default_file_from_absolute_root_path() { let harness = Harness::new(vec![("index.html", "this is index")]); let res = harness.get("/index.html").await.unwrap(); assert_eq!(read_body(res).await, "this is index"); } #[tokio::test] async fn serves_default_file_from_empty_root_path() { let harness = Harness::new(vec![("index.html", "this is index")]); let res = harness.get("/").await.unwrap(); assert_eq!(read_body(res).await, "this is index"); } #[tokio::test] async fn returns_404_if_file_not_found() { let harness = Harness::new(vec![]); let res = harness.get("/").await.unwrap(); assert_eq!(res.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn redirects_if_trailing_slash_is_missing() { let harness = Harness::new(vec![("foo/bar/index.html", "this is index")]); let res = harness.get("/foo/bar").await.unwrap(); assert_eq!(res.status(), StatusCode::MOVED_PERMANENTLY); let url = res.headers().get(header::LOCATION).unwrap(); assert_eq!(url, "/foo/bar/"); } #[tokio::test] async fn redirects_to_sanitized_path() { let harness = Harness::new(vec![("foo.org/bar/index.html", "this is index")]); // Previous versions would base the redirect on the request path, but that is user input, and // the user could construct a schema-relative redirect this way. let res = harness.get("//foo.org/bar").await.unwrap(); assert_eq!(res.status(), StatusCode::MOVED_PERMANENTLY); let url = res.headers().get(header::LOCATION).unwrap(); // TODO: The request path is apparently parsed differently on Windows, but at least the // resulting redirect is still safe, and that's the important part. if cfg!(target_os = "windows") { assert_eq!(url, "/"); } else { assert_eq!(url, "/foo.org/bar/"); } } #[tokio::test] async fn decodes_percent_notation() { let harness = Harness::new(vec![("has space.html", "file with funky chars")]); let res = harness.get("/has%20space.html").await.unwrap(); assert_eq!(read_body(res).await, "file with funky chars"); } #[tokio::test] async fn normalizes_path() { let harness = Harness::new(vec![("index.html", "this is index")]); let res = harness.get("/xxx/../index.html").await.unwrap(); assert_eq!(read_body(res).await, "this is index"); } #[tokio::test] async fn normalizes_percent_encoded_path() { let harness = Harness::new(vec![("file1.html", "this is file1")]); let res = harness.get("/xxx/..%2ffile1.html").await.unwrap(); assert_eq!(read_body(res).await, "this is file1"); } #[tokio::test] async fn prevents_from_escaping_root() { let harness = Harness::new(vec![("file1.html", "this is file1")]); let res = harness.get("/../file1.html").await.unwrap(); assert_eq!(read_body(res).await, "this is file1"); let res = harness.get("/..%2ffile1.html").await.unwrap(); assert_eq!(read_body(res).await, "this is file1"); let res = harness.get("/xxx/..%2f..%2ffile1.html").await.unwrap(); assert_eq!(read_body(res).await, "this is file1"); } #[tokio::test] async fn sends_headers() { let harness = Harness::new(vec![("file1.html", "this is file1")]); let res = harness.get("/file1.html").await.unwrap(); assert_eq!(res.status(), StatusCode::OK); assert_eq!(res.headers().get(header::CONTENT_LENGTH).unwrap(), "13"); assert!(res.headers().get(header::LAST_MODIFIED).is_some()); assert!(res.headers().get(header::ETAG).is_some()); assert_eq!( res.headers().get(header::CACHE_CONTROL).unwrap(), "public, max-age=3600" ); assert_eq!( res.headers().get(header::CONTENT_TYPE), Some(&header::HeaderValue::from_static("text/html")) ); assert_eq!(read_body(res).await, "this is file1"); } #[tokio::test] async fn content_length() { let harness = Harness::new(vec![("file1.html", "this is file1")]); let res = harness.get("/file1.html").await.unwrap(); harness.append("file1.html", "more content"); assert_eq!(res.headers().get(header::CONTENT_LENGTH).unwrap(), "13"); assert_eq!(read_body(res).await, "this is file1"); } #[tokio::test] async fn changes_content_type_on_extension() { let harness = Harness::new(vec![("file1.gif", "this is file1")]); let res = harness.get("/file1.gif").await.unwrap(); assert_eq!( res.headers().get(header::CONTENT_TYPE), Some(&header::HeaderValue::from_static("image/gif")) ); } #[tokio::test] async fn changes_content_type_on_extension_js() { let harness = Harness::new(vec![("file1.js", "this is file1")]); let res = harness.get("/file1.js").await.unwrap(); assert_eq!( res.headers().get(header::CONTENT_TYPE), Some(&header::HeaderValue::from_static( "text/javascript; charset=utf-8" )) ); } #[tokio::test] async fn serves_file_with_old_if_modified_since() { let harness = Harness::new(vec![("file1.html", "this is file1")]); let if_modified = SystemTime::now() - Duration::from_secs(3600); let req = Request::builder() .uri("/file1.html") .header(header::IF_MODIFIED_SINCE, fmt_http_date(if_modified)) .body(()) .expect("unable to build request"); let res = harness.request(req).await.unwrap(); assert_eq!(read_body(res).await, "this is file1"); } #[tokio::test] async fn serves_file_with_new_if_modified_since() { let harness = Harness::new(vec![("file1.html", "this is file1")]); let if_modified = SystemTime::now() + Duration::from_secs(3600); let req = Request::builder() .uri("/file1.html") .header(header::IF_MODIFIED_SINCE, fmt_http_date(if_modified)) .body(()) .expect("unable to build request"); let res = harness.request(req).await.unwrap(); assert_eq!(res.status(), StatusCode::NOT_MODIFIED); } #[cfg(target_family = "unix")] #[tokio::test] async fn last_modified_is_gmt() { let harness = Harness::new(vec![("file1.html", "this is file1")]); let mut file_path = harness.dir.path().to_path_buf(); file_path.push("file1.html"); let status = Command::new("touch") .args(["-t", "198510260122.00"]) .arg(file_path) .env("TZ", "UTC") .status() .unwrap(); assert!(status.success()); let res = harness.get("/file1.html").await.unwrap(); assert_eq!( res.headers() .get(header::LAST_MODIFIED) .map(|val| val.to_str().unwrap()), Some("Sat, 26 Oct 1985 01:22:00 GMT") ); } #[cfg(target_family = "unix")] #[tokio::test] async fn no_headers_for_invalid_mtime() { let harness = Harness::new(vec![("file1.html", "this is file1")]); let mut file_path = harness.dir.path().to_path_buf(); file_path.push("file1.html"); let status = Command::new("touch") .args(["-t", "197001010000.01"]) .arg(file_path) .env("TZ", "UTC") .status() .unwrap(); assert!(status.success()); let res = harness.get("/file1.html").await.unwrap(); assert!(res.headers().get(header::ETAG).is_none()); } #[tokio::test] async fn serves_file_ranges_beginning() { let harness = Harness::new(vec![("file1.html", "this is file1")]); let req = Request::builder() .uri("/file1.html") .header(header::RANGE, "bytes=0-3") .body(()) .expect("unable to build request"); let res = harness.request(req).await.unwrap(); assert_eq!(read_body(res).await, "this"); } #[tokio::test] async fn serves_file_ranges_end() { let harness = Harness::new(vec![("file1.html", "this is file1")]); let req = Request::builder() .uri("/file1.html") .header(header::RANGE, "bytes=5-") .body(()) .expect("unable to build request"); let res = harness.request(req).await.unwrap(); assert_eq!(read_body(res).await, "is file1"); } #[tokio::test] async fn serves_file_ranges_multi() { let harness = Harness::new(vec![("file1.html", "this is file1")]); let req = Request::builder() .uri("/file1.html") .header(header::RANGE, "bytes=0-3, 5-") .body(()) .expect("unable to build request"); let res = harness.request(req).await.unwrap(); let content_type = res .headers() .get(header::CONTENT_TYPE) .unwrap() .to_str() .unwrap(); assert!(content_type.starts_with("multipart/byteranges; boundary=")); let boundary = &content_type[31..]; let mut body_expectation = Cursor::new(Vec::new()); write!(&mut body_expectation, "--{}\r\n", boundary).unwrap(); write!(&mut body_expectation, "Content-Range: bytes 0-3/13\r\n").unwrap(); write!(&mut body_expectation, "Content-Type: text/html\r\n").unwrap(); write!(&mut body_expectation, "\r\n").unwrap(); write!(&mut body_expectation, "this\r\n").unwrap(); write!(&mut body_expectation, "--{}\r\n", boundary).unwrap(); write!(&mut body_expectation, "Content-Range: bytes 5-12/13\r\n").unwrap(); write!(&mut body_expectation, "Content-Type: text/html\r\n").unwrap(); write!(&mut body_expectation, "\r\n").unwrap(); write!(&mut body_expectation, "is file1\r\n").unwrap(); write!(&mut body_expectation, "--{}--\r\n", boundary).unwrap(); let body_expectation = String::from_utf8(body_expectation.into_inner()).unwrap(); assert_eq!(read_body(res).await, body_expectation); } #[tokio::test] async fn serves_file_ranges_multi_assert_content_length_correct() { let harness = Harness::new(vec![("file1.html", "this is file1")]); let req = Request::builder() .uri("/file1.html") .header(header::RANGE, "bytes=0-3, 5-") .body(()) .expect("unable to build request"); let res = harness.request(req).await.unwrap(); let content_length: usize = res .headers() .get(header::CONTENT_LENGTH) .unwrap() .to_str() .unwrap() .parse() .unwrap(); assert_eq!(read_body(res).await.len(), content_length); } #[tokio::test] async fn serves_file_ranges_if_range_negative() { let harness = Harness::new(vec![("file1.html", "this is file1")]); let req = Request::builder() .uri("/file1.html") .header(header::RANGE, "bytes=5-") .header(header::IF_RANGE, "Sat, 26 Oct 1985 01:22:00 GMT") .body(()) .expect("unable to build request"); let res = harness.request(req).await.unwrap(); // whole thing comes back since If-Range didn't match assert_eq!(read_body(res).await, "this is file1"); } #[tokio::test] async fn serves_file_ranges_if_range_etag_positive() { let harness = Harness::new(vec![("file1.html", "this is file1")]); // first request goes out without etag to fetch etag let req = Request::builder() .uri("/file1.html") .body(()) .expect("unable to build request"); let res = harness.request(req).await.unwrap(); let etag_value = res.headers().get(header::ETAG).unwrap(); let req = Request::builder() .uri("/file1.html") .header(header::RANGE, "bytes=5-") .header(header::IF_RANGE, etag_value) .body(()) .expect("unable to build request"); let res = harness.request(req).await.unwrap(); assert_eq!(read_body(res).await, "is file1"); } #[tokio::test] async fn serves_requested_range_not_satisfiable_when_at_end() { let harness = Harness::new(vec![("file1.html", "this is file1")]); let req = Request::builder() .uri("/file1.html") .header(header::RANGE, "bytes=13-") .body(()) .expect("unable to build request"); let res = harness.request(req).await.unwrap(); assert_eq!(res.status(), hyper::StatusCode::RANGE_NOT_SATISFIABLE); } #[tokio::test] async fn serves_gzip() { let harness = Harness::new(vec![ ("file1.html", "this is file1"), ("file1.html.gz", "fake gzip compression"), ]); let req = Request::builder() .uri("/file1.html") .header(header::ACCEPT_ENCODING, "gzip") .body(()) .expect("unable to build request"); let res = harness.request(req).await.unwrap(); assert_eq!( res.headers().get(header::CONTENT_ENCODING), Some(&Encoding::Gzip.to_header_value()) ); assert_eq!(read_body(res).await, "fake gzip compression"); } #[tokio::test] async fn serves_br() { let harness = Harness::new(vec![ ("file1.html", "this is file1"), ("file1.html.br", "fake brotli compression"), ("file1.html.gz", "fake gzip compression"), ]); let req = Request::builder() .uri("/file1.html") .header(header::ACCEPT_ENCODING, "br;q=1.0, gzip;q=0.5") .body(()) .expect("unable to build request"); let res = harness.request(req).await.unwrap(); assert_eq!( res.headers().get(header::CONTENT_ENCODING), Some(&Encoding::Br.to_header_value()) ); assert_eq!(read_body(res).await, "fake brotli compression"); } #[tokio::test] async fn test_memory_fs() { let dir = Harness::create_temp_dir(vec![ ("index.html", "root index"), ("nested/index.html", "nested index"), ]); let fs = MemoryFs::from_dir(dir.path()) .await .expect("MemoryFs failed"); dir.close().expect("tempdir cleanup failed"); let static_ = Static::with_opener(fs); let req = Request::builder() .uri("/") .body(()) .expect("unable to build request"); let res = static_.clone().serve(req).await.unwrap(); assert_eq!(read_body(res).await, "root index"); let req = Request::builder() .uri("/nested/") .body(()) .expect("unable to build request"); let res = static_.serve(req).await.unwrap(); assert_eq!(read_body(res).await, "nested index"); } #[cfg(target_os = "windows")] #[tokio::test] async fn ignore_windows_drive_letter() { let harness = Harness::new(vec![("file1.html", "this is file1")]); let res = harness.get("/c:/file1.html").await.unwrap(); assert_eq!(read_body(res).await, "this is file1"); }