eggserve-core 0.1.2

Security policy, path confinement, and static-serving primitives for eggserve
Documentation
use std::sync::Arc;
use std::time::Duration;

use eggserve_core::config::ServeConfig;
use eggserve_core::primitives::canonical::{Response, ResponseBody, StatusCode};
use eggserve_core::primitives::request_body_policy::RequestBodyPolicy;
use eggserve_core::server::config::RuntimeConfig;
use eggserve_core::server::handle::ServerHandle;
use eggserve_core::server::{service_fn_with_policy, Server};
use tempfile::TempDir;
use tokio::io::{AsyncReadExt, AsyncWriteExt};

async fn start_server_with_service<F, Fut>(
    config: RuntimeConfig,
    service: F,
    policy: RequestBodyPolicy,
) -> (ServerHandle, TempDir)
where
    F: Fn(eggserve_core::primitives::request::Request) -> Fut + Send + Sync + 'static,
    Fut: std::future::Future<Output = Result<Response, eggserve_core::server::ServiceError>>
        + Send
        + 'static,
{
    let tmp = TempDir::new().unwrap();
    let serve_config = Arc::new(ServeConfig {
        root: tmp.path().to_path_buf(),
        ..ServeConfig::default()
    });
    let server = Server::builder()
        .runtime(config)
        .serve_config(serve_config)
        .build()
        .unwrap();
    let handle = server
        .start_with_service(service_fn_with_policy(service, policy))
        .await
        .unwrap();
    (handle, tmp)
}

#[tokio::test]
async fn buffer_mode_post_with_body() {
    let config = RuntimeConfig::builder()
        .bind("127.0.0.1:0".parse().unwrap())
        .max_request_body_bytes(1024)
        .body_read_timeout(Duration::from_secs(5))
        .build()
        .unwrap();

    let (handle, _tmp) = start_server_with_service(
        config,
        |req: eggserve_core::primitives::request::Request| async move {
            let (head, body) = req.into_head_and_body();
            assert_eq!(head.method().as_str(), "POST");
            let data = body.read_all().await.unwrap();
            Ok(Response::builder()
                .status(StatusCode::OK)
                .body(ResponseBody::Bytes(data.to_vec()))
                .unwrap())
        },
        RequestBodyPolicy::Buffer { max_bytes: 1024 },
    )
    .await;
    let addr = handle.local_addr();

    let mut conn = tokio::net::TcpStream::connect(addr).await.unwrap();
    conn.write_all(b"POST /test HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello")
        .await
        .unwrap();
    let mut buf = Vec::new();
    conn.read_to_end(&mut buf).await.unwrap();
    let response = String::from_utf8_lossy(&buf);
    assert!(
        response.starts_with("HTTP/1.1 200"),
        "expected 200, got: {}",
        response
    );
    assert!(
        response.contains("hello"),
        "response should contain body: {}",
        response
    );
    handle.shutdown();
}

#[tokio::test]
async fn reject_policy_gets_empty_body() {
    let config = RuntimeConfig::builder()
        .bind("127.0.0.1:0".parse().unwrap())
        .max_request_body_bytes(1024)
        .build()
        .unwrap();

    let (handle, _tmp) = start_server_with_service(
        config,
        |_req: eggserve_core::primitives::request::Request| async move {
            Ok(Response::builder()
                .status(StatusCode::OK)
                .body(ResponseBody::Bytes(b"no body".to_vec()))
                .unwrap())
        },
        RequestBodyPolicy::Reject,
    )
    .await;
    let addr = handle.local_addr();

    let mut conn = tokio::net::TcpStream::connect(addr).await.unwrap();
    conn.write_all(b"POST /test HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello")
        .await
        .unwrap();
    let mut buf = Vec::new();
    conn.read_to_end(&mut buf).await.unwrap();
    let response = String::from_utf8_lossy(&buf);
    assert!(
        response.starts_with("HTTP/1.1 413"),
        "expected 413, got: {}",
        response
    );
    handle.shutdown();
}

