use std::fs;
use std::sync::Arc;
use hyper::{Response, StatusCode};
use mini_serve::{body, handler, Handler, Middleware, RouteBuilder, ServeError, State};
use mini_static::Server;
use tempfile::TempDir;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
async fn raw_get(port: u16, target: &str) -> String {
let mut stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
.await
.unwrap();
let request =
format!("GET {target} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n");
stream.write_all(request.as_bytes()).await.unwrap();
let mut response = Vec::new();
let _ = stream.read_to_end(&mut response).await;
String::from_utf8_lossy(&response).into_owned()
}
fn status_of(response: &str) -> u16 {
response
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|code| code.parse().ok())
.unwrap_or(0)
}
fn root() -> TempDir {
let root = TempDir::new().unwrap();
fs::create_dir(root.path().join("admin")).unwrap();
fs::write(root.path().join("admin/config"), b"FILE-CONTENTS").unwrap();
fs::write(root.path().join("index.html"), b"<html>home</html>").unwrap();
root
}
fn api() -> Handler<()> {
handler(|_req, _state| async {
Ok::<_, ServeError>(Response::new(body("API".into())))
})
}
fn guard() -> Middleware<()> {
Arc::new(|next: Handler<()>| {
let next = Arc::clone(&next);
handler(move |req, state: State<()>| {
let next = Arc::clone(&next);
async move {
if req.uri().path().starts_with("/admin/") {
return Ok(Response::builder()
.status(StatusCode::FORBIDDEN)
.body(body("GUARDED".into()))
.unwrap());
}
next(req, state).await
}
})
})
}
async fn composed(root: &TempDir) -> u16 {
RouteBuilder::stateless()
.wrap(guard())
.get("/api/health", api())
.with_fallback(Server::new(root.path()).unwrap().into_fallback())
.seal()
.bind_ephemeral()
.await
.unwrap()
}
#[tokio::test]
async fn api_routes_and_files_share_one_server() {
let root = root();
let port = composed(&root).await;
let api = raw_get(port, "/api/health").await;
assert_eq!(status_of(&api), 200, "the API route did not serve: {api}");
assert!(api.ends_with("API"), "the fallback shadowed a registered route: {api}");
let file = raw_get(port, "/index.html").await;
assert_eq!(status_of(&file), 200, "a file was not served: {file}");
assert!(file.contains("<html>home</html>"), "wrong body: {file}");
}
#[tokio::test]
async fn an_encoded_separator_cannot_bypass_a_guard_on_the_prefix() {
let root = root();
let port = composed(&root).await;
let honest = raw_get(port, "/admin/config").await;
assert_eq!(status_of(&honest), 403, "the guard did not fire: {honest}");
let encoded = raw_get(port, "/admin%2Fconfig").await;
assert_ne!(
status_of(&encoded),
200,
"an encoded separator reached the file past the guard: {encoded}"
);
assert!(
!encoded.contains("FILE-CONTENTS"),
"the guarded file's contents were served: {encoded}"
);
}
#[tokio::test]
async fn a_composed_deployment_refuses_a_request_with_no_host() {
let root = root();
let port = composed(&root).await;
let mut stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
.await
.unwrap();
stream
.write_all(b"GET /index.html HTTP/1.1\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response = Vec::new();
let _ = stream.read_to_end(&mut response).await;
let head = String::from_utf8_lossy(&response);
assert!(
head.starts_with("HTTP/1.1 400"),
"an HTTP/1.1 request with no Host was served (RFC 9112 §3.2): {head}"
);
}