mini-static 0.38.7

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
//! API routes and static files in one process, one router, one path model.
//!
//! This is the deployment `mini-unified` existed for, expressed without it: register
//! routes, hand the rest to `into_fallback()`. That crate's whole job was wrapping this
//! crate's handler for `mini-serve`, which was only necessary while this crate shipped a
//! whole server around it.
//!
//! The last test is the one that made all of this worth doing. `/admin%2Fconfig` used to
//! reach `admin/config` on disk while the router in front saw a single segment, matched no
//! route, and never consulted the guard protecting `/admin/`. Both crates now read that
//! path the same way, because only one of them reads it.

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};

/// Send a request-target verbatim and read the whole response. Raw sockets rather than a
/// client library, for two reasons: this crate carries no HTTP client dev-dependency, and
/// a client would normalise `%2F` before it ever reached the server, which is the one
/// thing these tests are about.
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())))
	})
}

/// A guard on `/admin/`, the way a deployment actually writes one.
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}");
}

/// The bug this whole plan exists to make impossible. Both spellings must be refused by
/// the guard or refused outright — neither may reach the 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}"
	);
}

/// The static fallback inherits the connection guarantees, so a composed deployment gets
/// them for the file half too — the `Host` check being one this crate never had alone.
#[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}"
	);
}