#[tokio::test]
async fn body_limit_exceeded_returns_413() {
    let config = RuntimeConfig::builder()
        .bind("127.0.0.1:0".parse().unwrap())
        .max_request_body_bytes(5)
        .body_read_timeout(Duration::from_secs(5))
        .build()
        .unwrap();

    let (handle, _tmp) = start_server_with_service(
        config,
        |_req: eggserve_core::primitives::request::Request| async move {
            unreachable!("service should not be called");
        },
        RequestBodyPolicy::Buffer { max_bytes: 1024 },
    )
    .await;
    let addr = handle.local_addr();

    let mut conn = tokio::net::TcpStream::connect(addr).await.unwrap();
    conn.write_all(b"POST /test HTTP/1.1\r\nHost: localhost\r\nContent-Length: 10\r\nConnection: close\r\n\r\nhello world")
        .await
        .unwrap();
    let mut buf = Vec::new();
    conn.read_to_end(&mut buf).await.unwrap();
    let response = String::from_utf8_lossy(&buf);
    assert!(
        response.starts_with("HTTP/1.1 413"),
        "expected 413, got: {}",
        response
    );
    handle.shutdown();
}

#[tokio::test]
async fn declared_length_too_large_returns_413() {
    let config = RuntimeConfig::builder()
        .bind("127.0.0.1:0".parse().unwrap())
        .max_request_body_bytes(5)
        .build()
        .unwrap();

    let (handle, _tmp) = start_server_with_service(
        config,
        |_req: eggserve_core::primitives::request::Request| async move {
            unreachable!("service should not be called");
        },
        RequestBodyPolicy::Buffer { max_bytes: 1024 },
    )
    .await;
    let addr = handle.local_addr();

    let mut conn = tokio::net::TcpStream::connect(addr).await.unwrap();
    conn.write_all(b"POST /test HTTP/1.1\r\nHost: localhost\r\nContent-Length: 100\r\nConnection: close\r\n\r\n")
        .await
        .unwrap();
    let mut buf = Vec::new();
    conn.read_to_end(&mut buf).await.unwrap();
    let response = String::from_utf8_lossy(&buf);
    assert!(
        response.starts_with("HTTP/1.1 413"),
        "expected 413, got: {}",
        response
    );
    handle.shutdown();
}

#[tokio::test]
async fn empty_post_with_content_length_zero() {
    let config = RuntimeConfig::builder()
        .bind("127.0.0.1:0".parse().unwrap())
        .max_request_body_bytes(1024)
        .build()
        .unwrap();

    let (handle, _tmp) = start_server_with_service(
        config,
        |req: eggserve_core::primitives::request::Request| async move {
            let (head, body) = req.into_head_and_body();
            assert_eq!(head.method().as_str(), "POST");
            let data = body.read_all().await.unwrap();
            assert!(data.is_empty());
            Ok(Response::builder()
                .status(StatusCode::OK)
                .body(ResponseBody::Bytes(b"ok".to_vec()))
                .unwrap())
        },
        RequestBodyPolicy::Buffer { max_bytes: 1024 },
    )
    .await;
    let addr = handle.local_addr();

    let mut conn = tokio::net::TcpStream::connect(addr).await.unwrap();
    conn.write_all(
        b"POST /test HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
    )
    .await
    .unwrap();
    let mut buf = Vec::new();
    conn.read_to_end(&mut buf).await.unwrap();
    let response = String::from_utf8_lossy(&buf);
    assert!(
        response.starts_with("HTTP/1.1 200"),
        "expected 200, got: {}",
        response
    );
    handle.shutdown();
}

#[tokio::test]
async fn get_with_body_follows_service_policy() {
    let config = RuntimeConfig::builder()
        .bind("127.0.0.1:0".parse().unwrap())
        .max_request_body_bytes(1024)
        .build()
        .unwrap();

    let (handle, _tmp) = start_server_with_service(
        config,
        |req: eggserve_core::primitives::request::Request| async move {
            let (_head, body) = req.into_head_and_body();
            assert_eq!(&body.read_all().await.unwrap()[..], b"hello");
            Ok(Response::builder()
                .status(StatusCode::OK)
                .body(ResponseBody::Bytes(b"ok".to_vec()))
                .unwrap())
        },
        RequestBodyPolicy::Buffer { max_bytes: 1024 },
    )
    .await;
    let addr = handle.local_addr();

    let mut conn = tokio::net::TcpStream::connect(addr).await.unwrap();
    conn.write_all(b"GET /test HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello")
        .await
        .unwrap();
    let mut buf = Vec::new();
    conn.read_to_end(&mut buf).await.unwrap();
    let response = String::from_utf8_lossy(&buf);
    assert!(
        response.starts_with("HTTP/1.1 200"),
        "expected 200, got: {}",
        response
    );
    handle.shutdown();
}

