mini-serve 0.13.12

An HTTP server: trie router, middleware, CORS, optional TLS. Built on hyper + tokio.
Documentation
//! The fallback seam: what happens when a request matches no route at all.
//!
//! The seam exists so a file server can sit behind an API without a bridge crate. That
//! makes it a new *exit path*, and every guarantee this crate makes about responses has to
//! hold on it. A fallback wired in anywhere other than inside the routing branch would
//! bypass `finalize` — losing `nosniff`, CORS and HEAD body-stripping — and a fallback
//! attached without `apply_middlewares` would run outside the middleware chain, so an
//! authorization middleware guarding a prefix would not protect what the fallback serves
//! for that prefix.
//!
//! That second failure is the one worth naming: it is the same shape as the encoded-slash
//! bypass that motivated this work, arriving through plumbing rather than a decoder.

use std::sync::Arc;

use hyper::{Response, StatusCode};
use mini_serve::{
	body, handler, CorsConfigBuilder, Handler, Middleware, PathSegments, RouteBuilder, ServeError,
	State,
};

fn text_handler(text: &'static str) -> Handler<()> {
	handler(move |_req, _state| async move {
		Ok::<_, ServeError>(Response::new(body(text.into())))
	})
}

/// An app with one route and a fallback that answers everything else.
async fn app_with_fallback() -> u16 {
	RouteBuilder::stateless()
		.get("/api/health", text_handler("API"))
		.with_fallback(text_handler("FALLBACK"))
		.seal()
		.bind_ephemeral()
		.await
		.unwrap()
}

#[tokio::test]
async fn a_registered_route_still_wins() {
	let port = app_with_fallback().await;
	let body = reqwest::get(format!("http://127.0.0.1:{port}/api/health"))
		.await
		.unwrap()
		.text()
		.await
		.unwrap();
	assert_eq!(body, "API", "the fallback shadowed a registered route");
}

#[tokio::test]
async fn an_unmatched_path_reaches_the_fallback() {
	let port = app_with_fallback().await;
	let response = reqwest::get(format!("http://127.0.0.1:{port}/assets/site.css"))
		.await
		.unwrap();
	assert_eq!(response.status(), StatusCode::OK);
	assert_eq!(response.text().await.unwrap(), "FALLBACK");
}

/// Without a fallback the miss is still a 404 — the seam is additive, not a behaviour
/// change for apps that do not use it.
#[tokio::test]
async fn a_miss_without_a_fallback_is_still_a_404() {
	let port = RouteBuilder::stateless()
		.get("/api/health", text_handler("API"))
		.seal()
		.bind_ephemeral()
		.await
		.unwrap();
	let response = reqwest::get(format!("http://127.0.0.1:{port}/nope")).await.unwrap();
	assert_eq!(response.status(), StatusCode::NOT_FOUND);
}

/// A path registered for another method answers `405`, not the fallback. Method semantics
/// stay where they are; the seam only fills the branch that was a flat `404`.
#[tokio::test]
async fn a_wrong_method_on_a_real_route_is_405_not_the_fallback() {
	let port = app_with_fallback().await;
	let response = reqwest::Client::new()
		.post(format!("http://127.0.0.1:{port}/api/health"))
		.send()
		.await
		.unwrap();
	assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
	assert!(response.headers().contains_key("allow"));
}

/// **The one that matters.** Middleware is applied per route at registration, so a
/// fallback attached outside `apply_middlewares` runs outside the chain — and a guard
/// protecting `/admin/` would not protect a file the fallback serves under `/admin/`.
#[tokio::test]
async fn middleware_wraps_the_fallback() {
	let 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
			}
		})
	});

	let port = RouteBuilder::stateless()
		.wrap(guard)
		.get("/api/health", text_handler("API"))
		.with_fallback(text_handler("FALLBACK"))
		.seal()
		.bind_ephemeral()
		.await
		.unwrap();

	let response = reqwest::get(format!("http://127.0.0.1:{port}/admin/secret.txt"))
		.await
		.unwrap();
	assert_eq!(
		response.status(),
		StatusCode::FORBIDDEN,
		"the fallback served a guarded path without the middleware running"
	);
	assert_eq!(response.text().await.unwrap(), "GUARDED");
}

/// `finalize` runs on every exit, so a fallback response carries `nosniff` without doing
/// anything itself.
#[tokio::test]
async fn a_fallback_response_carries_nosniff() {
	let port = app_with_fallback().await;
	let response = reqwest::get(format!("http://127.0.0.1:{port}/anything")).await.unwrap();
	assert_eq!(
		response.headers().get("x-content-type-options").map(|v| v.to_str().unwrap()),
		Some("nosniff")
	);
}

