mod common;
use std::fs;
use hyper::header::HeaderMap;
use hyper::Method;
use mini_static::{Server, StaticError};
use tempfile::TempDir;
const HSTS: &str = "Strict-Transport-Security";
const HSTS_VALUE: &str = "max-age=63072000; includeSubDomains";
fn root() -> TempDir {
let root = TempDir::new().unwrap();
fs::write(root.path().join("index.html"), b"<html>Hi</html>").unwrap();
fs::create_dir(root.path().join("dir")).unwrap();
fs::write(root.path().join("dir/index.html"), b"<html>Dir</html>").unwrap();
root
}
fn server_with_header(root: &TempDir) -> Server {
Server::new(root.path())
.unwrap()
.with_response_header(HSTS, HSTS_VALUE)
.unwrap()
}
fn header_of(response: &hyper::Response<mini_static::ResponseBody>, name: &str) -> Option<String> {
response
.headers()
.get(name)
.map(|v| v.to_str().unwrap().to_string())
}
#[tokio::test]
async fn a_configured_header_is_present_on_every_status() {
let root = root();
let server = server_with_header(&root);
let ok = common::get(&server, "/index.html").await;
assert_eq!(ok.status().as_u16(), 200);
assert_eq!(header_of(&ok, HSTS).as_deref(), Some(HSTS_VALUE));
let missing = common::get(&server, "/nope.html").await;
assert_eq!(missing.status().as_u16(), 404);
assert_eq!(header_of(&missing, HSTS).as_deref(), Some(HSTS_VALUE));
let traversal = common::get(&server, "/../../etc/passwd").await;
assert_eq!(traversal.status().as_u16(), 404);
assert_eq!(header_of(&traversal, HSTS).as_deref(), Some(HSTS_VALUE));
let rejected = common::request(&server, &Method::DELETE, "/index.html").await;
assert_eq!(rejected.status().as_u16(), 405);
assert_eq!(header_of(&rejected, HSTS).as_deref(), Some(HSTS_VALUE));
let redirect = common::get(&server, "/dir").await;
assert_eq!(redirect.status().as_u16(), 301);
assert_eq!(header_of(&redirect, HSTS).as_deref(), Some(HSTS_VALUE));
}
#[tokio::test]
async fn a_configured_header_is_present_on_a_304() {
let root = root();
let server = server_with_header(&root);
let first = common::get(&server, "/index.html").await;
let etag = first.headers().get("ETag").unwrap().clone();
let mut headers = HeaderMap::new();
headers.insert("if-none-match", etag);
let revalidated = server
.handle_request(&Method::GET, "/index.html", &headers)
.await;
assert_eq!(revalidated.status().as_u16(), 304);
assert_eq!(header_of(&revalidated, HSTS).as_deref(), Some(HSTS_VALUE));
}
#[tokio::test]
async fn several_headers_can_be_configured() {
let root = root();
let server = Server::new(root.path())
.unwrap()
.with_response_header(HSTS, HSTS_VALUE)
.unwrap()
.with_response_header("Referrer-Policy", "no-referrer")
.unwrap()
.with_response_header("Content-Security-Policy", "default-src 'self'")
.unwrap();
let response = common::get(&server, "/index.html").await;
assert_eq!(header_of(&response, HSTS).as_deref(), Some(HSTS_VALUE));
assert_eq!(
header_of(&response, "Referrer-Policy").as_deref(),
Some("no-referrer")
);
assert_eq!(
header_of(&response, "Content-Security-Policy").as_deref(),
Some("default-src 'self'")
);
}
#[tokio::test]
async fn nothing_extra_is_sent_without_configuration() {
let root = root();
let server = Server::new(root.path()).unwrap();
let response = common::get(&server, "/index.html").await;
assert_eq!(header_of(&response, HSTS), None);
}
#[test]
fn an_invalid_name_or_value_is_rejected_at_configuration_time() {
let root = root();
let bad_name = Server::new(root.path())
.unwrap()
.with_response_header("Not A Header", "x");
assert!(
matches!(bad_name, Err(StaticError::Config(_))),
"a header name with spaces must be rejected"
);
let bad_value = Server::new(root.path())
.unwrap()
.with_response_header("X-Fine", "bad\nvalue");
assert!(
matches!(bad_value, Err(StaticError::Config(_))),
"a header value containing a newline must be rejected"
);
}
#[test]
fn server_computed_headers_are_refused() {
let root = root();
for name in [
"Content-Length",
"content-type",
"ETag",
"Cache-Control",
"Vary",
"Accept-Ranges",
"Allow",
"Location",
"X-Content-Type-Options",
"Content-Encoding",
"Content-Range",
"Connection",
"Transfer-Encoding",
] {
let result = Server::new(root.path())
.unwrap()
.with_response_header(name, "x");
assert!(
matches!(result, Err(StaticError::Config(_))),
"{name} should be refused as a fixed header"
);
}
}
#[test]
fn the_computed_header_check_is_case_insensitive() {
let root = root();
let result = Server::new(root.path())
.unwrap()
.with_response_header("CONTENT-LENGTH", "999");
assert!(matches!(result, Err(StaticError::Config(_))));
}