Skip to main content

unitycatalog_server/rest/
validation.rs

1//! Request validation middleware.
2//!
3//! Provides:
4//! - [`ValidationLayer`] / [`ValidationMiddleware`]: Tower middleware that rejects requests with
5//!   a `Content-Length` header exceeding [`MAX_BODY_SIZE`] (1 MiB) with HTTP 413.
6//! - [`max_results_clamp`]: Utility that clamps an optional `max_results` query parameter to a
7//!   caller-supplied maximum, converting from `i32` to `usize`. Use this in list handlers instead
8//!   of the raw `.map(|v| v as usize)` pattern to enforce per-endpoint upper bounds.
9use std::task::{Context, Poll};
10
11use axum::extract::Request;
12use axum::http::StatusCode;
13use axum::response::{IntoResponse, Response};
14use futures_util::FutureExt;
15use futures_util::future::BoxFuture;
16use tower::{Layer, Service};
17
18/// Maximum allowed request body size (1 MiB).
19const MAX_BODY_SIZE: usize = 1024 * 1024;
20
21// ---------------------------------------------------------------------------
22// Public utility
23// ---------------------------------------------------------------------------
24
25/// Clamp `max_results` to at most `max`, converting `i32` to `usize`.
26///
27/// Returns `None` when `value` is `None` (i.e. the caller did not supply the parameter).
28/// Negative values of `value` are treated as `0` via the unsigned cast.
29///
30/// # Example
31///
32/// ```
33/// use unitycatalog_server::rest::max_results_clamp;
34///
35/// assert_eq!(max_results_clamp(None, 200), None);
36/// assert_eq!(max_results_clamp(Some(10), 200), Some(10));
37/// assert_eq!(max_results_clamp(Some(500), 200), Some(200));
38/// ```
39pub fn max_results_clamp(value: Option<i32>, max: usize) -> Option<usize> {
40    value.map(|v| (v as usize).min(max))
41}
42
43// ---------------------------------------------------------------------------
44// Middleware
45// ---------------------------------------------------------------------------
46
47/// Middleware that rejects requests whose `Content-Length` header exceeds [`MAX_BODY_SIZE`].
48///
49/// Only the `Content-Length` header is inspected — the body itself is not buffered. This makes
50/// the check cheap but means a client that omits the header (or lies about it) will not be
51/// caught here. Pair with a body-size limiting layer (e.g. `tower_http::limit::RequestBodyLimitLayer`)
52/// for full protection.
53#[derive(Clone)]
54pub struct ValidationMiddleware<S> {
55    inner: S,
56}
57
58impl<S> Service<Request> for ValidationMiddleware<S>
59where
60    S: Service<Request, Response = Response> + Send + 'static,
61    S::Future: Send + 'static,
62{
63    type Response = S::Response;
64    type Error = S::Error;
65    type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
66
67    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
68        self.inner.poll_ready(cx)
69    }
70
71    fn call(&mut self, req: Request) -> Self::Future {
72        // Check Content-Length before forwarding the request.
73        if let Some(content_length) = req
74            .headers()
75            .get(axum::http::header::CONTENT_LENGTH)
76            .and_then(|v| v.to_str().ok())
77            .and_then(|s| s.parse::<usize>().ok())
78            && content_length > MAX_BODY_SIZE
79        {
80            let response = (
81                StatusCode::PAYLOAD_TOO_LARGE,
82                format!(
83                    "Request body too large: {} bytes (limit: {} bytes)",
84                    content_length, MAX_BODY_SIZE
85                ),
86            )
87                .into_response();
88            return async { Ok(response) }.boxed();
89        }
90
91        self.inner.call(req).boxed()
92    }
93}
94
95// ---------------------------------------------------------------------------
96// Layer
97// ---------------------------------------------------------------------------
98
99/// [`Layer`] that applies [`ValidationMiddleware`] to a service.
100#[derive(Clone, Default)]
101pub struct ValidationLayer;
102
103impl ValidationLayer {
104    /// Create a new [`ValidationLayer`].
105    pub fn new() -> Self {
106        Self
107    }
108}
109
110impl<S> Layer<S> for ValidationLayer {
111    type Service = ValidationMiddleware<S>;
112
113    fn layer(&self, inner: S) -> Self::Service {
114        ValidationMiddleware { inner }
115    }
116}
117
118// ---------------------------------------------------------------------------
119// Tests
120// ---------------------------------------------------------------------------
121
122#[cfg(test)]
123mod tests {
124    use axum::body::Body;
125    use axum::http::{Request, StatusCode, header};
126    use tower::{ServiceBuilder, ServiceExt};
127
128    use super::*;
129
130    async fn echo(_req: Request<Body>) -> Result<Response<Body>, std::convert::Infallible> {
131        Ok(Response::new(Body::empty()))
132    }
133
134    #[tokio::test]
135    async fn test_allows_small_body() {
136        let mut svc = ServiceBuilder::new()
137            .layer(ValidationLayer::new())
138            .service_fn(echo);
139
140        let req = Request::post("/")
141            .header(header::CONTENT_LENGTH, "100")
142            .body(Body::empty())
143            .unwrap();
144
145        let resp = svc.ready().await.unwrap().call(req).await.unwrap();
146        assert_eq!(resp.status(), StatusCode::OK);
147    }
148
149    #[tokio::test]
150    async fn test_rejects_oversized_body() {
151        let mut svc = ServiceBuilder::new()
152            .layer(ValidationLayer::new())
153            .service_fn(echo);
154
155        let req = Request::post("/")
156            .header(header::CONTENT_LENGTH, (MAX_BODY_SIZE + 1).to_string())
157            .body(Body::empty())
158            .unwrap();
159
160        let resp = svc.ready().await.unwrap().call(req).await.unwrap();
161        assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
162    }
163
164    #[tokio::test]
165    async fn test_allows_missing_content_length() {
166        let mut svc = ServiceBuilder::new()
167            .layer(ValidationLayer::new())
168            .service_fn(echo);
169
170        let req = Request::post("/").body(Body::empty()).unwrap();
171        let resp = svc.ready().await.unwrap().call(req).await.unwrap();
172        assert_eq!(resp.status(), StatusCode::OK);
173    }
174
175    #[test]
176    fn test_max_results_clamp() {
177        assert_eq!(max_results_clamp(None, 200), None);
178        assert_eq!(max_results_clamp(Some(10), 200), Some(10));
179        assert_eq!(max_results_clamp(Some(200), 200), Some(200));
180        assert_eq!(max_results_clamp(Some(500), 200), Some(200));
181        assert_eq!(max_results_clamp(Some(0), 200), Some(0));
182    }
183}