Skip to main content

eggserve_core/primitives/
http.rs

1//! Request validation primitives for static/read-only serving.
2//!
3//! These types decouple HTTP method and body-framing validation from the
4//! server loop so callers can pre-validate requests without depending on
5//! Hyper types.
6
7/// Supported read-only HTTP methods for static file serving.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
9pub enum ReadOnlyMethod {
10    Get,
11    Head,
12}
13
14impl ReadOnlyMethod {
15    pub fn as_str(&self) -> &'static str {
16        match self {
17            Self::Get => "GET",
18            Self::Head => "HEAD",
19        }
20    }
21}
22
23impl std::fmt::Display for ReadOnlyMethod {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        f.write_str(self.as_str())
26    }
27}
28
29/// Errors from request validation.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum RequestValidationError {
32    /// Method not supported for static serving.
33    MethodNotAllowed,
34    /// Malformed `Content-Length` header (non-numeric, empty, negative, or
35    /// overflowing u64).
36    InvalidContentLength,
37    /// `Content-Length` exceeds the configured body size limit.
38    BodyTooLarge,
39    /// Non-empty `Transfer-Encoding` on a read-only request.
40    UnsupportedTransferEncoding,
41    /// Both `Content-Length` and `Transfer-Encoding` present.
42    ConflictingBodyHeaders,
43    /// Request target is not valid origin-form (must start with `/`).
44    InvalidRequestTarget,
45}
46
47impl std::fmt::Display for RequestValidationError {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self {
50            Self::MethodNotAllowed => write!(f, "method not allowed for static serving"),
51            Self::InvalidContentLength => write!(f, "malformed Content-Length"),
52            Self::BodyTooLarge => write!(f, "request body too large"),
53            Self::UnsupportedTransferEncoding => {
54                write!(f, "unsupported Transfer-Encoding on read-only request")
55            }
56            Self::ConflictingBodyHeaders => {
57                write!(f, "both Content-Length and Transfer-Encoding present")
58            }
59            Self::InvalidRequestTarget => write!(f, "request target is not valid origin-form"),
60        }
61    }
62}
63
64impl std::error::Error for RequestValidationError {}
65
66/// Check if a method string is a supported read-only method.
67pub fn validate_method(method: &str) -> Result<ReadOnlyMethod, RequestValidationError> {
68    match method {
69        "GET" => Ok(ReadOnlyMethod::Get),
70        "HEAD" => Ok(ReadOnlyMethod::Head),
71        _ => Err(RequestValidationError::MethodNotAllowed),
72    }
73}
74
75/// Validate that a request has no body, as expected for GET/HEAD.
76///
77/// Checks:
78/// - No `Content-Length` and `Transfer-Encoding` together
79/// - No non-empty `Transfer-Encoding`
80/// - `Content-Length` (if present) is zero or valid and within `max_body_bytes`
81pub fn validate_request_body(
82    content_length: Option<&str>,
83    transfer_encoding: Option<&str>,
84    max_body_bytes: u64,
85) -> Result<(), RequestValidationError> {
86    if content_length.is_some() && transfer_encoding.is_some() {
87        return Err(RequestValidationError::ConflictingBodyHeaders);
88    }
89
90    if transfer_encoding.is_some() {
91        return Err(RequestValidationError::UnsupportedTransferEncoding);
92    }
93
94    if let Some(cl) = content_length {
95        let trimmed = cl.trim();
96        if trimmed.is_empty() {
97            return Err(RequestValidationError::InvalidContentLength);
98        }
99        if !trimmed.chars().all(|c| c.is_ascii_digit()) {
100            return Err(RequestValidationError::InvalidContentLength);
101        }
102        let len: u64 = trimmed
103            .parse()
104            .map_err(|_| RequestValidationError::InvalidContentLength)?;
105        if len > max_body_bytes {
106            return Err(RequestValidationError::BodyTooLarge);
107        }
108    }
109
110    Ok(())
111}
112
113/// Validate a request target is valid origin-form (starts with `/`).
114pub fn validate_request_target(target: &str) -> Result<(), RequestValidationError> {
115    if target.is_empty() || !target.starts_with('/') {
116        return Err(RequestValidationError::InvalidRequestTarget);
117    }
118    if target.contains(char::is_whitespace) {
119        return Err(RequestValidationError::InvalidRequestTarget);
120    }
121    Ok(())
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn get_is_allowed() {
130        assert_eq!(validate_method("GET").unwrap(), ReadOnlyMethod::Get);
131    }
132
133    #[test]
134    fn head_is_allowed() {
135        assert_eq!(validate_method("HEAD").unwrap(), ReadOnlyMethod::Head);
136    }
137
138    #[test]
139    fn post_is_rejected() {
140        assert_eq!(
141            validate_method("POST").unwrap_err(),
142            RequestValidationError::MethodNotAllowed
143        );
144    }
145
146    #[test]
147    fn put_is_rejected() {
148        assert_eq!(
149            validate_method("PUT").unwrap_err(),
150            RequestValidationError::MethodNotAllowed
151        );
152    }
153
154    #[test]
155    fn delete_is_rejected() {
156        assert_eq!(
157            validate_method("DELETE").unwrap_err(),
158            RequestValidationError::MethodNotAllowed
159        );
160    }
161
162    #[test]
163    fn patch_is_rejected() {
164        assert_eq!(
165            validate_method("PATCH").unwrap_err(),
166            RequestValidationError::MethodNotAllowed
167        );
168    }
169
170    #[test]
171    fn options_is_rejected() {
172        assert_eq!(
173            validate_method("OPTIONS").unwrap_err(),
174            RequestValidationError::MethodNotAllowed
175        );
176    }
177
178    #[test]
179    fn zero_content_length_allowed() {
180        assert!(validate_request_body(Some("0"), None, 0).is_ok());
181    }
182
183    #[test]
184    fn positive_content_length_rejected() {
185        assert_eq!(
186            validate_request_body(Some("1024"), None, 0).unwrap_err(),
187            RequestValidationError::BodyTooLarge
188        );
189    }
190
191    #[test]
192    fn invalid_content_length_rejected() {
193        assert_eq!(
194            validate_request_body(Some("not-a-number"), None, 0).unwrap_err(),
195            RequestValidationError::InvalidContentLength
196        );
197    }
198
199    #[test]
200    fn negative_content_length_rejected() {
201        assert_eq!(
202            validate_request_body(Some("-1"), None, 0).unwrap_err(),
203            RequestValidationError::InvalidContentLength
204        );
205    }
206
207    #[test]
208    fn overflowing_content_length_rejected() {
209        assert_eq!(
210            validate_request_body(Some("99999999999999999999"), None, 0).unwrap_err(),
211            RequestValidationError::InvalidContentLength
212        );
213    }
214
215    #[test]
216    fn nonempty_transfer_encoding_rejected() {
217        assert_eq!(
218            validate_request_body(None, Some("chunked"), 0).unwrap_err(),
219            RequestValidationError::UnsupportedTransferEncoding
220        );
221    }
222
223    #[test]
224    fn content_length_and_transfer_encoding_rejected() {
225        assert_eq!(
226            validate_request_body(Some("0"), Some("chunked"), 0).unwrap_err(),
227            RequestValidationError::ConflictingBodyHeaders
228        );
229    }
230
231    #[test]
232    fn empty_transfer_encoding_rejected() {
233        assert_eq!(
234            validate_request_body(None, Some(""), 0).unwrap_err(),
235            RequestValidationError::UnsupportedTransferEncoding
236        );
237    }
238
239    #[test]
240    fn whitespace_transfer_encoding_rejected() {
241        assert_eq!(
242            validate_request_body(None, Some("  "), 0).unwrap_err(),
243            RequestValidationError::UnsupportedTransferEncoding
244        );
245    }
246
247    #[test]
248    fn no_headers_allowed() {
249        assert!(validate_request_body(None, None, 0).is_ok());
250    }
251
252    #[test]
253    fn empty_content_length_rejected() {
254        assert_eq!(
255            validate_request_body(Some(""), None, 0).unwrap_err(),
256            RequestValidationError::InvalidContentLength
257        );
258    }
259
260    #[test]
261    fn whitespace_only_content_length_rejected() {
262        assert_eq!(
263            validate_request_body(Some("  "), None, 0).unwrap_err(),
264            RequestValidationError::InvalidContentLength
265        );
266    }
267
268    #[test]
269    fn content_length_with_max_body_bytes() {
270        assert!(validate_request_body(Some("100"), None, 100).is_ok());
271        assert_eq!(
272            validate_request_body(Some("101"), None, 100).unwrap_err(),
273            RequestValidationError::BodyTooLarge
274        );
275    }
276
277    #[test]
278    fn valid_request_target() {
279        assert!(validate_request_target("/").is_ok());
280        assert!(validate_request_target("/foo").is_ok());
281        assert!(validate_request_target("/foo/bar").is_ok());
282        assert!(validate_request_target("/file.txt").is_ok());
283    }
284
285    #[test]
286    fn empty_request_target_rejected() {
287        assert_eq!(
288            validate_request_target("").unwrap_err(),
289            RequestValidationError::InvalidRequestTarget
290        );
291    }
292
293    #[test]
294    fn absolute_uri_request_target_rejected() {
295        assert_eq!(
296            validate_request_target("http://example.com/").unwrap_err(),
297            RequestValidationError::InvalidRequestTarget
298        );
299    }
300
301    #[test]
302    fn asterisk_request_target_rejected() {
303        assert_eq!(
304            validate_request_target("*").unwrap_err(),
305            RequestValidationError::InvalidRequestTarget
306        );
307    }
308
309    #[test]
310    fn whitespace_in_request_target_rejected() {
311        assert_eq!(
312            validate_request_target("/foo bar").unwrap_err(),
313            RequestValidationError::InvalidRequestTarget
314        );
315    }
316
317    #[test]
318    fn read_only_method_as_str() {
319        assert_eq!(ReadOnlyMethod::Get.as_str(), "GET");
320        assert_eq!(ReadOnlyMethod::Head.as_str(), "HEAD");
321    }
322
323    #[test]
324    fn read_only_method_display() {
325        assert_eq!(format!("{}", ReadOnlyMethod::Get), "GET");
326        assert_eq!(format!("{}", ReadOnlyMethod::Head), "HEAD");
327    }
328
329    #[test]
330    fn request_validation_error_is_display() {
331        let err = RequestValidationError::MethodNotAllowed;
332        assert!(!err.to_string().is_empty());
333    }
334
335    #[test]
336    fn request_validation_error_is_error() {
337        let err: &dyn std::error::Error = &RequestValidationError::BodyTooLarge;
338        assert!(!err.to_string().is_empty());
339    }
340}