2279 lines
80 KiB
Rust
2279 lines
80 KiB
Rust
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::<Vec<_>>()
|
|
.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"<?php echo 'placeholder';").unwrap();
|
|
let address = fastcgi_fixture(expected).await;
|
|
let mut configured = Service {
|
|
id: "php-compression-regression".into(),
|
|
name: "PHP compression regression".into(),
|
|
document_root: tmp.path().to_path_buf(),
|
|
index: vec!["index.php".into()],
|
|
php: Php {
|
|
enabled: true,
|
|
endpoint: format!("tcp:{address}"),
|
|
connect_timeout_ms: 500,
|
|
request_timeout_ms: 5_000,
|
|
max_concurrent_requests: 1,
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
};
|
|
configured.validate().unwrap();
|
|
let service = configured.compile().unwrap();
|
|
let response = serve_request(&service, &request(Method::GET, "/"), "/").await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
let mut accept = HeaderMap::new();
|
|
accept.insert("accept-encoding", "gzip".parse().unwrap());
|
|
let settings = crate::compression::Settings {
|
|
gzip: true,
|
|
br: false,
|
|
zstd: false,
|
|
deflate: false,
|
|
min_bytes: 32,
|
|
};
|
|
let response = crate::compression::encode(response, &accept, &Method::GET, &settings).await;
|
|
assert_eq!(response.headers()[CONTENT_ENCODING], "gzip");
|
|
|
|
let compressed = response.into_body().collect().await.unwrap().to_bytes();
|
|
let mut decoded = Vec::new();
|
|
flate2::read::GzDecoder::new(compressed.as_ref())
|
|
.read_to_end(&mut decoded)
|
|
.unwrap();
|
|
assert_eq!(decoded.as_slice(), expected);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn php_fpm_executes_index_and_try_files_front_controller_without_source_disclosure() {
|
|
let tmp = TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join("index.php"), b"<?php echo 'SOURCE';").unwrap();
|
|
let address = fastcgi_fixture(b"PHP-OK").await;
|
|
let mut configured = Service {
|
|
id: "php".into(),
|
|
name: "PHP".into(),
|
|
document_root: tmp.path().to_path_buf(),
|
|
index: vec!["index.php".into()],
|
|
try_files: vec!["$uri".into(), "/index.php?$query_string".into()],
|
|
php: Php {
|
|
enabled: true,
|
|
endpoint: format!("tcp:{address}"),
|
|
connect_timeout_ms: 500,
|
|
request_timeout_ms: 5_000,
|
|
max_concurrent_requests: 4,
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
};
|
|
configured.validate().unwrap();
|
|
let service = configured.compile().unwrap();
|
|
let response = serve_body_request(
|
|
&service,
|
|
&request(Method::POST, "/missing?name=test"),
|
|
"/missing",
|
|
Body::from("payload"),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
assert_eq!(response.headers()["x-php-fpm"], "fixture");
|
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
|
assert_eq!(body, "PHP-OK");
|
|
assert!(!std::str::from_utf8(&body).unwrap().contains("SOURCE"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn php_fpm_executes_configured_directory_index() {
|
|
let tmp = TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join("index.php"), b"<?php echo 'INDEX-SOURCE';").unwrap();
|
|
let address = fastcgi_fixture(b"PHP-INDEX").await;
|
|
let mut configured = Service {
|
|
id: "php-index".into(),
|
|
name: "PHP index".into(),
|
|
document_root: tmp.path().to_path_buf(),
|
|
index: vec!["index.php".into()],
|
|
php: Php {
|
|
enabled: true,
|
|
endpoint: format!("tcp:{address}"),
|
|
connect_timeout_ms: 500,
|
|
request_timeout_ms: 5_000,
|
|
max_concurrent_requests: 4,
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
};
|
|
configured.validate().unwrap();
|
|
let service = configured.compile().unwrap();
|
|
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();
|
|
assert_eq!(body, "PHP-INDEX");
|
|
assert!(!String::from_utf8_lossy(&body).contains("INDEX-SOURCE"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn php_request_body_limit_returns_413_before_fastcgi_connect() {
|
|
let tmp = TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join("index.php"), b"<?php echo 'secret';").unwrap();
|
|
let mut configured = Service {
|
|
id: "php-body-limit".into(),
|
|
name: "PHP body limit".into(),
|
|
document_root: tmp.path().to_path_buf(),
|
|
php: Php {
|
|
enabled: true,
|
|
endpoint: "tcp:127.0.0.1:9".into(),
|
|
max_request_body_bytes: 5,
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
};
|
|
configured.validate().unwrap();
|
|
let service = configured.compile().unwrap();
|
|
let mut req = request(Method::POST, "/index.php");
|
|
req.headers_mut()
|
|
.insert(CONTENT_LENGTH, HeaderValue::from_static("10"));
|
|
let response = serve_body_request(&service, &req, "/index.php", Body::from("0123456789")).await;
|
|
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn php_fpm_failure_returns_gateway_error_instead_of_php_source() {
|
|
let tmp = TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join("index.php"), b"SECRET-PHP-SOURCE").unwrap();
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let address = listener.local_addr().unwrap();
|
|
drop(listener);
|
|
let mut configured = Service {
|
|
id: "php-fail".into(),
|
|
name: "PHP fail".into(),
|
|
document_root: tmp.path().to_path_buf(),
|
|
php: Php {
|
|
enabled: true,
|
|
endpoint: format!("tcp:{address}"),
|
|
connect_timeout_ms: 100,
|
|
request_timeout_ms: 500,
|
|
max_concurrent_requests: 1,
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
};
|
|
configured.validate().unwrap();
|
|
let service = configured.compile().unwrap();
|
|
let response = serve_request(&service, &request(Method::GET, "/index.php"), "/index.php").await;
|
|
assert!(matches!(
|
|
response.status(),
|
|
StatusCode::BAD_GATEWAY | StatusCode::GATEWAY_TIMEOUT
|
|
));
|
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
|
assert!(!String::from_utf8_lossy(&body).contains("SECRET-PHP-SOURCE"));
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[tokio::test]
|
|
async fn inside_root_symlink_policy_allows_only_targets_beneath_root() {
|
|
use std::os::unix::fs::symlink;
|
|
let tmp = TempDir::new().unwrap();
|
|
let outside = TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join("inside.txt"), b"inside").unwrap();
|
|
std::fs::write(outside.path().join("outside.txt"), b"outside").unwrap();
|
|
symlink(
|
|
tmp.path().join("inside.txt"),
|
|
tmp.path().join("inside-link.txt"),
|
|
)
|
|
.unwrap();
|
|
symlink(
|
|
outside.path().join("outside.txt"),
|
|
tmp.path().join("outside-link.txt"),
|
|
)
|
|
.unwrap();
|
|
let mut configured = Service {
|
|
id: "links".into(),
|
|
name: "Links".into(),
|
|
document_root: tmp.path().to_path_buf(),
|
|
symlink_policy: SymlinkPolicy::InsideRoot,
|
|
..Default::default()
|
|
};
|
|
configured.validate().unwrap();
|
|
let service = configured.compile().unwrap();
|
|
|
|
let inside = serve_request(
|
|
&service,
|
|
&request(Method::GET, "/inside-link.txt"),
|
|
"/inside-link.txt",
|
|
)
|
|
.await;
|
|
assert_eq!(inside.status(), StatusCode::OK);
|
|
assert_eq!(
|
|
inside.into_body().collect().await.unwrap().to_bytes(),
|
|
"inside"
|
|
);
|
|
|
|
let outside = serve_request(
|
|
&service,
|
|
&request(Method::GET, "/outside-link.txt"),
|
|
"/outside-link.txt",
|
|
)
|
|
.await;
|
|
assert_eq!(outside.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn empty_index_can_go_directly_to_directory_listing() {
|
|
let tmp = TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join("file.txt"), b"x").unwrap();
|
|
let mut configured = Service {
|
|
id: "listing-only".into(),
|
|
name: "Listing only".into(),
|
|
document_root: tmp.path().to_path_buf(),
|
|
index: Vec::new(),
|
|
directory_listing: true,
|
|
..Default::default()
|
|
};
|
|
configured.validate().unwrap();
|
|
let service = configured.compile().unwrap();
|
|
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 body = std::str::from_utf8(&body).unwrap();
|
|
assert!(body.contains("file.txt"));
|
|
assert!(body.contains("DIRECTORY LISTING"));
|
|
assert!(body.contains(&format!("RustProxy <b>{}</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#"<IfModule mod_rewrite.c>
|
|
RewriteEngine On
|
|
RewriteCond %{REQUEST_FILENAME} !-f
|
|
RewriteCond %{REQUEST_FILENAME} !-d
|
|
RewriteRule . /index.html [L]
|
|
</IfModule>
|
|
"#,
|
|
)
|
|
.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"<?php echo 'secret';").unwrap();
|
|
let service = service(tmp.path(), false);
|
|
let response = serve_request(&service, &request(Method::GET, "/index.php"), "/index.php").await;
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
|
assert!(!String::from_utf8_lossy(&body).contains("secret"));
|
|
}
|
|
|
|
#[test]
|
|
fn htaccess_php_addhandler_requires_php_fpm() {
|
|
let tmp = TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join("index.phtml"), b"secret").unwrap();
|
|
std::fs::write(
|
|
tmp.path().join(".htaccess"),
|
|
b"AddHandler application/x-httpd-php .phtml\n",
|
|
)
|
|
.unwrap();
|
|
let mut configured = Service {
|
|
id: "htaccess-handler".into(),
|
|
name: "htaccess handler".into(),
|
|
document_root: tmp.path().to_path_buf(),
|
|
htaccess: htaccess::Config {
|
|
enabled: true,
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
};
|
|
configured.validate().unwrap();
|
|
assert!(configured
|
|
.compile()
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("requires PHP-FPM"));
|
|
}
|
|
|
|
#[test]
|
|
fn htaccess_security_sensitive_unsupported_directives_fail_closed() {
|
|
let tmp = TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join("index.php"), b"secret").unwrap();
|
|
std::fs::write(
|
|
tmp.path().join(".htaccess"),
|
|
br#"<FilesMatch "\.php$">
|
|
SetHandler application/x-httpd-php
|
|
</FilesMatch>
|
|
"#,
|
|
)
|
|
.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<String, String> {
|
|
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<BTreeMap<String, String>>,
|
|
) {
|
|
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"<?php // static asset handler",
|
|
)
|
|
.unwrap();
|
|
std::fs::write(tmp.path().join("index.php"), b"<?php // front controller").unwrap();
|
|
// This is the Roundcube v1.7 URL layout; /static.php/skins/... is
|
|
// virtual PATH_INFO, not a readable directory on disk.
|
|
let uri = "/static.php/skins/elastic/images/favicon.ico?s=1783249197";
|
|
let (address, received) = fastcgi_fixture_record_params().await;
|
|
let mut configured = Service {
|
|
id: "roundcube".into(),
|
|
name: "Roundcube".into(),
|
|
document_root: tmp.path().to_path_buf(),
|
|
index: vec!["index.php".into()],
|
|
try_files: vec![
|
|
"$uri".into(),
|
|
"$uri/".into(),
|
|
"/index.php?$query_string".into(),
|
|
],
|
|
php: Php {
|
|
enabled: true,
|
|
path_info: true,
|
|
endpoint: format!("tcp:{address}"),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
};
|
|
configured.validate().unwrap();
|
|
let service = configured.compile().unwrap();
|
|
let response = serve_request(
|
|
&service,
|
|
&request(Method::GET, uri),
|
|
"/static.php/skins/elastic/images/favicon.ico",
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
assert_eq!(response.headers()[CONTENT_TYPE], "text/css");
|
|
assert_eq!(
|
|
response.into_body().collect().await.unwrap().to_bytes(),
|
|
"PHP-STATIC-OK"
|
|
);
|
|
let params = received.await.unwrap();
|
|
assert_eq!(
|
|
params["SCRIPT_FILENAME"].as_str(),
|
|
tmp.path().join("static.php").to_string_lossy().as_ref()
|
|
);
|
|
assert_eq!(params["SCRIPT_NAME"], "/static.php");
|
|
assert_eq!(params["PATH_INFO"], "/skins/elastic/images/favicon.ico");
|
|
assert_eq!(params["QUERY_STRING"], "s=1783249197");
|
|
assert_eq!(params["REQUEST_URI"], uri);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn without_php_path_info_try_files_keeps_legacy_front_controller_fallback() {
|
|
let tmp = TempDir::new().unwrap();
|
|
std::fs::write(
|
|
tmp.path().join("static.php"),
|
|
b"<?php // static asset handler",
|
|
)
|
|
.unwrap();
|
|
std::fs::write(tmp.path().join("index.php"), b"<?php // front controller").unwrap();
|
|
let (address, received) = fastcgi_fixture_record_params().await;
|
|
let mut configured = Service {
|
|
id: "roundcube-no-path-info".into(),
|
|
name: "Roundcube without PATH_INFO".into(),
|
|
document_root: tmp.path().to_path_buf(),
|
|
try_files: vec!["$uri".into(), "/index.php?$query_string".into()],
|
|
php: Php {
|
|
enabled: true,
|
|
path_info: false,
|
|
endpoint: format!("tcp:{address}"),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
};
|
|
configured.validate().unwrap();
|
|
let service = configured.compile().unwrap();
|
|
let uri = "/static.php/plugins/zipdownload/zipdownload.min.js?s=1783249197";
|
|
let response = serve_request(
|
|
&service,
|
|
&request(Method::GET, uri),
|
|
"/static.php/plugins/zipdownload/zipdownload.min.js",
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let _ = response.into_body().collect().await.unwrap().to_bytes();
|
|
let params = received.await.unwrap();
|
|
assert_eq!(
|
|
params["SCRIPT_FILENAME"].as_str(),
|
|
tmp.path().join("index.php").to_string_lossy().as_ref()
|
|
);
|
|
assert_eq!(params["SCRIPT_NAME"], "/index.php");
|
|
assert!(!params.contains_key("PATH_INFO"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn web_metrics_count_handler_status_method_and_consumed_body_bytes() {
|
|
let tmp = TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join("asset.txt"), b"12345").unwrap();
|
|
let service = service(tmp.path(), false);
|
|
let get = serve_request(&service, &request(Method::GET, "/asset.txt"), "/asset.txt").await;
|
|
assert_eq!(get.status(), StatusCode::OK);
|
|
// Bytes are accounted when the body is actually consumed, not when headers arrive.
|
|
assert_eq!(service.web_metrics.view()["response_bytes"], 0);
|
|
assert_eq!(get.into_body().collect().await.unwrap().to_bytes(), "12345");
|
|
let head = serve_request(&service, &request(Method::HEAD, "/asset.txt"), "/asset.txt").await;
|
|
assert_eq!(head.status(), StatusCode::OK);
|
|
assert!(head
|
|
.into_body()
|
|
.collect()
|
|
.await
|
|
.unwrap()
|
|
.to_bytes()
|
|
.is_empty());
|
|
let missing = serve_request(&service, &request(Method::GET, "/missing"), "/missing").await;
|
|
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
|
|
let missing_bytes = missing
|
|
.into_body()
|
|
.collect()
|
|
.await
|
|
.unwrap()
|
|
.to_bytes()
|
|
.len();
|
|
let view = service.web_metrics.view();
|
|
assert_eq!(view["requests"], 3);
|
|
assert_eq!(view["status_2xx"], 2);
|
|
assert_eq!(view["status_4xx"], 1);
|
|
assert_eq!(view["static_requests"], 2);
|
|
assert_eq!(view["other_requests"], 1);
|
|
assert_eq!(view["methods"][0], 2); // GET
|
|
assert_eq!(view["methods"][4], 1); // HEAD -> 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"<?php echo 'ok';").unwrap();
|
|
let (address, received) = fastcgi_fixture_record_params().await;
|
|
let service = Service {
|
|
id: "telemetry".into(),
|
|
name: "Telemetry".into(),
|
|
document_root: tmp.path().to_path_buf(),
|
|
php: Php {
|
|
enabled: true,
|
|
endpoint: format!("tcp:{address}"),
|
|
max_concurrent_requests: 2,
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
}
|
|
.compile()
|
|
.unwrap();
|
|
let response = serve_request(&service, &request(Method::GET, "/index.php"), "/index.php").await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
assert_eq!(
|
|
response.into_body().collect().await.unwrap().to_bytes(),
|
|
"PHP-STATIC-OK"
|
|
);
|
|
received.await.unwrap();
|
|
let web = service.web_metrics.view();
|
|
let php = service.php.as_ref().unwrap();
|
|
assert_eq!(web["requests"], 1);
|
|
assert_eq!(web["php_requests"], 1);
|
|
assert_eq!(web["static_requests"], 0);
|
|
assert_eq!(web["response_bytes"], 13);
|
|
assert_eq!(php.metrics.view()["requests"], 1);
|
|
assert_eq!(php.metrics.view()["response_bytes"], 13);
|
|
assert_eq!(php.max_concurrent, 2);
|
|
assert_eq!(php.permits.available_permits(), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn static_streaming_keeps_large_payloads_intact_in_bounded_frames() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let service = service(tmp.path(), false);
|
|
let mut accounted = 0u64;
|
|
for length in [
|
|
0usize,
|
|
1,
|
|
1040,
|
|
16 * 1024 + 3,
|
|
64 * 1024,
|
|
64 * 1024 + 1,
|
|
1024 * 1024,
|
|
] {
|
|
let expected: Vec<u8> = (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<u8> = (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<u8> = (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::<Vec<_>>(),
|
|
["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::<SendfileMode>("\"auto\"").unwrap(), SendfileMode::Auto);
|
|
assert_eq!(serde_json::from_str::<SendfileMode>("\"off\"").unwrap(), SendfileMode::Off);
|
|
assert!(serde_json::from_str::<SendfileMode>("\"unsafe\"").is_err());
|
|
let legacy = serde_json::json!({"id":"legacy", "name":"Legacy", "document_root":"/tmp"});
|
|
assert_eq!(serde_json::from_value::<Service>(legacy).unwrap().sendfile, SendfileMode::Off);
|
|
}
|