nautalid 0.1.0

Scratch container substrate — TLS 1.3 HTTP/2+3 kernel, LID/AetherDB, optional filter bus (GPL-3.0-or-later).
//! HTTP DoS hardening: body size cap and request timeout.

use std::time::Duration;

use axum::body::Body;
use axum::extract::Request;
use axum::middleware::Next;
use axum::response::Response;
use axum::extract::DefaultBodyLimit;
use axum::http::header::{self, CONNECTION};
use axum::http::StatusCode;
use bytes::BytesMut;
use http_body_util::BodyExt;
use tower_http::timeout::TimeoutLayer;

const DEFAULT_BODY_BYTES: usize = 1024 * 1024;
const DEFAULT_TIMEOUT_SECS: u64 = 30;

#[must_use]
pub fn body_limit_from_env() -> usize {
    std::env::var("NAUTALID_HTTP_BODY_LIMIT_BYTES")
        .ok()
        .and_then(|s| s.parse().ok())
        .unwrap_or(DEFAULT_BODY_BYTES)
        .max(1024)
}

#[must_use]
pub fn request_timeout_from_env() -> Duration {
    let secs = std::env::var("NAUTALID_HTTP_TIMEOUT_SECS")
        .ok()
        .and_then(|s| s.parse().ok())
        .unwrap_or(DEFAULT_TIMEOUT_SECS)
        .max(1);
    Duration::from_secs(secs)
}

/// Returns **`true`** when **`chunk_len`** bytes may be appended without exceeding **`max_bytes`**.
#[must_use]
pub fn response_chunk_fits(sent: usize, chunk_len: usize, max_bytes: usize) -> bool {
    sent.saturating_add(chunk_len) <= max_bytes
}

/// Alias retained for H3 transport call sites.
#[must_use]
pub fn h3_response_chunk_fits(sent: usize, chunk_len: usize, max_bytes: usize) -> bool {
    response_chunk_fits(sent, chunk_len, max_bytes)
}

/// Cap outbound HTTP response bodies (H2 via Axum middleware; H3 uses the same limit inline).
pub async fn cap_response_body(request: Request, next: Next) -> Response {
    let max_bytes = body_limit_from_env();
    let response = next.run(request).await;
    let (mut parts, body) = response.into_parts();
    let (body, truncated) =
        cap_body_frames(body, max_bytes, request_timeout_from_env()).await;
    if truncated {
        parts.status = StatusCode::SERVICE_UNAVAILABLE;
        parts.headers.insert(CONNECTION, header::HeaderValue::from_static("close"));
    }
    Response::from_parts(parts, body)
}

/// Cap an outbound body to **`max_bytes`**, returning whether the source exceeded the limit.
pub(crate) async fn cap_body_frames(body: Body, max_bytes: usize, read_timeout: Duration) -> (Body, bool) {
    let deadline = tokio::time::Instant::now() + read_timeout;
    let mut body = std::pin::pin!(body);
    let mut out = BytesMut::new();
    let mut truncated = false;
    loop {
        let frame = tokio::time::timeout_at(deadline, body.frame()).await;
        let Ok(maybe_frame) = frame else {
            tracing::warn!(max = max_bytes, "http response body read timed out at cap");
            truncated = true;
            break;
        };
        let Some(frame_result) = maybe_frame else {
            break;
        };
        let Ok(frame) = frame_result else {
            truncated = true;
            break;
        };
        let Some(chunk) = frame.data_ref() else {
            continue;
        };
        if chunk.is_empty() {
            continue;
        }
        let room = max_bytes.saturating_sub(out.len());
        if chunk.len() > room {
            if room > 0 {
                out.extend_from_slice(&chunk[..room]);
            }
            tracing::warn!(
                sent = out.len(),
                max = max_bytes,
                "http response body truncated at limit"
            );
            truncated = true;
            break;
        }
        out.extend_from_slice(chunk);
    }
    (Body::from(out.freeze()), truncated)
}

/// Layers applied to the outer HTTP router (body limit + wall-clock timeout).
pub fn layers() -> (DefaultBodyLimit, TimeoutLayer) {
    (
        DefaultBodyLimit::max(body_limit_from_env()),
        TimeoutLayer::with_status_code(StatusCode::REQUEST_TIMEOUT, request_timeout_from_env()),
    )
}

#[cfg(test)]
mod tests {
    use super::{cap_body_frames, response_chunk_fits, Body};

    #[test]
    fn response_chunk_fits_respects_limit() {
        assert!(response_chunk_fits(0, 100, 100));
        assert!(response_chunk_fits(50, 50, 100));
        assert!(!response_chunk_fits(50, 51, 100));
        assert!(!response_chunk_fits(usize::MAX, 1, 100));
    }

    #[tokio::test]
    async fn cap_body_frames_truncates_large_response() {
        let body = Body::from("0123456789".repeat(10));
        let (capped, truncated) = cap_body_frames(body, 25, std::time::Duration::from_secs(5)).await;
        let bytes = axum::body::to_bytes(capped, 128).await.expect("read capped body");
        assert_eq!(bytes.len(), 25);
        assert!(truncated);
    }
}