453 lines
18 KiB
Rust
453 lines
18 KiB
Rust
use super::*;
|
|
use super::fastcgi::{
|
|
fastcgi_body_stream, fastcgi_params, parse_cgi_headers, read_fcgi_record, write_fcgi_record,
|
|
FCGI_END_REQUEST, FCGI_PARAMS, FCGI_STDIN, FCGI_STDOUT,
|
|
};
|
|
use axum::{body::Body, response::Response};
|
|
use bytes::Bytes;
|
|
use futures_util::StreamExt;
|
|
use http::{
|
|
header::{
|
|
ACCEPT_RANGES, ALLOW, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LENGTH,
|
|
CONTENT_RANGE, CONTENT_TYPE, ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, IF_RANGE,
|
|
LAST_MODIFIED, LOCATION, RANGE, VARY,
|
|
},
|
|
HeaderMap, HeaderValue, Method, StatusCode, Uri, Version,
|
|
};
|
|
use http_body_util::BodyExt;
|
|
use std::{collections::BTreeMap, path::Path, time::Duration};
|
|
use tempfile::TempDir;
|
|
use tokio::{io::AsyncWriteExt, sync::mpsc, time::Instant};
|
|
|
|
fn service(root: &Path, listing: bool) -> CompiledService {
|
|
Service {
|
|
id: "site".into(),
|
|
name: "Site".into(),
|
|
document_root: root.to_path_buf(),
|
|
index: vec!["index.html".into(), "index.htm".into()],
|
|
directory_listing: listing,
|
|
..Default::default()
|
|
}
|
|
.compile()
|
|
.unwrap()
|
|
}
|
|
|
|
fn request(method: Method, uri: &str) -> http::Request<()> {
|
|
http::Request::builder()
|
|
.method(method)
|
|
.uri(uri)
|
|
.body(())
|
|
.unwrap()
|
|
}
|
|
|
|
async fn serve_request(
|
|
service: &CompiledService,
|
|
request: &http::Request<()>,
|
|
path: &str,
|
|
) -> Response {
|
|
serve_body_request(service, request, path, Body::empty()).await
|
|
}
|
|
|
|
async fn serve_body_request(
|
|
service: &CompiledService,
|
|
request: &http::Request<()>,
|
|
path: &str,
|
|
body: Body,
|
|
) -> Response {
|
|
serve(
|
|
service,
|
|
request.method().clone(),
|
|
request.headers().clone(),
|
|
request.uri().clone(),
|
|
path,
|
|
body,
|
|
RequestContext {
|
|
client_ip: "127.0.0.1".parse().unwrap(),
|
|
client_port: Some(12345),
|
|
server_name: "example.test".into(),
|
|
server_port: 80,
|
|
tls: false,
|
|
version: Version::HTTP_11,
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_head_index_and_directory_redirect_work() {
|
|
let tmp = TempDir::new().unwrap();
|
|
std::fs::create_dir(tmp.path().join("dir")).unwrap();
|
|
std::fs::write(tmp.path().join("index.html"), b"root").unwrap();
|
|
std::fs::write(tmp.path().join("dir/index.html"), b"nested").unwrap();
|
|
let service = service(tmp.path(), false);
|
|
|
|
let response = serve_request(&service, &request(Method::GET, "/"), "/").await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
assert_eq!(response.headers()[CONTENT_TYPE], "text/html; charset=utf-8");
|
|
assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "root");
|
|
|
|
let response = serve_request(&service, &request(Method::HEAD, "/"), "/").await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
assert_eq!(response.headers()[CONTENT_LENGTH], "4");
|
|
assert!(response.into_body().collect().await.unwrap().to_bytes().is_empty());
|
|
|
|
let response = serve_request(&service, &request(Method::GET, "/dir?x=1"), "/dir").await;
|
|
assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY);
|
|
assert_eq!(response.headers()[LOCATION], "/dir/?x=1");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn missing_files_and_disabled_directory_listing_return_normal_errors() {
|
|
let tmp = TempDir::new().unwrap();
|
|
std::fs::create_dir(tmp.path().join("empty")).unwrap();
|
|
let service = service(tmp.path(), false);
|
|
|
|
let missing = serve_request(&service, &request(Method::GET, "/missing.txt"), "/missing.txt").await;
|
|
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
|
|
|
|
let directory = serve_request(&service, &request(Method::GET, "/empty/"), "/empty/").await;
|
|
assert_eq!(directory.status(), StatusCode::FORBIDDEN);
|
|
assert_eq!(directory.headers()[CONTENT_TYPE], "text/html; charset=utf-8");
|
|
let directory_body = directory.into_body().collect().await.unwrap().to_bytes();
|
|
let directory_body = std::str::from_utf8(&directory_body).unwrap();
|
|
assert!(directory_body.contains("403"));
|
|
assert!(!directory_body.contains("directory listing is disabled"));
|
|
|
|
let directory_head = serve_request(&service, &request(Method::HEAD, "/empty/"), "/empty/").await;
|
|
assert_eq!(directory_head.status(), StatusCode::FORBIDDEN);
|
|
assert!(directory_head.into_body().collect().await.unwrap().to_bytes().is_empty());
|
|
|
|
let method = serve_request(&service, &request(Method::POST, "/missing.txt"), "/missing.txt").await;
|
|
assert_eq!(method.status(), StatusCode::METHOD_NOT_ALLOWED);
|
|
assert_eq!(method.headers()[ALLOW], "GET, HEAD");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn last_modified_condition_returns_304() {
|
|
let tmp = TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join("file.txt"), b"validator").unwrap();
|
|
let service = service(tmp.path(), false);
|
|
let first = serve_request(&service, &request(Method::GET, "/file.txt"), "/file.txt").await;
|
|
let last_modified = first.headers()[LAST_MODIFIED].clone();
|
|
assert_eq!(first.headers()[CONTENT_TYPE], "text/plain; charset=utf-8");
|
|
assert_eq!(first.headers()[CONTENT_LENGTH], "9");
|
|
|
|
let mut conditional = request(Method::GET, "/file.txt");
|
|
conditional
|
|
.headers_mut()
|
|
.insert(IF_MODIFIED_SINCE, last_modified);
|
|
let response = serve_request(&service, &conditional, "/file.txt").await;
|
|
assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
|
|
assert!(response.headers().contains_key(LAST_MODIFIED));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn validators_and_single_ranges_work() {
|
|
let tmp = TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join("file.txt"), b"0123456789").unwrap();
|
|
let service = service(tmp.path(), false);
|
|
let first = serve_request(&service, &request(Method::GET, "/file.txt"), "/file.txt").await;
|
|
let etag = first.headers()[ETAG].clone();
|
|
assert_eq!(first.headers()[ACCEPT_RANGES], "bytes");
|
|
|
|
let mut conditional = request(Method::GET, "/file.txt");
|
|
conditional.headers_mut().insert(IF_NONE_MATCH, etag);
|
|
assert_eq!(
|
|
serve_request(&service, &conditional, "/file.txt").await.status(),
|
|
StatusCode::NOT_MODIFIED
|
|
);
|
|
|
|
let mut ranged = request(Method::GET, "/file.txt");
|
|
ranged.headers_mut().insert(RANGE, "bytes=2-5".parse().unwrap());
|
|
let response = serve_request(&service, &ranged, "/file.txt").await;
|
|
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
|
|
assert_eq!(response.headers()[CONTENT_RANGE], "bytes 2-5/10");
|
|
assert_eq!(response.headers()[CONTENT_LENGTH], "4");
|
|
assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "2345");
|
|
|
|
let mut invalid = request(Method::GET, "/file.txt");
|
|
invalid.headers_mut().insert(RANGE, "bytes=99-100".parse().unwrap());
|
|
let response = serve_request(&service, &invalid, "/file.txt").await;
|
|
assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
|
|
assert_eq!(response.headers()[CONTENT_RANGE], "bytes */10");
|
|
|
|
let mut if_range_match = request(Method::GET, "/file.txt");
|
|
if_range_match.headers_mut().insert(RANGE, "bytes=0-1".parse().unwrap());
|
|
if_range_match.headers_mut().insert(IF_RANGE, first.headers()[ETAG].clone());
|
|
assert_eq!(serve_request(&service, &if_range_match, "/file.txt").await.status(), StatusCode::PARTIAL_CONTENT);
|
|
|
|
let mut if_range_miss = request(Method::GET, "/file.txt");
|
|
if_range_miss.headers_mut().insert(RANGE, "bytes=0-1".parse().unwrap());
|
|
if_range_miss.headers_mut().insert(IF_RANGE, "\"different\"".parse().unwrap());
|
|
let response = serve_request(&service, &if_range_miss, "/file.txt").await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
assert_eq!(response.headers()[CONTENT_LENGTH], "10");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn multipart_byte_ranges_stream_each_requested_part() {
|
|
let tmp = TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join("file.txt"), b"0123456789").unwrap();
|
|
let service = service(tmp.path(), false);
|
|
let mut ranged = request(Method::GET, "/file.txt");
|
|
ranged
|
|
.headers_mut()
|
|
.insert(RANGE, "bytes=0-1,6-8".parse().unwrap());
|
|
let response = serve_request(&service, &ranged, "/file.txt").await;
|
|
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
|
|
let content_type = response.headers()[CONTENT_TYPE].to_str().unwrap().to_owned();
|
|
assert!(content_type.starts_with("multipart/byteranges; boundary="));
|
|
assert!(!response.headers().contains_key(CONTENT_RANGE));
|
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
|
let body = String::from_utf8(body.to_vec()).unwrap();
|
|
assert!(body.contains("Content-Range: bytes 0-1/10"));
|
|
assert!(body.contains("Content-Range: bytes 6-8/10"));
|
|
assert!(body.contains("\r\n01\r\n"));
|
|
assert!(body.contains("\r\n678\r\n"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn too_many_byte_ranges_are_rejected() {
|
|
let tmp = TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join("file.txt"), b"0123456789").unwrap();
|
|
let service = service(tmp.path(), false);
|
|
let ranges = std::iter::repeat("0-0").take(17).collect::<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");
|
|
}
|