server/lib/tests/door_request_body_and_read_only.rs
//! Two gates that fire in the HTTP door's PREAMBLE, before routing — both only
//! observable over a live gateway, because both are about what `handle` does with
//! a request before it ever reaches a backend.
//!
//! * **a read's body is never collected.** The body cap exists to bound memory on
//! an upload; it was applied to every verb, so a GET was buffered up to the same
//! ceiling (a GIGABYTE by default) for a request that then ignores the bytes.
use std::io::Write;
use traits::test_support::hold_port;
use holger_server_lib::{read_ron_config, wire_holger, Holger};
struct Running {
_data: tempfile::TempDir,
_cfg: tempfile::NamedTempFile,
base: String,
}
/// One writable `raw` repository behind a cleartext HTTP door, with an explicit
/// body cap and operation mode. No auth: the open-read default posture, so these
/// tests are about the door's own gates and nothing else. Retries on a lost port
/// race so a flake cannot be mistaken for a security regression.
fn start(mode: &str, max_body: usize) -> Running {
for _ in 0..8 {
let (grpc, http) = (hold_port(), hold_port());
if let Some(r) = try_start(mode, max_body, grpc, http) {
return r;
}
}
panic!("could not bind a free loopback port pair");
}
fn try_start(mode: &str, max_body: usize, grpc: u16, http: u16) -> Option<Running> {
let data = tempfile::tempdir().unwrap();
let ron = format!(
r#"(
operation_mode: {mode},
repositories: [
(ron_name: "raw", ron_repo_type: "raw", ron_upstreams: [], ron_in: None,
ron_out: Some((ron_storage_endpoint: "storage", ron_exposed_endpoint: "default")),
ron_data_dir: Some("{data}")),
],
exposed_endpoints: [
(ron_name: "default", ron_url: "127.0.0.1:{grpc}", ron_http_url: Some("127.0.0.1:{http}"),
ron_max_body_bytes: Some({max_body})),
],
storage_endpoints: [ (ron_name: "storage", ron_storage_type: "znippy", ron_path: "{data}") ],
)"#,
data = data.path().display(),
);
let mut cfg = tempfile::NamedTempFile::new().unwrap();
cfg.write_all(ron.as_bytes()).unwrap();
let mut holger: Holger = read_ron_config(cfg.path()).unwrap();
holger.instantiate_backends().unwrap();
wire_holger(&mut holger).unwrap();
holger.start().ok()?;
Some(Running {
_data: data,
_cfg: cfg,
base: format!("http://127.0.0.1:{http}"),
})
}
async fn wait_healthy(c: &reqwest::Client, base: &str) {
for _ in 0..100 {
if let Ok(r) = c.get(format!("{base}/healthz")).send().await {
if r.status() == 200 {
return;
}
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
panic!("gateway never became healthy at {base}");
}
/// **A read's request body is never collected.**
///
/// The door used to run `Limited::new(req.into_body(), max_body_bytes).collect()`
/// for EVERY verb, so a `GET` carrying a body was buffered into memory up to the
/// cap — a gigabyte on a default endpoint — and then discarded, because no read
/// path looks at a request body. It cost nothing to send and nothing to
/// authenticate: reads are open by default.
///
/// The cap is a 4 KiB endpoint here so the test can send a body that crosses it
/// without moving real megabytes. RED-when-broken: with the collect back on every
/// verb, the GET below is refused 413 — proof the door read the bytes it had no
/// use for — instead of answering the read.
#[tokio::test(flavor = "multi_thread")]
async fn a_read_is_not_charged_for_a_body_it_never_looks_at() {
let r = start("ReadWrite", 4096);
let cl = tls_roots(reqwest::Client::builder())
.build()
.expect("reqwest client");
wait_healthy(&cl, &r.base).await;
let oversized = vec![b'x'; 8192];
// The control: on a WRITE the cap still bites, exactly as before. The upload
// DoS guard is untouched.
let put = cl
.put(format!("{}/raw/big.bin", r.base))
.body(oversized.clone())
.send()
.await
.unwrap();
assert_eq!(put.status(), 413, "an oversized upload is still refused");
// The guarantee: the same oversized body on a GET is not read at all, so the
// request is answered on its merits (the blob does not exist ⇒ 404).
let get = cl
.request(reqwest::Method::GET, format!("{}/raw/absent.bin", r.base))
.body(oversized.clone())
.send()
.await
.unwrap();
assert_eq!(
get.status(),
404,
"a GET must be dispatched without buffering its body, got {}",
get.status()
);
// And a read that DOES resolve still resolves, body or no body — skipping the
// collect must not break the read path itself.
let seed = cl
.put(format!("{}/raw/present.bin", r.base))
.body(b"hello".to_vec())
.send()
.await
.unwrap();
assert_eq!(seed.status(), 201);
let got = cl
.request(reqwest::Method::GET, format!("{}/raw/present.bin", r.base))
.body(oversized)
.send()
.await
.unwrap();
assert_eq!(got.status(), 200);
assert_eq!(got.bytes().await.unwrap().as_ref(), b"hello");
}
/// reqwest 0.13 with reqwest 0.12 `rustls-tls` behaviour (2026-10-02): the ring
/// provider and the bundled Mozilla roots (webpki), not 0.13's aws-lc-rs + OS
/// trust store. Roots added later on the builder are merged in, as in 0.12.
#[allow(dead_code)]
fn tls_roots(b: reqwest::ClientBuilder) -> reqwest::ClientBuilder {
let _ = rustls::crypto::ring::default_provider().install_default();
b.tls_certs_only(
webpki_root_certs::TLS_SERVER_ROOT_CERTS
.iter()
.filter_map(|c| reqwest::Certificate::from_der(c).ok()),
)
}