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 super::*; 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" ); } async fn fastcgi_fixture(response_body: &'static [u8]) -> std::net::SocketAddr { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); tokio::spawn(async move { let (mut stream, _) = listener.accept().await.unwrap(); let deadline = Instant::now() + Duration::from_secs(5); loop { let (kind, content) = read_fcgi_record(&mut stream, deadline).await.unwrap(); if kind == FCGI_STDIN && content.is_empty() { break; } } let mut stdout = b"Status: 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\nX-PHP-FPM: fixture\r\n\r\n".to_vec(); stdout.extend_from_slice(response_body); write_fcgi_record(&mut stream, FCGI_STDOUT, &stdout) .await .unwrap(); write_fcgi_record(&mut stream, FCGI_STDOUT, &[]) .await .unwrap(); write_fcgi_record(&mut stream, FCGI_END_REQUEST, &[0u8; 8]) .await .unwrap(); stream.flush().await.unwrap(); }); address } #[tokio::test] async fn fastcgi_stream_can_be_polled_repeatedly_after_eof() { let (tx, rx) = mpsc::channel(1); tx.send(Ok(Bytes::from_static(b"data"))).await.unwrap(); drop(tx); let mut body = Box::pin(fastcgi_body_stream(rx)); assert_eq!( body.next().await.unwrap().unwrap(), Bytes::from_static(b"data") ); assert!(body.next().await.is_none()); // Without `fuse` this panics inside futures-util::stream::unfold. assert!(body.next().await.is_none()); } #[tokio::test] async fn php_fpm_gzip_response_finishes_after_fastcgi_eof() { use std::io::Read; let expected = b"FastCGI compressed response regression: 0123456789 0123456789 0123456789"; let tmp = TempDir::new().unwrap(); std::fs::write(tmp.path().join("index.php"), b"{}", env!("CARGO_PKG_VERSION")))); } #[tokio::test] async fn traversal_and_symlink_escape_are_not_served() { let tmp = TempDir::new().unwrap(); std::fs::write(tmp.path().join("ok.txt"), b"ok").unwrap(); let service = service(tmp.path(), false); assert_eq!( serve_request(&service, &request(Method::GET, "/../secret"), "/../secret") .await .status(), StatusCode::BAD_REQUEST ); #[cfg(unix)] { use std::os::unix::fs::symlink; let outside = TempDir::new().unwrap(); std::fs::write(outside.path().join("secret.txt"), b"secret").unwrap(); symlink( outside.path().join("secret.txt"), tmp.path().join("escape.txt"), ) .unwrap(); let response = serve_request( &service, &request(Method::GET, "/escape.txt"), "/escape.txt", ) .await; assert_ne!(response.status(), StatusCode::OK); } } #[tokio::test] async fn htaccess_wordpress_style_front_controller_and_protected_files_work() { let tmp = TempDir::new().unwrap(); std::fs::write(tmp.path().join("index.html"), b"front-controller").unwrap(); std::fs::write(tmp.path().join("existing.txt"), b"existing").unwrap(); std::fs::write( tmp.path().join(".htaccess"), br#" RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule . /index.html [L] "#, ) .unwrap(); let mut configured = Service { id: "htaccess-rewrite".into(), name: "htaccess rewrite".into(), document_root: tmp.path().to_path_buf(), htaccess: htaccess::Config { enabled: true, ..Default::default() }, ..Default::default() }; configured.validate().unwrap(); let service = configured.compile().unwrap(); let rewritten = serve_request(&service, &request(Method::GET, "/missing"), "/missing").await; assert_eq!(rewritten.status(), StatusCode::OK); assert_eq!( rewritten.into_body().collect().await.unwrap().to_bytes(), "front-controller" ); let existing = serve_request( &service, &request(Method::GET, "/existing.txt"), "/existing.txt", ) .await; assert_eq!(existing.status(), StatusCode::OK); assert_eq!( existing.into_body().collect().await.unwrap().to_bytes(), "existing" ); let protected = serve_request(&service, &request(Method::GET, "/.htaccess"), "/.htaccess").await; assert_eq!(protected.status(), StatusCode::FORBIDDEN); } #[tokio::test] async fn htaccess_directory_index_mime_headers_expires_and_nested_scope_work() { let tmp = TempDir::new().unwrap(); std::fs::create_dir(tmp.path().join("docs")).unwrap(); std::fs::write(tmp.path().join("docs/home.foo"), b"nested").unwrap(); std::fs::write( tmp.path().join("docs/.htaccess"), br#"DirectoryIndex home.foo Options -Indexes AddType text/x-foo .foo Header always set X-Htaccess yes ExpiresActive On ExpiresByType text/x-foo "access plus 1 hour" "#, ) .unwrap(); let mut configured = Service { id: "htaccess-nested".into(), name: "htaccess nested".into(), document_root: tmp.path().to_path_buf(), index: vec!["index.html".into()], directory_listing: true, htaccess: htaccess::Config { enabled: true, ..Default::default() }, ..Default::default() }; configured.validate().unwrap(); let service = configured.compile().unwrap(); let response = serve_request(&service, &request(Method::GET, "/docs/"), "/docs/").await; assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.headers()[CONTENT_TYPE], "text/x-foo"); assert_eq!(response.headers()["x-htaccess"], "yes"); assert_eq!(response.headers()[CACHE_CONTROL], "public, max-age=3600"); assert!(response.headers().contains_key(http::header::EXPIRES)); assert_eq!( response.into_body().collect().await.unwrap().to_bytes(), "nested" ); } #[tokio::test] async fn php_source_extensions_are_not_served_without_php_fpm() { let tmp = TempDir::new().unwrap(); std::fs::write(tmp.path().join("index.php"), b" SetHandler application/x-httpd-php "#, ) .unwrap(); let mut configured = Service { id: "htaccess-filesmatch".into(), name: "htaccess filesmatch".into(), document_root: tmp.path().to_path_buf(), htaccess: htaccess::Config { enabled: true, strict: false, ..Default::default() }, ..Default::default() }; configured.validate().unwrap(); let error = configured.compile().unwrap_err().to_string(); assert!(error.contains("Files/FilesMatch")); } #[test] fn htaccess_strict_mode_rejects_unsupported_directives() { let tmp = TempDir::new().unwrap(); std::fs::write(tmp.path().join("index.html"), b"ok").unwrap(); std::fs::write( tmp.path().join(".htaccess"), b"ErrorDocument 404 /404.html\n", ) .unwrap(); let mut configured = Service { id: "htaccess-strict".into(), name: "htaccess strict".into(), document_root: tmp.path().to_path_buf(), htaccess: htaccess::Config { enabled: true, strict: true, ..Default::default() }, ..Default::default() }; configured.validate().unwrap(); assert!(configured .compile() .unwrap_err() .to_string() .contains("ErrorDocument")); } #[cfg(unix)] #[test] fn htaccess_configuration_file_cannot_be_a_symlink() { use std::os::unix::fs::symlink; let tmp = TempDir::new().unwrap(); let outside = TempDir::new().unwrap(); std::fs::write(outside.path().join("outside.conf"), b"RewriteEngine On\n").unwrap(); symlink( outside.path().join("outside.conf"), tmp.path().join(".htaccess"), ) .unwrap(); let mut configured = Service { id: "htaccess-symlink".into(), name: "htaccess symlink".into(), document_root: tmp.path().to_path_buf(), htaccess: htaccess::Config { enabled: true, ..Default::default() }, ..Default::default() }; configured.validate().unwrap(); let error = configured.compile().unwrap_err().to_string(); assert!(error.contains("must not be a symlink")); } #[test] fn fastcgi_never_forwards_proxy_header_as_http_proxy() { let tmp = TempDir::new().unwrap(); let mut configured = Service { id: "fastcgi-params".into(), name: "FastCGI params".into(), document_root: tmp.path().to_path_buf(), php: Php { enabled: true, endpoint: "tcp:127.0.0.1:9000".into(), ..Default::default() }, ..Default::default() }; configured.validate().unwrap(); let service = configured.compile().unwrap(); let php = service.php.as_ref().unwrap(); let mut headers = HeaderMap::new(); headers.insert( "proxy", HeaderValue::from_static("http://attacker.invalid:8080"), ); headers.insert("user-agent", HeaderValue::from_static("security-test")); let uri: Uri = "/index.php?q=1".parse().unwrap(); let encoded = fastcgi_params( &service, php, &Method::GET, &headers, &uri, "/index.php", None, Some("q=1"), &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, }, ) .unwrap(); assert!(!encoded .windows(b"HTTP_PROXY".len()) .any(|window| window == b"HTTP_PROXY")); assert!(encoded .windows(b"HTTP_USER_AGENT".len()) .any(|window| window == b"HTTP_USER_AGENT")); } #[test] fn php_custom_fastcgi_params_and_php_values_are_validated() { let tmp = TempDir::new().unwrap(); let mut configured = Service { id: "php-custom".into(), name: "PHP custom".into(), document_root: tmp.path().to_path_buf(), php: Php { enabled: true, endpoint: "tcp:127.0.0.1:9000".into(), pass_authorization: false, custom_params: BTreeMap::from([("app_env".into(), "production".into())]), php_values: BTreeMap::from([("memory_limit".into(), "256M".into())]), ..Default::default() }, ..Default::default() }; configured.validate().unwrap(); assert!(configured.php.custom_params.contains_key("APP_ENV")); let service = configured.compile().unwrap(); let php = service.php.as_ref().unwrap(); let mut headers = HeaderMap::new(); headers.insert("authorization", HeaderValue::from_static("Bearer secret")); let uri: Uri = "/index.php".parse().unwrap(); let encoded = fastcgi_params( &service, php, &Method::GET, &headers, &uri, "/index.php", None, None, &RequestContext { client_ip: "127.0.0.1".parse().unwrap(), client_port: None, server_name: "example.test".into(), server_port: 443, tls: true, version: Version::HTTP_2, }, ) .unwrap(); assert!(encoded .windows(b"APP_ENV".len()) .any(|window| window == b"APP_ENV")); assert!(encoded .windows(b"production".len()) .any(|window| window == b"production")); assert!(encoded .windows(b"PHP_VALUE".len()) .any(|window| window == b"PHP_VALUE")); assert!(encoded .windows(b"memory_limit=256M".len()) .any(|window| window == b"memory_limit=256M")); assert!(!encoded .windows(b"HTTP_AUTHORIZATION".len()) .any(|window| window == b"HTTP_AUTHORIZATION")); } #[test] fn fastcgi_path_info_keeps_script_filename_separate() { let tmp = TempDir::new().unwrap(); let mut configured = Service { id: "php-path-info".into(), name: "PHP PATH_INFO".into(), document_root: tmp.path().to_path_buf(), php: Php { enabled: true, path_info: true, endpoint: "tcp:127.0.0.1:9000".into(), ..Default::default() }, ..Default::default() }; configured.validate().unwrap(); let service = configured.compile().unwrap(); assert!(service.php.as_ref().unwrap().path_info); let uri: Uri = "/index.php/foo/bar?q=1".parse().unwrap(); let encoded = fastcgi_params( &service, service.php.as_ref().unwrap(), &Method::GET, &HeaderMap::new(), &uri, "/index.php", Some("/foo/bar"), Some("q=1"), &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, }, ) .unwrap(); assert!(encoded .windows(b"PATH_INFO".len()) .any(|window| window == b"PATH_INFO")); assert!(encoded .windows(b"/foo/bar".len()) .any(|window| window == b"/foo/bar")); assert!(encoded .windows(b"SCRIPT_FILENAME".len()) .any(|window| window == b"SCRIPT_FILENAME")); } #[test] fn php_custom_fastcgi_params_cannot_override_core_or_php_admin_values() { let tmp = TempDir::new().unwrap(); for name in [ "SCRIPT_FILENAME", "HTTP_HOST", "PHP_VALUE", "PHP_ADMIN_VALUE", ] { let mut configured = Service { id: "php-reserved".into(), name: "PHP reserved".into(), document_root: tmp.path().to_path_buf(), php: Php { custom_params: BTreeMap::from([(name.into(), "bad".into())]), ..Default::default() }, ..Default::default() }; assert!(configured .validate() .unwrap_err() .to_string() .contains("reserved name")); } } #[test] fn php_custom_fastcgi_data_has_a_combined_bound() { let tmp = TempDir::new().unwrap(); let custom_params = (0..17) .map(|index| (format!("APP_{index}"), "x".repeat(8192))) .collect(); let mut configured = Service { id: "php-custom-bound".into(), name: "PHP custom bound".into(), document_root: tmp.path().to_path_buf(), php: Php { custom_params, ..Default::default() }, ..Default::default() }; assert!(configured .validate() .unwrap_err() .to_string() .contains("at most 128 KiB")); } #[test] fn fastcgi_response_cannot_control_downstream_framing_or_hop_headers() { let (_, headers) = parse_cgi_headers( b"Content-Type: text/plain\r\nContent-Length: 999999\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\nTE: trailers\r\nTrailer: X-Test\r\nProxy-Authenticate: Basic realm=x\r\nProxy-Authorization: secret\r\nX-App: ok\r\n", ) .unwrap(); assert_eq!(headers.get("x-app").unwrap(), "ok"); for name in [ "content-length", "transfer-encoding", "connection", "te", "trailer", "proxy-authenticate", "proxy-authorization", ] { assert!( !headers.contains_key(name), "unexpected FastCGI framing header: {name}" ); } } #[test] fn fastcgi_response_rejects_informational_status_codes() { let error = parse_cgi_headers(b"Status: 101 Switching Protocols\r\nContent-Type: text/plain\r\n") .unwrap_err() .to_string(); assert!(error.contains("unsupported informational/invalid status")); } #[tokio::test] async fn hardening_blocks_common_probe_paths_and_trace() { let tmp = TempDir::new().unwrap(); std::fs::write(tmp.path().join("index.html"), b"ok").unwrap(); std::fs::write(tmp.path().join(".env"), b"secret").unwrap(); std::fs::write(tmp.path().join("backup.sql"), b"secret").unwrap(); std::fs::write(tmp.path().join("public.txt"), b"public").unwrap(); let mut configured = Service { id: "hardened".into(), name: "hardened".into(), document_root: tmp.path().to_path_buf(), index: vec![], directory_listing: true, hardening: Hardening { block_common_attacks: true, strip_unnecessary_headers: false, }, ..Default::default() }; configured.validate().unwrap(); let service = configured.compile().unwrap(); for path in [ "/.env", "/wp-config.php", "/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php", "/backup.sql", ] { let response = serve_request(&service, &request(Method::GET, path), path).await; assert_eq!(response.status(), StatusCode::FORBIDDEN, "{path}"); } let trace = serve_request(&service, &request(Method::TRACE, "/"), "/").await; assert_eq!(trace.status(), StatusCode::FORBIDDEN); let listing = serve_request(&service, &request(Method::GET, "/"), "/").await; let listing = String::from_utf8_lossy(&listing.into_body().collect().await.unwrap().to_bytes()) .to_string(); assert!(listing.contains("public.txt")); assert!(!listing.contains(".env")); assert!(!listing.contains("backup.sql")); let well_known = serve_request( &service, &request(Method::GET, "/.well-known/missing"), "/.well-known/missing", ) .await; assert_eq!(well_known.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn hardening_strips_fingerprint_headers_after_source_rules() { let tmp = TempDir::new().unwrap(); std::fs::write(tmp.path().join("index.html"), b"ok").unwrap(); let mut configured = Service { id: "headers".into(), name: "headers".into(), document_root: tmp.path().to_path_buf(), response_headers: BTreeMap::from([ ("server".into(), "Example/1.0".into()), ("x-powered-by".into(), "PHP/8.4".into()), ("x-custom".into(), "kept".into()), ]), hardening: Hardening { block_common_attacks: false, strip_unnecessary_headers: true, }, ..Default::default() }; configured.validate().unwrap(); let service = configured.compile().unwrap(); let response = serve_request( &service, &request(Method::GET, "/index.html"), "/index.html", ) .await; assert_eq!(response.status(), StatusCode::OK); assert!(!response.headers().contains_key("server")); assert!(!response.headers().contains_key("x-powered-by")); assert_eq!(response.headers()["x-custom"], "kept"); } #[test] fn php_admin_values_are_operator_only_and_serialized() { let tmp = TempDir::new().unwrap(); let mut configured = Service { id: "php-admin".into(), name: "PHP admin".into(), document_root: tmp.path().to_path_buf(), php: Php { enabled: true, endpoint: "tcp:127.0.0.1:9000".into(), php_admin_values: BTreeMap::from([( "open_basedir".into(), "/var/www/webmail/:/tmp/".into(), )]), ..Default::default() }, ..Default::default() }; configured.validate().unwrap(); let service = configured.compile().unwrap(); let encoded = fastcgi_params( &service, service.php.as_ref().unwrap(), &Method::GET, &HeaderMap::new(), &"/index.php".parse().unwrap(), "/index.php", None, None, &RequestContext { client_ip: "127.0.0.1".parse().unwrap(), client_port: None, server_name: "webmail.example.test".into(), server_port: 8589, tls: false, version: Version::HTTP_11, }, ) .unwrap(); assert!(encoded .windows(b"PHP_ADMIN_VALUE".len()) .any(|w| w == b"PHP_ADMIN_VALUE")); assert!(encoded .windows(b"open_basedir=/var/www/webmail/:/tmp/".len()) .any(|w| w == b"open_basedir=/var/www/webmail/:/tmp/")); configured .php .php_values .insert("open_basedir".into(), "/".into()); assert!(configured.validate().is_err()); configured.php.php_values.clear(); configured .php .php_admin_values .insert("unsafe".into(), "value\nother=1".into()); assert!(configured.validate().is_err()); } #[test] fn fastcgi_always_sets_http_host_for_http2_http3_wordpress_requests() { let tmp = TempDir::new().unwrap(); let mut configured = Service { id: "host-fallback".into(), name: "Host fallback".into(), document_root: tmp.path().to_path_buf(), php: Php { enabled: true, endpoint: "tcp:127.0.0.1:9000".into(), ..Default::default() }, ..Default::default() }; configured.validate().unwrap(); let service = configured.compile().unwrap(); let php = service.php.as_ref().unwrap(); let uri: Uri = "/wp-admin/index.php".parse().unwrap(); for version in [Version::HTTP_2, Version::HTTP_3] { let encoded = fastcgi_params( &service, php, &Method::GET, &HeaderMap::new(), &uri, "/wp-admin/index.php", None, None, &RequestContext { client_ip: "127.0.0.1".parse().unwrap(), client_port: Some(12345), server_name: "example.test".into(), server_port: 443, tls: true, version, }, ) .unwrap(); assert!(encoded .windows(b"HTTP_HOST".len()) .any(|w| w == b"HTTP_HOST")); assert!(encoded .windows(b"example.test".len()) .any(|w| w == b"example.test")); } } #[test] fn fastcgi_http_host_keeps_explicit_host_and_port() { let tmp = TempDir::new().unwrap(); let mut configured = Service { id: "host-port".into(), name: "Host port".into(), document_root: tmp.path().to_path_buf(), php: Php { enabled: true, endpoint: "tcp:127.0.0.1:9000".into(), ..Default::default() }, ..Default::default() }; configured.validate().unwrap(); let service = configured.compile().unwrap(); let mut headers = HeaderMap::new(); headers.insert( http::header::HOST, HeaderValue::from_static("example.test:8443"), ); let encoded = fastcgi_params( &service, service.php.as_ref().unwrap(), &Method::GET, &headers, &"/index.php".parse().unwrap(), "/index.php", None, None, &RequestContext { client_ip: "127.0.0.1".parse().unwrap(), client_port: None, server_name: "example.test".into(), server_port: 443, tls: true, version: Version::HTTP_11, }, ) .unwrap(); assert!(encoded .windows(b"HTTP_HOST".len()) .any(|w| w == b"HTTP_HOST")); assert!(encoded .windows(b"example.test:8443".len()) .any(|w| w == b"example.test:8443")); } // Decode the actual FastCGI PARAMS records received by a mock PHP-FPM peer. // This tests the Web Service dispatch and parameter generation together. fn decode_fastcgi_params(mut bytes: &[u8]) -> BTreeMap { fn read_len(bytes: &mut &[u8]) -> usize { let prefix = bytes[0]; if prefix & 0x80 == 0 { *bytes = &bytes[1..]; prefix as usize } else { let value = u32::from_be_bytes(bytes[..4].try_into().unwrap()) & 0x7fff_ffff; *bytes = &bytes[4..]; value as usize } } let mut result = BTreeMap::new(); while !bytes.is_empty() { let name_len = read_len(&mut bytes); let value_len = read_len(&mut bytes); let name = std::str::from_utf8(&bytes[..name_len]).unwrap().to_owned(); let value = std::str::from_utf8(&bytes[name_len..name_len + value_len]) .unwrap() .to_owned(); bytes = &bytes[name_len + value_len..]; result.insert(name, value); } result } async fn fastcgi_fixture_record_params() -> ( std::net::SocketAddr, tokio::sync::oneshot::Receiver>, ) { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); let (tx, rx) = tokio::sync::oneshot::channel(); tokio::spawn(async move { let (mut stream, _) = listener.accept().await.unwrap(); let deadline = Instant::now() + Duration::from_secs(5); let mut params = Vec::new(); loop { let (kind, content) = read_fcgi_record(&mut stream, deadline).await.unwrap(); if kind == FCGI_PARAMS { params.extend_from_slice(&content); } if kind == FCGI_STDIN && content.is_empty() { break; } } let _ = tx.send(decode_fastcgi_params(¶ms)); write_fcgi_record( &mut stream, FCGI_STDOUT, b"Status: 200 OK\r\nContent-Type: text/css\r\n\r\nPHP-STATIC-OK", ) .await .unwrap(); write_fcgi_record(&mut stream, FCGI_STDOUT, &[]) .await .unwrap(); write_fcgi_record(&mut stream, FCGI_END_REQUEST, &[0u8; 8]) .await .unwrap(); stream.flush().await.unwrap(); }); (address, rx) } #[tokio::test] async fn roundcube_static_php_path_info_runs_before_front_controller_try_files() { let tmp = TempDir::new().unwrap(); std::fs::write( tmp.path().join("static.php"), b" other assert_eq!(view["selected_status"][0], 2); // 200 assert_eq!(view["selected_status"][4], 1); // 404 assert_eq!(view["response_bytes"], 5 + missing_bytes as u64); assert_eq!(view["active_headers"], 0); } #[test] fn web_header_gauge_is_released_when_request_scope_exits() { let counter = std::sync::atomic::AtomicU64::new(0); { let _first = crate::web_server::metrics::HeadersInFlight::new(&counter); let _second = crate::web_server::metrics::HeadersInFlight::new(&counter); assert_eq!(counter.load(std::sync::atomic::Ordering::Relaxed), 2); } assert_eq!(counter.load(std::sync::atomic::Ordering::Relaxed), 0); } #[tokio::test] async fn php_metrics_are_separate_from_static_and_record_capacity() { let tmp = TempDir::new().unwrap(); std::fs::write(tmp.path().join("index.php"), b" = (0..length).map(|index| (index % 251) as u8).collect(); std::fs::write(tmp.path().join("asset.bin"), &expected).unwrap(); let response = serve_request(&service, &request(Method::GET, "/asset.bin"), "/asset.bin").await; assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.headers()[CONTENT_LENGTH], length.to_string()); let mut body = response.into_body(); let mut received = Vec::new(); let mut frames = 0usize; while let Some(frame) = body.frame().await { let data = frame.unwrap().into_data().unwrap(); assert!( data.len() <= 64 * 1024, "static frame exceeded the memory bound" ); received.extend_from_slice(&data); frames += 1; } assert_eq!(received, expected); if length >= 64 * 1024 { // A normal regular file must not regress to one dispatched read and // body-accounting pass for every 4 KiB of its representation. assert!(frames < length.div_ceil(4 * 1024)); } accounted += length as u64; assert_eq!(service.web_metrics.view()["response_bytes"], accounted); } } #[tokio::test] async fn large_static_ranges_keep_offsets_validators_and_head_semantics() { let tmp = TempDir::new().unwrap(); let expected: Vec = (0..256 * 1024 + 31) .map(|index| (index % 251) as u8) .collect(); std::fs::write(tmp.path().join("asset.bin"), &expected).unwrap(); let service = service(tmp.path(), false); let initial = serve_request(&service, &request(Method::HEAD, "/asset.bin"), "/asset.bin").await; let etag = initial.headers()[ETAG].clone(); assert!(initial .into_body() .collect() .await .unwrap() .to_bytes() .is_empty()); for (range, start, end) in [ ("bytes=0-0", 0, 1), ("bytes=65530-131089", 65530, 131090), ("bytes=-65543", expected.len() - 65543, expected.len()), ("bytes=131065-", 131065, expected.len()), ] { for method in [Method::GET, Method::HEAD] { let mut requested = request(method.clone(), "/asset.bin"); requested .headers_mut() .insert(RANGE, range.parse().unwrap()); requested.headers_mut().insert(IF_RANGE, etag.clone()); let response = serve_request(&service, &requested, "/asset.bin").await; assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); assert_eq!( response.headers()[CONTENT_LENGTH], (end - start).to_string() ); assert_eq!( response.headers()[CONTENT_RANGE], format!("bytes {start}-{}/{}", end - 1, expected.len()) ); assert_eq!(response.headers()[ETAG], etag); let data = response.into_body().collect().await.unwrap().to_bytes(); if method == Method::HEAD { assert!(data.is_empty()); } else { assert_eq!(&data[..], &expected[start..end]); } } } let mut changed = request(Method::GET, "/asset.bin"); changed .headers_mut() .insert(RANGE, "bytes=65530-131089".parse().unwrap()); changed .headers_mut() .insert(IF_RANGE, "\"changed\"".parse().unwrap()); let response = serve_request(&service, &changed, "/asset.bin").await; assert_eq!(response.status(), StatusCode::OK); assert_eq!( response.into_body().collect().await.unwrap().to_bytes(), expected ); let mut conditional = request(Method::GET, "/asset.bin"); conditional .headers_mut() .insert(RANGE, "bytes=65530-131089".parse().unwrap()); conditional.headers_mut().insert(IF_NONE_MATCH, etag); let response = serve_request(&service, &conditional, "/asset.bin").await; assert_eq!(response.status(), StatusCode::NOT_MODIFIED); assert!(response .into_body() .collect() .await .unwrap() .to_bytes() .is_empty()); let mut invalid = request(Method::GET, "/asset.bin"); invalid .headers_mut() .insert(RANGE, "bytes=999999-".parse().unwrap()); let response = serve_request(&service, &invalid, "/asset.bin").await; assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE); assert_eq!( response.headers()[CONTENT_RANGE], format!("bytes */{}", expected.len()) ); assert!(response .into_body() .collect() .await .unwrap() .to_bytes() .is_empty()); } #[tokio::test] async fn large_precompressed_and_multipart_representations_remain_exact() { use std::io::Write; let tmp = TempDir::new().unwrap(); let plain: Vec = (0..512 * 1024) .map(|index| b'a' + (index % 26) as u8) .collect(); let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::none()); encoder.write_all(&plain).unwrap(); let compressed = encoder.finish().unwrap(); std::fs::write(tmp.path().join("asset.css"), &plain).unwrap(); std::fs::write(tmp.path().join("asset.css.gz"), &compressed).unwrap(); let service = Service { id: "large-compressed".into(), name: "Large compressed".into(), document_root: tmp.path().to_path_buf(), precompressed: Precompressed { enabled: true, brotli: false, gzip: true, }, ..Default::default() } .compile() .unwrap(); let mut requested = request(Method::GET, "/asset.css"); requested .headers_mut() .insert(http::header::ACCEPT_ENCODING, "gzip".parse().unwrap()); let response = serve_request(&service, &requested, "/asset.css").await; assert_eq!(response.headers()[CONTENT_ENCODING], "gzip"); assert_eq!(response.headers()[CONTENT_TYPE], "text/css; charset=utf-8"); assert_eq!(response.headers()[VARY], "Accept-Encoding"); assert_eq!( response.headers()[CONTENT_LENGTH], compressed.len().to_string() ); let etag = response.headers()[ETAG].clone(); assert_eq!( response.into_body().collect().await.unwrap().to_bytes(), compressed ); *requested.method_mut() = Method::HEAD; let response = serve_request(&service, &requested, "/asset.css").await; assert_eq!(response.headers()[CONTENT_ENCODING], "gzip"); assert_eq!( response.headers()[CONTENT_LENGTH], compressed.len().to_string() ); assert!(response .into_body() .collect() .await .unwrap() .to_bytes() .is_empty()); *requested.method_mut() = Method::GET; requested.headers_mut().insert(IF_NONE_MATCH, etag); let response = serve_request(&service, &requested, "/asset.css").await; assert_eq!(response.status(), StatusCode::NOT_MODIFIED); assert_eq!(response.headers()[CONTENT_ENCODING], "gzip"); assert!(response .into_body() .collect() .await .unwrap() .to_bytes() .is_empty()); requested.headers_mut().remove(IF_NONE_MATCH); requested .headers_mut() .insert(RANGE, "bytes=65530-131089".parse().unwrap()); let response = serve_request(&service, &requested, "/asset.css").await; assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); assert_eq!(response.headers()[CONTENT_ENCODING], "gzip"); assert_eq!( response.into_body().collect().await.unwrap().to_bytes(), compressed[65530..131090] ); requested .headers_mut() .insert(RANGE, "bytes=65520-131124,262100-458800".parse().unwrap()); let response = serve_request(&service, &requested, "/asset.css").await; assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); assert!(!response.headers().contains_key(CONTENT_ENCODING)); let content_type = response.headers()[CONTENT_TYPE].to_str().unwrap(); let boundary = content_type .strip_prefix("multipart/byteranges; boundary=") .unwrap(); let mut expected = Vec::new(); for (start, end) in [(65520, 131124), (262100, 458800)] { write!(&mut expected, "--{boundary}\r\nContent-Type: text/css; charset=utf-8\r\nContent-Range: bytes {start}-{end}/{}\r\n\r\n", plain.len()).unwrap(); expected.extend_from_slice(&plain[start..=end]); expected.extend_from_slice(b"\r\n"); } write!(&mut expected, "--{boundary}--\r\n").unwrap(); assert_eq!( response.headers()[CONTENT_LENGTH], expected.len().to_string() ); let multipart_length = expected.len(); assert_eq!( response.into_body().collect().await.unwrap().to_bytes(), expected ); *requested.method_mut() = Method::HEAD; let response = serve_request(&service, &requested, "/asset.css").await; assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); assert!(!response.headers().contains_key(CONTENT_ENCODING)); assert_eq!( response.headers()[CONTENT_LENGTH], multipart_length.to_string() ); assert!(response .into_body() .collect() .await .unwrap() .to_bytes() .is_empty()); } #[cfg(unix)] #[test] fn filesystem_context_owns_root_and_preserves_independent_directory_offsets() { use std::io::Read; let tmp = TempDir::new().unwrap(); std::fs::create_dir(tmp.path().join("nested")).unwrap(); std::fs::write(tmp.path().join("top.txt"), b"top").unwrap(); std::fs::write(tmp.path().join("nested/file.txt"), b"nested").unwrap(); let service = service(tmp.path(), true); let root = filesystem::FilesystemRoot::for_service(&service); drop(service); for _ in 0..3 { let components = filesystem::path_components("/nested/file.txt").unwrap(); let mut opened = filesystem::secure_open_sync(&root, &components).unwrap(); let mut contents = String::new(); opened.file.read_to_string(&mut contents).unwrap(); assert_eq!(contents, "nested"); let entries = filesystem::list_directory_sync(&root, &[]).unwrap(); assert_eq!( entries .iter() .map(|entry| entry.name.as_str()) .collect::>(), ["nested", "top.txt"] ); let entries = filesystem::list_directory_sync(&root, &["nested".into()]).unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].name, "file.txt"); } } #[cfg(target_os = "linux")] #[tokio::test] async fn filesystem_context_keeps_symlink_policy_on_intermediate_components() { use std::os::unix::fs::symlink; let tmp = TempDir::new().unwrap(); let outside = TempDir::new().unwrap(); std::fs::create_dir(tmp.path().join("safe")).unwrap(); std::fs::write(tmp.path().join("safe/asset.txt"), b"safe").unwrap(); std::fs::write(outside.path().join("asset.txt"), b"secret").unwrap(); symlink(tmp.path().join("safe"), tmp.path().join("inside-link")).unwrap(); symlink(outside.path(), tmp.path().join("outside-link")).unwrap(); for policy in [SymlinkPolicy::Deny, SymlinkPolicy::InsideRoot] { let service = Service { id: "links".into(), name: "Links".into(), document_root: tmp.path().to_path_buf(), directory_listing: true, symlink_policy: policy, ..Default::default() } .compile() .unwrap(); let response = serve_request( &service, &request(Method::GET, "/inside-link/asset.txt"), "/inside-link/asset.txt", ) .await; if policy == SymlinkPolicy::InsideRoot { assert_eq!(response.status(), StatusCode::OK); assert_eq!( response.into_body().collect().await.unwrap().to_bytes(), "safe" ); } else { assert_ne!(response.status(), StatusCode::OK); } let response = serve_request( &service, &request(Method::GET, "/outside-link/asset.txt"), "/outside-link/asset.txt", ) .await; assert_ne!(response.status(), StatusCode::OK); assert!(!response .into_body() .collect() .await .unwrap() .to_bytes() .windows(6) .any(|part| part == b"secret")); for _ in 0..3 { let response = serve_request(&service, &request(Method::GET, "/"), "/").await; assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); let listing = std::str::from_utf8(&body).unwrap(); assert!(listing.contains("safe/")); assert!(!listing.contains("outside-link")); assert_eq!( listing.contains("inside-link"), policy == SymlinkPolicy::InsideRoot ); } } } #[cfg(unix)] #[tokio::test] async fn borrowed_root_descriptor_stays_anchored_after_root_path_replacement() { let tmp = TempDir::new().unwrap(); let configured_root = tmp.path().join("site"); std::fs::create_dir_all(configured_root.join("nested")).unwrap(); std::fs::write(configured_root.join("nested/asset.txt"), b"original").unwrap(); let service = service(&configured_root, false); std::fs::rename(&configured_root, tmp.path().join("moved-site")).unwrap(); std::fs::create_dir_all(configured_root.join("nested")).unwrap(); std::fs::write( configured_root.join("nested/asset.txt"), b"replacement-secret", ) .unwrap(); for _ in 0..3 { let response = serve_request( &service, &request(Method::GET, "/nested/asset.txt"), "/nested/asset.txt", ) .await; assert_eq!(response.status(), StatusCode::OK); assert_eq!( response.into_body().collect().await.unwrap().to_bytes(), "original" ); } } #[cfg(target_os = "linux")] fn open_descriptors_for(path: &Path) -> usize { std::fs::read_dir("/proc/self/fd") .unwrap() .filter_map(|entry| std::fs::read_link(entry.ok()?.path()).ok()) .filter(|target| target == path) .count() } #[cfg(target_os = "linux")] async fn wait_for_file_descriptors_to_close(path: &Path) { tokio::time::timeout(Duration::from_secs(5), async { while open_descriptors_for(path) != 0 { tokio::time::sleep(Duration::from_millis(10)).await; } }) .await .expect("cancelled static response retained its file descriptor"); } #[cfg(target_os = "linux")] #[tokio::test] async fn cancelled_static_and_multipart_bodies_release_files_and_count_only_emitted_bytes() { let tmp = TempDir::new().unwrap(); let path = tmp.path().join("asset.bin"); std::fs::write(&path, vec![b'x'; 1024 * 1024]).unwrap(); let service = service(tmp.path(), false); let unpolled = serve_request(&service, &request(Method::GET, "/asset.bin"), "/asset.bin").await; drop(unpolled); wait_for_file_descriptors_to_close(&path).await; assert_eq!(service.web_metrics.view()["response_bytes"], 0); let mut accounted = 0u64; for range in [None, Some("bytes=0-400000,600000-1000000")] { let mut requested = request(Method::GET, "/asset.bin"); if let Some(range) = range { requested .headers_mut() .insert(RANGE, range.parse().unwrap()); } let mut body = serve_request(&service, &requested, "/asset.bin") .await .into_body(); let first = body.frame().await.unwrap().unwrap().into_data().unwrap(); assert!(!first.is_empty()); assert!(first.len() <= 64 * 1024); accounted += first.len() as u64; assert_eq!(service.web_metrics.view()["response_bytes"], accounted); // The multipart writer may fill its bounded duplex buffer in the // background. Neither it nor a pending file read counts unsent bytes. tokio::task::yield_now().await; assert_eq!(service.web_metrics.view()["response_bytes"], accounted); drop(body); wait_for_file_descriptors_to_close(&path).await; assert_eq!(service.web_metrics.view()["response_bytes"], accounted); } } #[cfg(target_os = "linux")] #[tokio::test] async fn sendfile_auto_without_tcp_bridge_preserves_full_head_range_and_validators() { let tmp = TempDir::new().unwrap(); let original = vec![0x5au8; 128 * 1024 + 3]; std::fs::write(tmp.path().join("large.bin"), &original).unwrap(); let service = Service { id: "sendfile-fallback".into(), name: "Sendfile fallback".into(), document_root: tmp.path().to_path_buf(), sendfile: SendfileMode::Auto, ..Default::default() }.compile().unwrap(); let get = serve_request(&service, &request(Method::GET, "/large.bin"), "/large.bin").await; assert_eq!(get.status(), StatusCode::OK); assert_eq!(get.headers()[CONTENT_LENGTH].to_str().unwrap(), original.len().to_string()); // No transport adapter here. Even if a candidate extension was produced, // its fallback Body must still stream the exact original representation. let etag = get.headers()[ETAG].clone(); assert_eq!(get.into_body().collect().await.unwrap().to_bytes().to_vec(), original); let head = serve_request(&service, &request(Method::HEAD, "/large.bin"), "/large.bin").await; assert_eq!(head.status(), StatusCode::OK); assert_eq!(head.headers()[CONTENT_LENGTH].to_str().unwrap(), original.len().to_string()); assert!(head.into_body().collect().await.unwrap().to_bytes().is_empty()); let mut range = request(Method::GET, "/large.bin"); range.headers_mut().insert(RANGE, HeaderValue::from_static("bytes=100-199")); let ranged = serve_request(&service, &range, "/large.bin").await; assert_eq!(ranged.status(), StatusCode::PARTIAL_CONTENT); assert_eq!(ranged.headers()[CONTENT_RANGE].to_str().unwrap(), format!("bytes 100-199/{}", original.len())); assert_eq!(ranged.into_body().collect().await.unwrap().to_bytes().to_vec(), original[100..200]); let mut conditional = request(Method::GET, "/large.bin"); conditional.headers_mut().insert(IF_NONE_MATCH, etag); let unchanged = serve_request(&service, &conditional, "/large.bin").await; assert_eq!(unchanged.status(), StatusCode::NOT_MODIFIED); assert!(unchanged.into_body().collect().await.unwrap().to_bytes().is_empty()); } #[test] fn sendfile_mode_is_backward_compatible_and_rejects_unknown_values() { assert_eq!(SendfileMode::default(), SendfileMode::Off); assert_eq!(serde_json::from_str::("\"auto\"").unwrap(), SendfileMode::Auto); assert_eq!(serde_json::from_str::("\"off\"").unwrap(), SendfileMode::Off); assert!(serde_json::from_str::("\"unsafe\"").is_err()); let legacy = serde_json::json!({"id":"legacy", "name":"Legacy", "document_root":"/tmp"}); assert_eq!(serde_json::from_value::(legacy).unwrap().sendfile, SendfileMode::Off); }