Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

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()),
    )
}