use crate::application::ServeState;
use crate::h1_backend::serve::{h1_config, serve_bound};
use crate::http::{HttpRequest, HttpResponse};
use crate::pipeline::PipelineConfig;
use crate::route_cache::OptimizedRouter;
use crate::routing::{Route, Router};
use crate::traits::HttpMethod;
use crate::{Error, application::DEFAULT_MAX_BODY_SIZE};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
async fn echo(req: HttpRequest) -> Result<HttpResponse, Error> {
let body = format!(
"method={} path={} trace={} peer={} body={}",
req.method,
req.path,
req.headers.get("x-trace-id").unwrap_or("-"),
req.peer.map_or("-".to_string(), |p| p.to_string()),
String::from_utf8_lossy(&req.body),
);
Ok(HttpResponse::ok().with_body(body.into_bytes()))
}
fn test_state(max_body_size: usize) -> ServeState {
let mut router = Router::new();
router.add_route(Route::new(HttpMethod::GET, "/echo", echo));
router.add_route(Route::new(HttpMethod::POST, "/echo", echo));
ServeState::for_test(
Arc::new(OptimizedRouter::from_router(&router)),
max_body_size,
)
}
async fn with_server<F, Fut, T>(state: ServeState, body: F) -> T
where
F: FnOnce(std::net::SocketAddr) -> Fut + Send + 'static,
Fut: std::future::Future<Output = T> + Send,
T: Send + 'static,
{
let cfg = h1_config(
"127.0.0.1:0".parse().expect("addr"),
&PipelineConfig::default(),
Some(1),
);
let (tx, rx) = tokio::sync::oneshot::channel();
let server = tokio::spawn(async move {
let mut tx = Some(tx);
serve_bound(cfg, state, None, move |addr, handle| {
let _ = tx.take().expect("bound once").send((addr, handle));
})
.await
});
let (addr, handle) = tokio::time::timeout(Duration::from_secs(5), rx)
.await
.expect("server bound within 5s")
.expect("bind address");
let out = body(addr).await;
handle.shutdown();
let served = tokio::time::timeout(Duration::from_secs(10), server)
.await
.expect(
"the server did not stop within 10s of being told to; its worker \
threads are still held, and the runtime will block at drop",
)
.expect("the task running the server panicked");
assert!(
served.is_ok(),
"the server was shut down deliberately through its handle, so it must \
report a clean exit; an `Err` here means a graceful shutdown is being \
reported as a failure, which is what every caller's `main` propagates \
as a non-zero exit status: {served:?}"
);
out
}
async fn roundtrip_closed(addr: std::net::SocketAddr, request: &[u8]) -> (String, bool) {
let mut stream = tokio::net::TcpStream::connect(addr)
.await
.expect("connect to test server");
stream.write_all(request).await.expect("write request");
let mut out = Vec::new();
let closed = tokio::time::timeout(Duration::from_secs(5), stream.read_to_end(&mut out))
.await
.is_ok_and(|read| read.is_ok());
(String::from_utf8_lossy(&out).into_owned(), closed)
}
async fn roundtrip(addr: std::net::SocketAddr, request: &[u8]) -> String {
let (response, closed) = roundtrip_closed(addr, request).await;
assert!(
closed,
"the server answered but never closed the connection, so the response \
below is only what arrived before the read deadline — a leaked \
connection reads exactly like a served one otherwise: {response:?}"
);
response
}
#[tokio::test]
async fn a_routed_request_is_served_end_to_end() {
let response = with_server(test_state(DEFAULT_MAX_BODY_SIZE), |addr| async move {
roundtrip(
addr,
b"GET /echo?q=1 HTTP/1.1\r\nHost: a\r\nX-Trace-Id: abc\r\nConnection: close\r\n\r\n",
)
.await
})
.await;
assert!(
response.starts_with("HTTP/1.1 200 OK"),
"expected a 200: {response:?}"
);
assert!(
response.contains("method=GET"),
"the method must survive the bridge: {response:?}"
);
assert!(
response.contains("path=/echo?q=1"),
"the target must arrive whole, query included: {response:?}"
);
assert!(
response.contains("trace=abc"),
"a custom header must reach the handler: {response:?}"
);
assert!(
response.contains("peer=127.0.0.1:"),
"the peer address must be stamped onto the request, or every \
rate-limit and audit decision keyed on it silently loses the client: \
{response:?}"
);
}
#[tokio::test]
async fn a_request_body_reaches_the_handler() {
let response = with_server(test_state(DEFAULT_MAX_BODY_SIZE), |addr| async move {
roundtrip(
addr,
b"POST /echo HTTP/1.1\r\nHost: a\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello",
)
.await
})
.await;
assert!(response.starts_with("HTTP/1.1 200 OK"), "{response:?}");
assert!(
response.contains("body=hello"),
"the body must be read and handed over: {response:?}"
);
}
#[tokio::test]
async fn a_chunked_body_reaches_the_handler() {
let response = with_server(test_state(DEFAULT_MAX_BODY_SIZE), |addr| async move {
roundtrip(
addr,
b"POST /echo HTTP/1.1\r\nHost: a\r\nTransfer-Encoding: chunked\r\n\
Connection: close\r\n\r\n5\r\nhello\r\n0\r\n\r\n",
)
.await
})
.await;
assert!(response.starts_with("HTTP/1.1 200 OK"), "{response:?}");
assert!(
response.contains("body=hello"),
"a chunked body must be decoded, not handed over as frames: {response:?}"
);
}
#[tokio::test]
async fn keep_alive_serves_a_second_request_on_one_connection() {
let response = with_server(test_state(DEFAULT_MAX_BODY_SIZE), |addr| async move {
let mut stream = tokio::net::TcpStream::connect(addr).await.expect("connect");
stream
.write_all(b"GET /echo?first HTTP/1.1\r\nHost: a\r\n\r\n")
.await
.expect("write first");
let mut buf = [0u8; 4096];
let n = tokio::time::timeout(Duration::from_secs(5), stream.read(&mut buf))
.await
.expect("first response within 5s")
.expect("read");
let first = String::from_utf8_lossy(&buf[..n]).into_owned();
stream
.write_all(b"GET /echo?second HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n")
.await
.expect("write second");
let mut rest = Vec::new();
let _ = tokio::time::timeout(Duration::from_secs(5), stream.read_to_end(&mut rest)).await;
(first, String::from_utf8_lossy(&rest).into_owned())
})
.await;
let (first, second) = response;
assert!(
first.contains("path=/echo?first"),
"first response: {first:?}"
);
assert!(
second.contains("path=/echo?second"),
"the connection must be reused rather than closed after one request: \
{second:?}"
);
}
#[tokio::test]
async fn an_unrouted_path_is_a_404_not_a_dropped_connection() {
let (response, closed) = with_server(test_state(DEFAULT_MAX_BODY_SIZE), |addr| async move {
roundtrip_closed(
addr,
b"GET /nope HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n",
)
.await
})
.await;
assert!(
response.starts_with("HTTP/1.1 404"),
"an unrouted path must produce this framework's 404, which means the \
router was actually consulted: {response:?}"
);
assert!(
closed,
"the 404 arrived but the connection stayed open past the read \
deadline, which is the leak this test is named for: {response:?}"
);
}
#[tokio::test]
async fn a_declared_content_length_over_the_limit_is_refused_before_the_body() {
let (response, closed) = with_server(test_state(16), |addr| async move {
roundtrip_closed(
addr,
b"POST /echo HTTP/1.1\r\nHost: a\r\nContent-Length: 100\r\nConnection: close\r\n\r\n",
)
.await
})
.await;
assert!(
response.starts_with("HTTP/1.1 413"),
"an over-limit declaration must be refused with 413: {response:?}"
);
assert!(
response.contains("\"status\":413"),
"the framework's own 413 envelope must reach the client, not \
armature-h1's bare status line: {response:?}"
);
assert!(
closed,
"a refusal before the body must close the connection rather than wait \
for the body it declined to read: {response:?}"
);
}
#[tokio::test]
async fn an_undeclared_over_limit_body_is_refused_while_being_read() {
let mut request =
b"POST /echo HTTP/1.1\r\nHost: a\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n"
.to_vec();
request.extend_from_slice(format!("{:x}\r\n", 100).as_bytes());
request.extend_from_slice(&[b'x'; 100]);
request.extend_from_slice(b"\r\n0\r\n\r\n");
let (response, closed) = with_server(test_state(16), move |addr| async move {
roundtrip_closed(addr, &request).await
})
.await;
assert!(
response.starts_with("HTTP/1.1 413"),
"a chunked body over the cap must still be refused: {response:?}"
);
assert!(
response.contains("\"status\":413"),
"a mid-read refusal must produce the same envelope the declared-length \
refusal does, not a bare status line: {response:?}"
);
assert!(
closed,
"the read was abandoned mid-body, so the connection is out of sync \
with the sender and must not be reused: {response:?}"
);
}
#[tokio::test]
async fn a_body_error_that_is_not_a_413_is_not_dressed_as_one() {
let mut request =
b"POST /echo HTTP/1.1\r\nHost: a\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n"
.to_vec();
request.extend_from_slice(b"zz\r\nhello\r\n0\r\n\r\n");
let (response, closed) = with_server(test_state(16), move |addr| async move {
roundtrip_closed(addr, &request).await
})
.await;
assert!(
response.starts_with("HTTP/1.1 400"),
"an unparseable chunk size is a framing error, so it keeps the status \
armature-h1 assigned it rather than being mapped to something else: \
{response:?}"
);
assert!(
!response.contains("Payload Too Large"),
"a body this far under the cap was not refused for its size, and \
telling the client it was sends them to shrink a request that was \
never too big: {response:?}"
);
assert!(
closed,
"the framing is unrecoverable, so nothing further on this connection \
can be trusted to be a request boundary: {response:?}"
);
}
#[tokio::test]
async fn a_smuggling_shaped_request_is_rejected_rather_than_served() {
let (response, closed) = with_server(test_state(DEFAULT_MAX_BODY_SIZE), |addr| async move {
roundtrip_closed(
addr,
b"POST /echo HTTP/1.1\r\nHost: a\r\nContent-Length: 5\r\n\
Transfer-Encoding: chunked\r\n\r\n0\r\n\r\n",
)
.await
})
.await;
assert!(
response.starts_with("HTTP/1.1 400"),
"Content-Length together with Transfer-Encoding must be refused: \
{response:?}"
);
assert!(
closed,
"a request whose two framings disagree must take the connection with \
it; keeping it open is the smuggle the 400 was supposed to prevent: \
{response:?}"
);
}
use crate::guard::{Guard, GuardContext};
async fn echo_param(req: HttpRequest) -> Result<HttpResponse, Error> {
use crate::http::RouteParamsExt;
let id = req.path_params.get_str("id").unwrap_or("-").to_string();
Ok(HttpResponse::ok().with_body(format!("id={id}").into_bytes()))
}
async fn empty_ok(_req: HttpRequest) -> Result<HttpResponse, Error> {
Ok(HttpResponse::ok())
}
async fn no_content(_req: HttpRequest) -> Result<HttpResponse, Error> {
Ok(HttpResponse::new(204))
}
fn routed_state() -> ServeState {
let mut router = Router::new();
router.add_route(Route::new(HttpMethod::GET, "/echo", echo));
router.add_route(Route::new(HttpMethod::POST, "/echo", echo));
router.add_route(Route::new(HttpMethod::GET, "/u/:id", echo_param));
router.add_route(Route::new(HttpMethod::GET, "/empty", empty_ok));
router.add_route(Route::new(HttpMethod::GET, "/nothing", no_content));
router.add_route(Route::new(HttpMethod::HEAD, "/head", echo));
router.add_route(Route::new(HttpMethod::OPTIONS, "/echo", echo));
ServeState::for_test(
Arc::new(OptimizedRouter::from_router(&router)),
DEFAULT_MAX_BODY_SIZE,
)
}
struct DenyAll;
#[async_trait::async_trait]
impl Guard for DenyAll {
async fn can_activate(&self, _ctx: &GuardContext) -> Result<bool, Error> {
Ok(false)
}
}
struct ExplodingGuard;
#[async_trait::async_trait]
impl Guard for ExplodingGuard {
async fn can_activate(&self, _ctx: &GuardContext) -> Result<bool, Error> {
Err(Error::Unauthorized("no credentials".to_string()))
}
}
fn header_count(response: &str, name: &str) -> usize {
let head = response.split("\r\n\r\n").next().unwrap_or(response);
head.lines()
.filter(|line| {
line.split_once(':')
.is_some_and(|(k, _)| k.trim().eq_ignore_ascii_case(name))
})
.count()
}
#[tokio::test]
async fn a_cors_configured_response_carries_exactly_one_allow_origin() {
let state = routed_state().with_cors_for_test(crate::CorsConfig::new("https://example.test"));
let response = with_server(state, |addr| async move {
roundtrip(
addr,
b"GET /echo HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n",
)
.await
})
.await;
assert!(response.starts_with("HTTP/1.1 200 OK"), "{response:?}");
assert_eq!(
header_count(&response, "access-control-allow-origin"),
1,
"exactly one origin header — zero means CORS never reached the serve \
path, two means both the response path and something upstream added \
it: {response:?}"
);
}
#[tokio::test]
async fn an_options_preflight_is_answered_before_routing() {
let state = routed_state().with_cors_for_test(crate::CorsConfig::new("https://example.test"));
let response = with_server(state, |addr| async move {
roundtrip(
addr,
b"OPTIONS /echo HTTP/1.1\r\nHost: a\r\nOrigin: https://example.test\r\n\
Access-Control-Request-Method: POST\r\nConnection: close\r\n\r\n",
)
.await
})
.await;
assert!(
response.starts_with("HTTP/1.1 204"),
"a preflight is answered with 204, not routed: {response:?}"
);
assert_eq!(
header_count(&response, "access-control-allow-origin"),
1,
"the preflight builds its own complete header set, so adding the \
per-response origin on top would duplicate it: {response:?}"
);
assert!(
response
.to_ascii_lowercase()
.contains("access-control-allow-methods"),
"the preflight set must be complete: {response:?}"
);
}
#[tokio::test]
async fn a_plain_options_request_routes_even_with_cors_configured() {
let state = routed_state().with_cors_for_test(crate::CorsConfig::new("https://example.test"));
let response = with_server(state, |addr| async move {
roundtrip(
addr,
b"OPTIONS /echo HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n",
)
.await
})
.await;
assert!(
response.starts_with("HTTP/1.1 200 OK"),
"a non-preflight OPTIONS must reach its route, not the canned 204: \
{response:?}"
);
assert!(
response.contains("method=OPTIONS"),
"the handler must actually have run: {response:?}"
);
}
#[tokio::test]
async fn an_options_request_with_only_an_origin_still_routes() {
let state = routed_state().with_cors_for_test(crate::CorsConfig::new("https://example.test"));
let response = with_server(state, |addr| async move {
roundtrip(
addr,
b"OPTIONS /echo HTTP/1.1\r\nHost: a\r\nOrigin: https://example.test\r\n\
Connection: close\r\n\r\n",
)
.await
})
.await;
assert!(
response.starts_with("HTTP/1.1 200 OK"),
"only `Access-Control-Request-Method` marks a preflight: {response:?}"
);
}
#[tokio::test]
async fn a_denying_guard_produces_the_frameworks_403() {
let state = routed_state().with_guard_for_test(Arc::new(DenyAll));
let response = with_server(state, |addr| async move {
roundtrip(
addr,
b"GET /echo HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n",
)
.await
})
.await;
assert!(
response.starts_with("HTTP/1.1 403"),
"guards fail closed, so a refusal must reach the wire as a 403 rather \
than the handler running: {response:?}"
);
assert!(
response.contains("\"status\":403"),
"the framework's own 403 envelope, not a bare status line: {response:?}"
);
}
#[tokio::test]
async fn a_guard_returning_an_error_maps_to_its_status() {
let state = routed_state().with_guard_for_test(Arc::new(ExplodingGuard));
let response = with_server(state, |addr| async move {
roundtrip(
addr,
b"GET /echo HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n",
)
.await
})
.await;
assert!(
response.starts_with("HTTP/1.1 401"),
"a guard's error maps through the error path, which is distinct from \
the canned 403 a refusal produces: {response:?}"
);
}
#[tokio::test]
async fn a_path_parameter_is_extracted_and_reaches_the_handler() {
let response = with_server(routed_state(), |addr| async move {
roundtrip(
addr,
b"GET /u/42 HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n",
)
.await
})
.await;
assert!(response.starts_with("HTTP/1.1 200 OK"), "{response:?}");
assert!(
response.ends_with("id=42"),
"param extraction is a routing semantic that must survive the backend \
swap: {response:?}"
);
}
#[tokio::test]
async fn a_known_path_with_an_unregistered_method_is_a_404() {
let response = with_server(routed_state(), |addr| async move {
roundtrip(
addr,
b"DELETE /u/42 HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n",
)
.await
})
.await;
assert!(
response.starts_with("HTTP/1.1 404"),
"a known path with an unregistered method is a 404, not a 405 and not \
a match: {response:?}"
);
}
#[tokio::test]
async fn a_head_response_reports_a_length_but_sends_no_body_bytes() {
let response = with_server(routed_state(), |addr| async move {
roundtrip(
addr,
b"HEAD /head HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n",
)
.await
})
.await;
assert!(response.starts_with("HTTP/1.1 200 OK"), "{response:?}");
let (head, body) = response
.split_once("\r\n\r\n")
.expect("a complete response head");
assert!(
head.to_ascii_lowercase().contains("content-length:"),
"HEAD reports the length a GET would have sent, or a client cannot use \
it to size a fetch: {head:?}"
);
assert!(
!head.to_ascii_lowercase().contains("content-length: 0"),
"the reported length is the body a GET would produce, not zero: {head:?}"
);
assert!(
body.is_empty(),
"…but none of those bytes go on the wire: {body:?}"
);
}
#[tokio::test]
async fn head_is_not_derived_from_a_registered_get_route() {
let response = with_server(routed_state(), |addr| async move {
roundtrip(
addr,
b"HEAD /echo HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n",
)
.await
})
.await;
assert!(
response.starts_with("HTTP/1.1 404"),
"a GET-only route does not answer HEAD; if this ever starts passing as \
a 200, the router gained auto-derivation and this test should become \
the assertion that it did: {response:?}"
);
}
#[tokio::test]
async fn an_empty_200_is_framed_with_content_length_zero_but_a_204_is_not() {
let empty_200 = with_server(routed_state(), |addr| async move {
roundtrip(
addr,
b"GET /empty HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n",
)
.await
})
.await;
assert!(empty_200.starts_with("HTTP/1.1 200 OK"), "{empty_200:?}");
assert!(
empty_200.to_ascii_lowercase().contains("content-length: 0"),
"a 200 with an empty body still needs an explicit zero length, or the \
client cannot tell the body ended: {empty_200:?}"
);
let no_content = with_server(routed_state(), |addr| async move {
roundtrip(
addr,
b"GET /nothing HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n",
)
.await
})
.await;
assert!(no_content.starts_with("HTTP/1.1 204"), "{no_content:?}");
assert!(
!no_content.to_ascii_lowercase().contains("content-length"),
"a 204 must carry no body framing at all — this is the distinction a \
type-level assertion on ResponseBody cannot make, because it never \
reaches the writer: {no_content:?}"
);
}
#[tokio::test]
async fn two_pipelined_requests_are_answered_in_request_order() {
let response = with_server(routed_state(), |addr| async move {
let mut stream = tokio::net::TcpStream::connect(addr).await.expect("connect");
stream
.write_all(
b"GET /echo?first HTTP/1.1\r\nHost: a\r\n\r\n\
GET /echo?second HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n",
)
.await
.expect("write both requests");
let mut out = Vec::new();
tokio::time::timeout(Duration::from_secs(5), stream.read_to_end(&mut out))
.await
.expect("both responses within 5s")
.expect("read");
String::from_utf8_lossy(&out).into_owned()
})
.await;
let first = response
.find("path=/echo?first")
.unwrap_or_else(|| panic!("the first pipelined request was never answered: {response:?}"));
let second = response
.find("path=/echo?second")
.unwrap_or_else(|| panic!("the second pipelined request was never answered: {response:?}"));
assert!(
first < second,
"pipelined responses must come back in request order, because that \
position is the only thing pairing them with their requests: \
{response:?}"
);
}
#[tokio::test]
async fn an_expect_continue_request_receives_the_interim_response_and_is_served() {
let response = with_server(test_state(DEFAULT_MAX_BODY_SIZE), |addr| async move {
roundtrip(
addr,
b"POST /echo HTTP/1.1\r\nHost: a\r\nExpect: 100-continue\r\n\
Content-Length: 5\r\nConnection: close\r\n\r\nhello",
)
.await
})
.await;
assert!(
response.starts_with("HTTP/1.1 100 Continue"),
"a client that honours 100-continue waits for this before sending its \
body, so not sending it stalls the request until a timeout: \
{response:?}"
);
assert!(
response.contains("HTTP/1.1 200 OK"),
"the interim response is not the answer — the real one must follow it \
on the same connection: {response:?}"
);
assert!(
response.contains("body=hello"),
"the body must still reach the handler after the interim response: \
{response:?}"
);
}
#[tokio::test]
async fn an_expect_continue_request_that_sends_no_body_is_answered_rather_than_stalled() {
let observed = with_server(test_state(DEFAULT_MAX_BODY_SIZE), |addr| async move {
let mut stream = tokio::net::TcpStream::connect(addr).await.expect("connect");
stream
.write_all(
b"POST /echo HTTP/1.1\r\nHost: a\r\nExpect: 100-continue\r\n\
Content-Length: 5\r\nConnection: close\r\n\r\n",
)
.await
.expect("write the head");
let mut buf = [0u8; 4096];
let observed =
match tokio::time::timeout(Duration::from_secs(2), stream.read(&mut buf)).await {
Ok(Ok(n)) => String::from_utf8_lossy(&buf[..n]).into_owned(),
Ok(Err(e)) => format!("<io error: {e}>"),
Err(_) => String::new(),
};
drop(stream);
observed
})
.await;
assert!(
!observed.is_empty(),
"nothing arrived in 2s for a request that sent its head and stopped. \
That is the deadlock this test exists for: the client is waiting for \
the go-ahead `100 Continue` before sending its body, and the server is \
waiting for the body before writing anything — neither side times out \
and the request hangs until somebody's socket does"
);
assert!(
observed.starts_with("HTTP/1.1 100 Continue"),
"expected the interim go-ahead first: {observed:?}"
);
}
use crate::exception_filter::{ExceptionContext, ExceptionFilter, ExceptionFilterChain};
use std::sync::atomic::{AtomicBool, Ordering};
struct RecordingFilter {
ran: Arc<AtomicBool>,
}
#[async_trait::async_trait]
impl ExceptionFilter for RecordingFilter {
async fn catch(&self, _error: &Error, _ctx: &ExceptionContext) -> Option<HttpResponse> {
self.ran.store(true, Ordering::SeqCst);
Some(HttpResponse::new(599).with_body(b"caught-by-the-e2e-filter".to_vec()))
}
}
async fn always_fails(_req: HttpRequest) -> Result<HttpResponse, Error> {
Err(Error::Internal("handler boom".to_string()))
}
#[tokio::test]
async fn a_global_filters_response_reaches_the_wire_intact_over_h1() {
let ran = Arc::new(AtomicBool::new(false));
let mut router = Router::new();
router.add_route(Route::new(HttpMethod::GET, "/broken", always_fails));
let state = ServeState::for_test(
Arc::new(OptimizedRouter::from_router(&router)),
DEFAULT_MAX_BODY_SIZE,
)
.with_filter_chain_for_test(ExceptionFilterChain::new().add_filter(RecordingFilter {
ran: Arc::clone(&ran),
}));
let response = with_server(state, |addr| async move {
roundtrip(
addr,
b"GET /broken HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n",
)
.await
})
.await;
assert!(
response.starts_with("HTTP/1.1 599"),
"the filter's status must survive the h1 response conversion; a 500 \
here means the chain was consulted and its answer then discarded, or \
never consulted at all: {response:?}"
);
assert!(
response.contains("caught-by-the-e2e-filter"),
"the filter's body must survive too — a filter that keeps its status \
and loses its body has still lost everything it was written to say, \
and the status alone cannot tell the two apart: {response:?}"
);
assert!(
ran.load(Ordering::SeqCst),
"the filter never ran, so whatever produced the response above did so \
by coincidence: {response:?}"
);
assert!(
!response.contains("Internal Server Error"),
"a registered filter's answer replaces the default mapping rather than \
being merged with it: {response:?}"
);
}
#[tokio::test]
async fn dropping_the_serve_future_stops_the_listener() {
let cfg = h1_config(
"127.0.0.1:0".parse().expect("addr"),
&PipelineConfig::default(),
Some(1),
);
let (tx, rx) = tokio::sync::oneshot::channel();
let mut server = Box::pin(serve_bound(
cfg,
test_state(DEFAULT_MAX_BODY_SIZE),
None,
move |addr, _handle| {
let _ = tx.send(addr);
},
));
let addr = tokio::select! {
result = &mut server => panic!("the server stopped before it bound: {result:?}"),
addr = tokio::time::timeout(Duration::from_secs(5), rx) => addr
.expect("server bound within 5s")
.expect("bind address"),
};
let response = roundtrip(
addr,
b"GET /echo HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n",
)
.await;
assert!(
response.starts_with("HTTP/1.1 200 OK"),
"the server has to actually be up first, or the refused connection \
below would prove nothing: {response:?}"
);
drop(server);
let mut refused = false;
for _ in 0..100 {
match tokio::time::timeout(Duration::from_secs(1), tokio::net::TcpStream::connect(addr))
.await
{
Ok(Err(_)) => {
refused = true;
break;
}
_ => tokio::time::sleep(Duration::from_millis(50)).await,
}
}
assert!(
refused,
"the listener on {addr} was still accepting 5s after the serve future \
was dropped, so a cancelled `listen_on` leaves a bound socket and \
workers still serving requests nobody is waiting for"
);
}
async fn echo_without_peer(req: HttpRequest) -> Result<HttpResponse, Error> {
let body = format!(
"method={} path={} trace={} body={}",
req.method,
req.path,
req.headers.get("x-trace-id").unwrap_or("-"),
String::from_utf8_lossy(&req.body),
);
Ok(HttpResponse::ok().with_body(body.into_bytes()))
}
async fn echo_client_address(req: HttpRequest) -> Result<HttpResponse, Error> {
let client = req
.client_address(1)
.map_or("-".to_string(), |ip| ip.to_string());
Ok(HttpResponse::ok().with_body(format!("client={client}").into_bytes()))
}
async fn hostile_headers(_req: HttpRequest) -> Result<HttpResponse, Error> {
let mut response = HttpResponse::ok().with_body(b"hi".to_vec());
response
.headers
.insert("Connection".to_string(), "keep-alive".to_string());
response
.headers
.insert("Content-Length".to_string(), "999".to_string());
response
.headers
.insert("Transfer-Encoding".to_string(), "chunked".to_string());
response.cookies.push("a=1; Secure".to_string());
response.cookies.push("b=2; HttpOnly".to_string());
Ok(response)
}
fn parity_state(max_body_size: usize) -> ServeState {
let mut router = Router::new();
router.add_route(Route::new(HttpMethod::GET, "/echo", echo_without_peer));
router.add_route(Route::new(HttpMethod::GET, "/hostile", hostile_headers));
router.add_route(Route::new(HttpMethod::OPTIONS, "/echo", echo_without_peer));
router.add_route(Route::new(HttpMethod::POST, "/echo", echo_without_peer));
router.add_route(Route::new(HttpMethod::GET, "/empty", empty_ok));
router.add_route(Route::new(HttpMethod::GET, "/nothing", no_content));
router.add_route(Route::new(HttpMethod::HEAD, "/head", echo_without_peer));
router.add_route(Route::new(HttpMethod::GET, "/client", echo_client_address));
ServeState::for_test(
Arc::new(OptimizedRouter::from_router(&router)),
max_body_size,
)
}
#[tokio::test]
async fn a_handlers_framing_headers_never_reach_the_wire() {
let response = with_server(parity_state(DEFAULT_MAX_BODY_SIZE), |addr| async move {
roundtrip(
addr,
b"GET /hostile HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n",
)
.await
})
.await;
assert!(response.starts_with("HTTP/1.1 200 OK"), "{response:?}");
let (head, body) = response
.split_once("\r\n\r\n")
.expect("a complete response head");
assert_eq!(
header_count(&response, "content-length"),
1,
"exactly one length: two would make the message unparseable, and zero \
on a 200 with a body leaves the client unable to tell where it ends: \
{head:?}"
);
let length = head
.lines()
.find_map(|line| {
line.split_once(':')
.filter(|(k, _)| k.trim().eq_ignore_ascii_case("content-length"))
})
.map(|(_, v)| v.trim().to_string())
.expect("the one content-length asserted above");
assert_eq!(
length,
body.len().to_string(),
"the length on the wire must be the body actually sent, not the 999 \
the handler declared; a client honouring the handler's value blocks \
waiting for 997 bytes that will never arrive: {response:?}"
);
assert_eq!(body, "hi", "the body itself must survive: {body:?}");
assert_eq!(
header_count(&response, "transfer-encoding"),
0,
"a handler claiming chunked encoding for a body the loop framed with a \
length gives the client two contradictory framings, which is the \
request-smuggling shape in the response direction: {head:?}"
);
assert!(
!head.to_ascii_lowercase().contains("connection: keep-alive"),
"the request asked for `close` and the loop is about to close; the \
handler's `keep-alive` must not talk it out of saying so, or a pooling \
proxy reuses a socket the server has hung up: {head:?}"
);
assert_eq!(
header_count(&response, "set-cookie"),
2,
"two cookies must emit two field lines, not one comma-joined value — a \
client parses only the first and the second cookie is silently lost: \
{head:?}"
);
}
async fn hyper_roundtrip(state: ServeState, request: &[u8]) -> String {
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Request, body::Incoming};
use hyper_util::rt::TokioIo;
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.expect("bind the hyper listener");
let addr = listener.local_addr().expect("hyper listener address");
let server = tokio::spawn(async move {
let (stream, peer) = listener.accept().await.expect("accept");
let state = state.for_peer(peer);
let service = service_fn(move |req: Request<Incoming>| {
let state = state.clone();
async move { crate::application::handle_request(req, state).await }
});
let _ = http1::Builder::new()
.serve_connection(TokioIo::new(stream), service)
.await;
});
let mut stream = tokio::net::TcpStream::connect(addr)
.await
.expect("connect to the hyper server");
stream.write_all(request).await.expect("write request");
let mut out = Vec::new();
tokio::time::timeout(Duration::from_secs(5), stream.read_to_end(&mut out))
.await
.expect("the hyper adapter answered within 5s")
.expect("read");
tokio::time::timeout(Duration::from_secs(5), server)
.await
.expect("the hyper connection task finished within 5s")
.expect("the hyper connection task panicked");
String::from_utf8_lossy(&out).into_owned()
}
#[tokio::test]
async fn a_plain_options_request_routes_over_the_hyper_adapter_too() {
let state = parity_state(DEFAULT_MAX_BODY_SIZE)
.with_cors_for_test(crate::CorsConfig::new("https://example.test"));
let response = hyper_roundtrip(
state,
b"OPTIONS /echo HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n",
)
.await;
assert!(
response.starts_with("HTTP/1.1 200 OK"),
"configuring CORS must not make every `OPTIONS` route unreachable over \
HTTP/2; a 204 here is the canned preflight answering a request that \
was not one: {response:?}"
);
assert!(
response.contains("method=OPTIONS"),
"the registered handler must actually have run: {response:?}"
);
}
#[tokio::test]
async fn a_real_preflight_is_intercepted_by_the_hyper_adapter() {
let state = parity_state(DEFAULT_MAX_BODY_SIZE)
.with_cors_for_test(crate::CorsConfig::new("https://example.test"));
let response = hyper_roundtrip(
state,
b"OPTIONS /echo HTTP/1.1\r\nHost: a\r\nOrigin: https://example.test\r\n\
Access-Control-Request-Method: POST\r\nConnection: close\r\n\r\n",
)
.await;
assert!(
response.starts_with("HTTP/1.1 204"),
"a preflight is answered before routing, or the browser never gets the \
permission it asked for and refuses the real request: {response:?}"
);
assert_eq!(
header_count(&response, "access-control-allow-origin"),
1,
"the preflight builds its own complete header set, so adding the \
per-response origin on top would duplicate it: {response:?}"
);
assert!(
response
.to_ascii_lowercase()
.contains("access-control-allow-methods"),
"the preflight set must be complete: {response:?}"
);
}
fn normalised(response: &str) -> String {
let (head, body) = response.split_once("\r\n\r\n").unwrap_or((response, ""));
let mut lines = head.split("\r\n");
let status = lines
.next()
.unwrap_or_default()
.split_whitespace()
.take(2)
.collect::<Vec<_>>()
.join(" ");
let mut headers: Vec<String> = lines
.filter(|line| {
let name = line
.split_once(':')
.map_or_else(String::new, |(k, _)| k.trim().to_ascii_lowercase());
name != "date" && name != "server"
})
.map(|line| line.trim().to_ascii_lowercase())
.filter(|line| !line.is_empty())
.collect();
headers.sort();
format!("{status}\n{}\n\n{body}", headers.join("\n"))
}
#[tokio::test]
async fn the_two_adapters_answer_the_same_request_the_same_way() {
let cors = crate::CorsConfig::new("https://example.test");
let cases: Vec<(&str, ServeState, &[u8])> = vec![
(
"a routed 200",
parity_state(DEFAULT_MAX_BODY_SIZE),
b"GET /echo?q=1 HTTP/1.1\r\nHost: a\r\nX-Trace-Id: abc\r\nConnection: close\r\n\r\n",
),
(
"an unrouted 404",
parity_state(DEFAULT_MAX_BODY_SIZE),
b"GET /nope HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n",
),
(
"a declared-length 413",
parity_state(16),
b"POST /echo HTTP/1.1\r\nHost: a\r\nContent-Length: 100\r\nConnection: close\r\n\r\n",
),
(
"an OPTIONS preflight",
parity_state(DEFAULT_MAX_BODY_SIZE).with_cors_for_test(cors.clone()),
b"OPTIONS /echo HTTP/1.1\r\nHost: a\r\nOrigin: https://example.test\r\n\
Access-Control-Request-Method: POST\r\nConnection: close\r\n\r\n",
),
(
"a bare OPTIONS that is not a preflight",
parity_state(DEFAULT_MAX_BODY_SIZE).with_cors_for_test(cors.clone()),
b"OPTIONS /echo HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n",
),
(
"a handler that sets framing headers and two cookies",
parity_state(DEFAULT_MAX_BODY_SIZE),
b"GET /hostile HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n",
),
(
"a guard refusal",
parity_state(DEFAULT_MAX_BODY_SIZE).with_guard_for_test(Arc::new(DenyAll)),
b"GET /echo HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n",
),
(
"a HEAD",
parity_state(DEFAULT_MAX_BODY_SIZE),
b"HEAD /head HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n",
),
(
"an empty 200",
parity_state(DEFAULT_MAX_BODY_SIZE),
b"GET /empty HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n",
),
(
"a 204",
parity_state(DEFAULT_MAX_BODY_SIZE),
b"GET /nothing HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n",
),
(
"a repeated X-Forwarded-For",
parity_state(DEFAULT_MAX_BODY_SIZE),
b"GET /client HTTP/1.1\r\nHost: a\r\nX-Forwarded-For: 198.51.100.9\r\n\
X-Forwarded-For: 203.0.113.7\r\nConnection: close\r\n\r\n",
),
];
let mut divergences = Vec::new();
for (name, state, request) in cases {
let via_hyper = normalised(&hyper_roundtrip(state.clone(), request).await);
let request = request.to_vec();
let via_h1 = with_server(
state,
move |addr| async move { roundtrip(addr, &request).await },
)
.await;
let via_h1 = normalised(&via_h1);
if via_hyper != via_h1 {
divergences.push(format!(
"\n=== {name} ===\n--- hyper ---\n{via_hyper}\n--- armature-h1 ---\n{via_h1}"
));
}
}
assert!(
divergences.is_empty(),
"the two adapters answered the same bytes differently. Both are live \
in a default build — hyper serves HTTP/2, armature-h1 serves \
HTTP/1.1 — so each difference below is one request getting two \
answers depending only on which protocol the client negotiated:{}",
divergences.join("")
);
}
#[tokio::test]
async fn the_two_adapters_spell_413s_reason_phrase_differently() {
let request: &[u8] =
b"POST /echo HTTP/1.1\r\nHost: a\r\nContent-Length: 100\r\nConnection: close\r\n\r\n";
let via_hyper = hyper_roundtrip(parity_state(16), request).await;
let via_h1 = with_server(parity_state(16), move |addr| async move {
roundtrip(addr, request).await
})
.await;
assert!(
via_hyper.starts_with("HTTP/1.1 413 Payload Too Large"),
"hyper's status table is the pre-RFC-9110 spelling; a change here means \
the divergence moved rather than closed: {via_hyper:?}"
);
assert!(
via_h1.starts_with("HTTP/1.1 413 Content Too Large"),
"armature-h1's status table is the current RFC 9110 spelling; a change \
here means the divergence moved rather than closed: {via_h1:?}"
);
for response in [&via_hyper, &via_h1] {
assert!(
response.contains("\"error\":\"Payload Too Large\",\"status\":413"),
"the envelope callers parse must be byte-identical on both \
adapters even though the advisory phrase is not: {response:?}"
);
}
}