#[tokio::test]
async fn stream_mode_chunked_body() {
    let config = RuntimeConfig::builder()
        .bind("127.0.0.1:0".parse().unwrap())
        .max_request_body_bytes(1024)
        .body_read_timeout(Duration::from_secs(5))
        .build()
        .unwrap();

    let (handle, _tmp) = start_server_with_service(
        config,
        |req: eggserve_core::primitives::request::Request| async move {
            let (head, mut body) = req.into_head_and_body();
            assert_eq!(head.method().as_str(), "POST");
            let mut all = Vec::new();
            while let Some(chunk) = body.next_chunk().await.unwrap() {
                all.extend_from_slice(&chunk);
            }
            Ok(Response::builder()
                .status(StatusCode::OK)
                .body(ResponseBody::Bytes(all))
                .unwrap())
        },
        RequestBodyPolicy::Stream { max_bytes: 1024 },
    )
    .await;
    let addr = handle.local_addr();

    let mut conn = tokio::net::TcpStream::connect(addr).await.unwrap();
    conn.write_all(b"POST /test HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n")
        .await
        .unwrap();
    conn.write_all(b"5\r\nhello\r\n").await.unwrap();
    conn.write_all(b"6\r\n world\r\n").await.unwrap();
    conn.write_all(b"0\r\n\r\n").await.unwrap();
    let mut buf = Vec::new();
    conn.read_to_end(&mut buf).await.unwrap();
    let response = String::from_utf8_lossy(&buf);
    assert!(
        response.starts_with("HTTP/1.1 200"),
        "expected 200, got: {}",
        response
    );
    assert!(
        response.contains("hello world"),
        "response should contain body: {}",
        response
    );
    handle.shutdown();
}

#[tokio::test]
async fn static_service_post_body_is_rejected_before_method_dispatch() {
    let tmp = TempDir::new().unwrap();
    std::fs::write(tmp.path().join("hello.txt"), "hello").unwrap();
    let config = RuntimeConfig::builder()
        .bind("127.0.0.1:0".parse().unwrap())
        .build()
        .unwrap();
    let serve_config = Arc::new(ServeConfig {
        root: tmp.path().to_path_buf(),
        ..ServeConfig::default()
    });
    let server = Server::builder()
        .runtime(config)
        .serve_config(serve_config)
        .build()
        .unwrap();
    let handle = server.start().await.unwrap();
    let addr = handle.local_addr();

    let mut conn = tokio::net::TcpStream::connect(addr).await.unwrap();
    conn.write_all(b"POST /hello.txt HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello")
        .await
        .unwrap();
    let mut buf = Vec::new();
    conn.read_to_end(&mut buf).await.unwrap();
    let response = String::from_utf8_lossy(&buf);
    assert!(
        response.starts_with("HTTP/1.1 413"),
        "expected 413 for POST body to static service, got: {}",
        response
    );
    handle.shutdown();
}

#[tokio::test]
async fn body_timeout_returns_408() {
    let config = RuntimeConfig::builder()
        .bind("127.0.0.1:0".parse().unwrap())
        .max_request_body_bytes(1024)
        .body_read_timeout(Duration::from_millis(50))
        .build()
        .unwrap();

    let (handle, _tmp) = start_server_with_service(
        config,
        |_req: eggserve_core::primitives::request::Request| async move {
            unreachable!("service should not be called");
        },
        RequestBodyPolicy::Buffer { max_bytes: 1024 },
    )
    .await;
    let addr = handle.local_addr();

    let mut conn = tokio::net::TcpStream::connect(addr).await.unwrap();
    conn.write_all(b"POST /test HTTP/1.1\r\nHost: localhost\r\nContent-Length: 100\r\nConnection: close\r\n\r\n")
        .await
        .unwrap();
    let mut buf = Vec::new();
    let _ = tokio::time::timeout(Duration::from_secs(2), conn.read_to_end(&mut buf)).await;
    let response = String::from_utf8_lossy(&buf);
    assert!(
        response.starts_with("HTTP/1.1 408") || response.is_empty(),
        "expected 408 or connection close, got: {}",
        response
    );
    handle.shutdown();
}