/// And CORS headers, on the same reasoning: a fallback is an exit path like any other.
#[tokio::test]
async fn a_fallback_response_carries_cors_headers() {
	let cors = CorsConfigBuilder::default()
		.allow_origin("https://example.com")
		.build()
		.unwrap();
	let port = RouteBuilder::stateless()
		.with_cors(cors)
		.get("/api/health", text_handler("API"))
		.with_fallback(text_handler("FALLBACK"))
		.seal()
		.bind_ephemeral()
		.await
		.unwrap();

	let response = reqwest::Client::new()
		.get(format!("http://127.0.0.1:{port}/assets/site.css"))
		.header("Origin", "https://example.com")
		.send()
		.await
		.unwrap();
	assert_eq!(
		response.headers().get("access-control-allow-origin").map(|v| v.to_str().unwrap()),
		Some("https://example.com"),
		"a fallback response skipped the CORS exit"
	);
}

/// A fallback's 5xx is sanitized exactly as a routed handler's is — it goes through the
/// same `invoke`, which is the reason that code is shared rather than copied.
#[tokio::test]
async fn a_fallback_5xx_does_not_leak_internals() {
	let port = RouteBuilder::stateless()
		.with_fallback(handler(|_req, _state| async {
			Err::<Response<mini_serve::ResponseBody>, _>(ServeError::new(
				500,
				"replica set primary unreachable",
			))
		}))
		.seal()
		.bind_ephemeral()
		.await
		.unwrap();

	let response = reqwest::get(format!("http://127.0.0.1:{port}/boom")).await.unwrap();
	assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
	let body = response.text().await.unwrap();
	assert!(
		!body.contains("replica set"),
		"a fallback leaked an internal message: {body}"
	);
}

/// HEAD is stripped after the branch, so a fallback needs no HEAD handling of its own —
/// and must not do any, or it would report a `Content-Length` its GET would not.
#[tokio::test]
async fn a_head_to_the_fallback_reports_the_length_of_its_get() {
	let port = app_with_fallback().await;
	let client = reqwest::Client::new();
	let url = format!("http://127.0.0.1:{port}/assets/site.css");

	let get = client.get(&url).send().await.unwrap();
	let get_len = get.headers().get("content-length").cloned();
	let head = client.head(&url).send().await.unwrap();

	assert_eq!(head.headers().get("content-length"), get_len.as_ref());
	assert!(head.text().await.unwrap().is_empty(), "HEAD carried a body");
}

/// A CORS preflight for a path only the fallback would serve must not be answered with a
/// `204` — that guarantee is about *registered* routes, and a fallback registers nothing.
#[tokio::test]
async fn a_preflight_for_a_fallback_path_is_not_masked_by_a_204() {
	let cors = CorsConfigBuilder::default()
		.allow_origin("https://example.com")
		.build()
		.unwrap();
	let port = RouteBuilder::stateless()
		.with_cors(cors)
		.get("/api/health", text_handler("API"))
		.with_fallback(text_handler("FALLBACK"))
		.seal()
		.bind_ephemeral()
		.await
		.unwrap();

	let response = reqwest::Client::new()
		.request(reqwest::Method::OPTIONS, format!("http://127.0.0.1:{port}/assets/site.css"))
		.header("Origin", "https://example.com")
		.header("Access-Control-Request-Method", "GET")
		.send()
		.await
		.unwrap();
	assert_ne!(
		response.status(),
		StatusCode::NO_CONTENT,
		"a preflight was answered for a path with no registered route"
	);
}

/// The fallback is handed the segments the router already split and decoded, so a
/// consumer never parses the raw URI a second time.
///
/// This is the seam's real point. `mini-static` reached a nested file through
/// `/admin%2Fconfig` because it decoded the whole path and split afterwards, while the
/// router split first and decoded after — two answers to "what are this path's segments"
/// in one process. There is now one answer, and it arrives with the request.
#[tokio::test]
async fn the_fallback_receives_the_routers_own_segments() {
	let port = RouteBuilder::stateless()
		.with_fallback(handler(|req, _state| async move {
			let segments = req
				.extensions()
				.get::<PathSegments>()
				.expect("the fallback was not given the router's segments");
			Ok::<_, ServeError>(hyper::Response::new(body(segments.0.join("|").into())))
		}))
		.seal()
		.bind_ephemeral()
		.await
		.unwrap();

	// `%2F` is a character inside one segment, never a separator (RFC 3986 §3.3).
	let body = reqwest::get(format!("http://127.0.0.1:{port}/admin%2Fconfig"))
		.await
		.unwrap()
		.text()
		.await
		.unwrap();
	assert_eq!(body, "admin/config", "an encoded slash was treated as a separator");

	let body = reqwest::get(format!("http://127.0.0.1:{port}/admin/config"))
		.await
		.unwrap()
		.text()
		.await
		.unwrap();
	assert_eq!(body, "admin|config", "a real separator was not one");
}