use super::*; use super::fastcgi::{ fastcgi_body_stream, fastcgi_params, parse_cgi_headers, read_fcgi_record, write_fcgi_record, FCGI_END_REQUEST, FCGI_PARAMS, FCGI_STDIN, FCGI_STDOUT, }; use axum::{body::Body, response::Response}; use bytes::Bytes; use futures_util::StreamExt; use http::{ header::{ ACCEPT_RANGES, ALLOW, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, IF_RANGE, LAST_MODIFIED, LOCATION, RANGE, VARY, }, HeaderMap, HeaderValue, Method, StatusCode, Uri, Version, }; use http_body_util::BodyExt; use std::{collections::BTreeMap, path::Path, time::Duration}; use tempfile::TempDir; use tokio::{io::AsyncWriteExt, sync::mpsc, time::Instant}; fn service(root: &Path, listing: bool) -> CompiledService { Service { id: "site".into(), name: "Site".into(), document_root: root.to_path_buf(), index: vec!["index.html".into(), "index.htm".into()], directory_listing: listing, ..Default::default() } .compile() .unwrap() } fn request(method: Method, uri: &str) -> http::Request<()> { http::Request::builder() .method(method) .uri(uri) .body(()) .unwrap() } async fn serve_request( service: &CompiledService, request: &http::Request<()>, path: &str, ) -> Response { serve_body_request(service, request, path, Body::empty()).await } async fn serve_body_request( service: &CompiledService, request: &http::Request<()>, path: &str, body: Body, ) -> Response { serve( service, request.method().clone(), request.headers().clone(), request.uri().clone(), path, body, RequestContext { client_ip: "127.0.0.1".parse().unwrap(), client_port: Some(12345), server_name: "example.test".into(), server_port: 80, tls: false, version: Version::HTTP_11, }, ) .await } #[tokio::test] async fn get_head_index_and_directory_redirect_work() { let tmp = TempDir::new().unwrap(); std::fs::create_dir(tmp.path().join("dir")).unwrap(); std::fs::write(tmp.path().join("index.html"), b"root").unwrap(); std::fs::write(tmp.path().join("dir/index.html"), b"nested").unwrap(); let service = service(tmp.path(), false); let response = serve_request(&service, &request(Method::GET, "/"), "/").await; assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "root"); let response = serve_request(&service, &request(Method::HEAD, "/"), "/").await; assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.headers()[CONTENT_LENGTH], "4"); assert!(response.into_body().collect().await.unwrap().to_bytes().is_empty()); let response = serve_request(&service, &request(Method::GET, "/dir?x=1"), "/dir").await; assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY); assert_eq!(response.headers()[LOCATION], "/dir/?x=1"); } #[tokio::test] async fn missing_files_and_disabled_directory_listing_return_normal_errors() { let tmp = TempDir::new().unwrap(); std::fs::create_dir(tmp.path().join("empty")).unwrap(); let service = service(tmp.path(), false); let missing = serve_request(&service, &request(Method::GET, "/missing.txt"), "/missing.txt").await; assert_eq!(missing.status(), StatusCode::NOT_FOUND); let directory = serve_request(&service, &request(Method::GET, "/empty/"), "/empty/").await; assert_eq!(directory.status(), StatusCode::FORBIDDEN); assert_eq!(directory.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); let directory_body = directory.into_body().collect().await.unwrap().to_bytes(); let directory_body = std::str::from_utf8(&directory_body).unwrap(); assert!(directory_body.contains("403")); assert!(!directory_body.contains("directory listing is disabled")); let directory_head = serve_request(&service, &request(Method::HEAD, "/empty/"), "/empty/").await; assert_eq!(directory_head.status(), StatusCode::FORBIDDEN); assert!(directory_head.into_body().collect().await.unwrap().to_bytes().is_empty()); let method = serve_request(&service, &request(Method::POST, "/missing.txt"), "/missing.txt").await; assert_eq!(method.status(), StatusCode::METHOD_NOT_ALLOWED); assert_eq!(method.headers()[ALLOW], "GET, HEAD"); } #[tokio::test] async fn last_modified_condition_returns_304() { let tmp = TempDir::new().unwrap(); std::fs::write(tmp.path().join("file.txt"), b"validator").unwrap(); let service = service(tmp.path(), false); let first = serve_request(&service, &request(Method::GET, "/file.txt"), "/file.txt").await; let last_modified = first.headers()[LAST_MODIFIED].clone(); assert_eq!(first.headers()[CONTENT_TYPE], "text/plain; charset=utf-8"); assert_eq!(first.headers()[CONTENT_LENGTH], "9"); let mut conditional = request(Method::GET, "/file.txt"); conditional .headers_mut() .insert(IF_MODIFIED_SINCE, last_modified); let response = serve_request(&service, &conditional, "/file.txt").await; assert_eq!(response.status(), StatusCode::NOT_MODIFIED); assert!(response.headers().contains_key(LAST_MODIFIED)); } #[tokio::test] async fn validators_and_single_ranges_work() { let tmp = TempDir::new().unwrap(); std::fs::write(tmp.path().join("file.txt"), b"0123456789").unwrap(); let service = service(tmp.path(), false); let first = serve_request(&service, &request(Method::GET, "/file.txt"), "/file.txt").await; let etag = first.headers()[ETAG].clone(); assert_eq!(first.headers()[ACCEPT_RANGES], "bytes"); let mut conditional = request(Method::GET, "/file.txt"); conditional.headers_mut().insert(IF_NONE_MATCH, etag); assert_eq!( serve_request(&service, &conditional, "/file.txt").await.status(), StatusCode::NOT_MODIFIED ); let mut ranged = request(Method::GET, "/file.txt"); ranged.headers_mut().insert(RANGE, "bytes=2-5".parse().unwrap()); let response = serve_request(&service, &ranged, "/file.txt").await; assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); assert_eq!(response.headers()[CONTENT_RANGE], "bytes 2-5/10"); assert_eq!(response.headers()[CONTENT_LENGTH], "4"); assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "2345"); let mut invalid = request(Method::GET, "/file.txt"); invalid.headers_mut().insert(RANGE, "bytes=99-100".parse().unwrap()); let response = serve_request(&service, &invalid, "/file.txt").await; assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE); assert_eq!(response.headers()[CONTENT_RANGE], "bytes */10"); let mut if_range_match = request(Method::GET, "/file.txt"); if_range_match.headers_mut().insert(RANGE, "bytes=0-1".parse().unwrap()); if_range_match.headers_mut().insert(IF_RANGE, first.headers()[ETAG].clone()); assert_eq!(serve_request(&service, &if_range_match, "/file.txt").await.status(), StatusCode::PARTIAL_CONTENT); let mut if_range_miss = request(Method::GET, "/file.txt"); if_range_miss.headers_mut().insert(RANGE, "bytes=0-1".parse().unwrap()); if_range_miss.headers_mut().insert(IF_RANGE, "\"different\"".parse().unwrap()); let response = serve_request(&service, &if_range_miss, "/file.txt").await; assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.headers()[CONTENT_LENGTH], "10"); } #[tokio::test] async fn multipart_byte_ranges_stream_each_requested_part() { let tmp = TempDir::new().unwrap(); std::fs::write(tmp.path().join("file.txt"), b"0123456789").unwrap(); let service = service(tmp.path(), false); let mut ranged = request(Method::GET, "/file.txt"); ranged .headers_mut() .insert(RANGE, "bytes=0-1,6-8".parse().unwrap()); let response = serve_request(&service, &ranged, "/file.txt").await; assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); let content_type = response.headers()[CONTENT_TYPE].to_str().unwrap().to_owned(); assert!(content_type.starts_with("multipart/byteranges; boundary=")); assert!(!response.headers().contains_key(CONTENT_RANGE)); let body = response.into_body().collect().await.unwrap().to_bytes(); let body = String::from_utf8(body.to_vec()).unwrap(); assert!(body.contains("Content-Range: bytes 0-1/10")); assert!(body.contains("Content-Range: bytes 6-8/10")); assert!(body.contains("\r\n01\r\n")); assert!(body.contains("\r\n678\r\n")); } #[tokio::test] async fn too_many_byte_ranges_are_rejected() { let tmp = TempDir::new().unwrap(); std::fs::write(tmp.path().join("file.txt"), b"0123456789").unwrap(); let service = service(tmp.path(), false); let ranges = std::iter::repeat("0-0").take(17).collect::>().join(","); let mut ranged = request(Method::GET, "/file.txt"); ranged .headers_mut() .insert(RANGE, format!("bytes={ranges}").parse().unwrap()); let response = serve_request(&service, &ranged, "/file.txt").await; assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE); assert_eq!(response.headers()[CONTENT_RANGE], "bytes */10"); } #[tokio::test] async fn listing_escapes_html_and_encodes_urls() { let tmp = TempDir::new().unwrap(); std::fs::write(tmp.path().join("a & b.txt"), b"x").unwrap(); let service = service(tmp.path(), true); let response = serve_request(&service, &request(Method::GET, "/"), "/").await; let body = response.into_body().collect().await.unwrap().to_bytes(); let body = std::str::from_utf8(&body).unwrap(); assert!(body.contains("a & b.txt")); assert!(body.contains("a%20%26%20b.txt")); assert!(!body.contains(tmp.path().to_string_lossy().as_ref())); } #[tokio::test] async fn root_directory_listing_is_repeatable_across_refreshes() { let tmp = TempDir::new().unwrap(); std::fs::write(tmp.path().join("first.txt"), b"one").unwrap(); std::fs::write(tmp.path().join("second.txt"), b"two").unwrap(); let mut configured = Service { id: "repeat-listing".into(), name: "Repeat listing".into(), document_root: tmp.path().to_path_buf(), index: Vec::new(), directory_listing: true, ..Default::default() }; configured.validate().unwrap(); let service = configured.compile().unwrap(); for _ in 0..2 { let response = serve_request(&service, &request(Method::GET, "/"), "/").await; assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.headers()[CACHE_CONTROL], "no-store"); let body = response.into_body().collect().await.unwrap().to_bytes(); let body = std::str::from_utf8(&body).unwrap(); assert!(body.contains("first.txt")); assert!(body.contains("second.txt")); } } #[tokio::test] async fn index_precedes_listing_and_missing_index_falls_back_to_listing_per_directory() { let tmp = TempDir::new().unwrap(); std::fs::create_dir(tmp.path().join("with-index")).unwrap(); std::fs::create_dir(tmp.path().join("without-index")).unwrap(); std::fs::write(tmp.path().join("with-index/index.html"), b"INDEX-WINS").unwrap(); std::fs::write(tmp.path().join("with-index/other.txt"), b"hidden by index").unwrap(); std::fs::write(tmp.path().join("without-index/listed.txt"), b"listed").unwrap(); let service = service(tmp.path(), true); let indexed = serve_request( &service, &request(Method::GET, "/with-index/"), "/with-index/", ) .await; assert_eq!(indexed.status(), StatusCode::OK); assert_eq!( indexed.into_body().collect().await.unwrap().to_bytes(), "INDEX-WINS" ); let listed = serve_request( &service, &request(Method::GET, "/without-index/"), "/without-index/", ) .await; assert_eq!(listed.status(), StatusCode::OK); let body = listed.into_body().collect().await.unwrap().to_bytes(); let body = std::str::from_utf8(&body).unwrap(); assert!(body.contains("listed.txt")); assert!(body.contains("DIRECTORY LISTING")); } #[tokio::test] async fn mime_overrides_default_mime_and_cache_control_are_applied_to_files() { let tmp = TempDir::new().unwrap(); std::fs::write(tmp.path().join("asset.foo"), b"foo").unwrap(); std::fs::write(tmp.path().join("asset.unknown"), b"unknown").unwrap(); let mut configured = Service { id: "mime".into(), name: "MIME".into(), document_root: tmp.path().to_path_buf(), mime_types: BTreeMap::from([(".FOO".into(), "application/x-foo".into())]), default_mime_type: "application/x-fallback".into(), cache_control: Some("public, max-age=3600".into()), ..Default::default() }; configured.validate().unwrap(); assert!(configured.mime_types.contains_key("foo")); let service = configured.compile().unwrap(); let custom = serve_request(&service, &request(Method::GET, "/asset.foo"), "/asset.foo").await; assert_eq!(custom.headers()[CONTENT_TYPE], "application/x-foo"); assert_eq!(custom.headers()[CACHE_CONTROL], "public, max-age=3600"); let fallback = serve_request( &service, &request(Method::GET, "/asset.unknown"), "/asset.unknown", ) .await; assert_eq!(fallback.headers()[CONTENT_TYPE], "application/x-fallback"); assert_eq!(fallback.headers()[CACHE_CONTROL], "public, max-age=3600"); } #[tokio::test] async fn try_files_rewrites_cache_rules_headers_and_downloads_work() { let tmp = TempDir::new().unwrap(); std::fs::write(tmp.path().join("index.html"), b"SPA").unwrap(); std::fs::write(tmp.path().join("style.css"), b"css").unwrap(); std::fs::write(tmp.path().join("archive.zip"), b"zip").unwrap(); let mut configured = Service { id: "routing".into(), name: "Routing".into(), document_root: tmp.path().to_path_buf(), try_files: vec!["$uri".into(), "/index.html?$query_string".into()], rewrites: vec![ RewriteRule { pattern: "^/internal$".into(), replacement: "/style.css".into(), status: None, }, RewriteRule { pattern: "^/old/(.*)$".into(), replacement: "/new/$1".into(), status: Some(301), }, ], cache_control: Some("no-cache".into()), cache_rules: vec![CacheRule { matcher: "ext:css".into(), cache_control: "public, max-age=3600".into(), }], response_headers: BTreeMap::from([("x-static-service".into(), "yes".into())]), download_extensions: vec!["zip".into()], ..Default::default() }; configured.validate().unwrap(); let service = configured.compile().unwrap(); let spa = serve_request( &service, &request(Method::GET, "/missing?x=1"), "/missing", ) .await; assert_eq!(spa.status(), StatusCode::OK); assert_eq!(spa.headers()[CACHE_CONTROL], "no-cache"); assert_eq!(spa.headers()["x-static-service"], "yes"); assert_eq!(spa.into_body().collect().await.unwrap().to_bytes(), "SPA"); let internal = serve_request( &service, &request(Method::GET, "/internal"), "/internal", ) .await; assert_eq!(internal.status(), StatusCode::OK); assert_eq!(internal.headers()[CONTENT_TYPE], "text/css; charset=utf-8"); assert_eq!(internal.headers()[CACHE_CONTROL], "public, max-age=3600"); let redirect = serve_request( &service, &request(Method::GET, "/old/path"), "/old/path", ) .await; assert_eq!(redirect.status(), StatusCode::MOVED_PERMANENTLY); assert_eq!(redirect.headers()[LOCATION], "/new/path"); let download = serve_request( &service, &request(Method::GET, "/archive.zip"), "/archive.zip", ) .await; assert!(download.headers()[CONTENT_DISPOSITION] .to_str() .unwrap() .starts_with("attachment;")); } #[tokio::test] async fn precompressed_sidecars_keep_mime_validators_and_range_semantics() { let tmp = TempDir::new().unwrap(); std::fs::write(tmp.path().join("app.js"), b"ORIGINAL").unwrap(); std::fs::write(tmp.path().join("app.js.br"), b"BROTLI").unwrap(); let mut configured = Service { id: "compressed".into(), name: "Compressed".into(), document_root: tmp.path().to_path_buf(), precompressed: Precompressed { enabled: true, brotli: true, gzip: false, }, ..Default::default() }; configured.validate().unwrap(); let service = configured.compile().unwrap(); let mut accepted = request(Method::GET, "/app.js"); accepted .headers_mut() .insert(http::header::ACCEPT_ENCODING, "br".parse().unwrap()); let response = serve_request(&service, &accepted, "/app.js").await; assert_eq!(response.headers()[CONTENT_ENCODING], "br"); assert_eq!(response.headers()[CONTENT_TYPE], "text/javascript; charset=utf-8"); assert_eq!(response.headers()[VARY], "Accept-Encoding"); assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "BROTLI"); let mut ranged = request(Method::GET, "/app.js"); ranged .headers_mut() .insert(http::header::ACCEPT_ENCODING, "br".parse().unwrap()); ranged .headers_mut() .insert(RANGE, "bytes=0-2".parse().unwrap()); let response = serve_request(&service, &ranged, "/app.js").await; assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); assert_eq!(response.headers()[CONTENT_RANGE], "bytes 0-2/6"); assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "BRO"); let plain = serve_request(&service, &request(Method::GET, "/app.js"), "/app.js").await; assert!(!plain.headers().contains_key(CONTENT_ENCODING)); assert_eq!(plain.into_body().collect().await.unwrap().to_bytes(), "ORIGINAL"); }