Skip to main content

eggserve_core/primitives/
request_target.rs

1//! Canonical HTTP request target.
2//!
3//! [`RequestTarget`] represents the request target from an HTTP request
4//! line, split into path and optional query components. It preserves the
5//! raw target for logging while providing validated access to components.
6
7use std::fmt;
8
9/// Errors from request target validation.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum RequestTargetError {
12    /// The target is empty.
13    Empty,
14    /// The target is not valid origin-form (does not start with `/`).
15    NotOriginForm,
16    /// The target contains whitespace.
17    ContainsWhitespace,
18    /// The target is an absolute URI (contains `://`).
19    AbsoluteUri,
20    /// The target is an authority-form URI (contains `@` without `/`).
21    AuthorityForm,
22    /// The target is an asterisk-form (`*`).
23    AsteriskForm,
24}
25
26impl fmt::Display for RequestTargetError {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        match self {
29            Self::Empty => write!(f, "request target is empty"),
30            Self::NotOriginForm => write!(f, "request target must start with '/'"),
31            Self::ContainsWhitespace => write!(f, "request target contains whitespace"),
32            Self::AbsoluteUri => write!(f, "absolute URI not supported"),
33            Self::AuthorityForm => write!(f, "authority-form URI not supported"),
34            Self::AsteriskForm => write!(f, "asterisk-form URI not supported"),
35        }
36    }
37}
38
39impl std::error::Error for RequestTargetError {}
40
41/// A validated HTTP request target in origin form.
42///
43/// The raw target is preserved for logging or downstream parsing. The
44/// validated path and optional query are available through accessor
45/// methods.
46///
47/// # Security
48///
49/// This type does not perform percent decoding or path normalization.
50/// Those operations belong to the [`ConfinedPath`] validation pipeline
51/// and must not be duplicated here.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct RequestTarget {
54    raw: String,
55    path: String,
56    query: Option<String>,
57}
58
59impl RequestTarget {
60    /// Parse and validate a request target.
61    ///
62    /// # Errors
63    ///
64    /// Returns [`RequestTargetError`] if the target is not valid origin-form.
65    pub fn parse(raw: impl Into<String>) -> Result<Self, RequestTargetError> {
66        let raw = raw.into();
67        if raw.is_empty() {
68            return Err(RequestTargetError::Empty);
69        }
70        if raw == "*" {
71            return Err(RequestTargetError::AsteriskForm);
72        }
73        if raw.contains(char::is_whitespace) {
74            return Err(RequestTargetError::ContainsWhitespace);
75        }
76        if raw.starts_with('/') {
77            if raw.starts_with("//") {
78                return Err(RequestTargetError::AuthorityForm);
79            }
80            return Self::parse_origin_form(raw);
81        }
82        if raw.contains("://") {
83            return Err(RequestTargetError::AbsoluteUri);
84        }
85        if raw.contains('@') || raw.contains(':') {
86            return Err(RequestTargetError::AuthorityForm);
87        }
88        // Non-`/`-prefixed, no `://`, no `@`, no `:` — not origin-form
89        Err(RequestTargetError::NotOriginForm)
90    }
91
92    fn parse_origin_form(raw: String) -> Result<Self, RequestTargetError> {
93        debug_assert!(raw.starts_with('/'));
94        let (path, query) = match raw.find('?') {
95            Some(pos) => {
96                let path = raw[..pos].to_string();
97                let q = &raw[pos + 1..];
98                if q.is_empty() {
99                    (path, None)
100                } else {
101                    (path, Some(q.to_string()))
102                }
103            }
104            None => (raw.clone(), None),
105        };
106
107        Ok(Self { raw, path, query })
108    }
109
110    /// Returns the raw request target string.
111    pub fn raw(&self) -> &str {
112        &self.raw
113    }
114
115    /// Returns the path component (before the `?`).
116    pub fn path(&self) -> &str {
117        &self.path
118    }
119
120    /// Returns the query component (after the `?`), if present.
121    pub fn query(&self) -> Option<&str> {
122        self.query.as_deref()
123    }
124
125    /// Returns the full target including query, if present.
126    pub fn path_and_query(&self) -> &str {
127        &self.raw
128    }
129}
130
131impl fmt::Display for RequestTarget {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        f.write_str(&self.raw)
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn root_path() {
143        let t = RequestTarget::parse("/").unwrap();
144        assert_eq!(t.raw(), "/");
145        assert_eq!(t.path(), "/");
146        assert!(t.query().is_none());
147    }
148
149    #[test]
150    fn network_path_reference_is_rejected() {
151        assert_eq!(
152            RequestTarget::parse("//example.com/file").unwrap_err(),
153            RequestTargetError::AuthorityForm
154        );
155    }
156
157    #[test]
158    fn path_with_query() {
159        let t = RequestTarget::parse("/foo?bar=baz").unwrap();
160        assert_eq!(t.path(), "/foo");
161        assert_eq!(t.query(), Some("bar=baz"));
162    }
163
164    #[test]
165    fn path_with_empty_query() {
166        let t = RequestTarget::parse("/foo?").unwrap();
167        assert_eq!(t.path(), "/foo");
168        assert!(t.query().is_none());
169    }
170
171    #[test]
172    fn path_with_multiple_query_params() {
173        let t = RequestTarget::parse("/a?b=1&c=2").unwrap();
174        assert_eq!(t.path(), "/a");
175        assert_eq!(t.query(), Some("b=1&c=2"));
176    }
177
178    #[test]
179    fn complex_path() {
180        let t = RequestTarget::parse("/foo/bar/file.txt?x=1&y=2").unwrap();
181        assert_eq!(t.path(), "/foo/bar/file.txt");
182        assert_eq!(t.query(), Some("x=1&y=2"));
183    }
184
185    #[test]
186    fn reject_empty() {
187        assert_eq!(
188            RequestTarget::parse("").unwrap_err(),
189            RequestTargetError::Empty
190        );
191    }
192
193    #[test]
194    fn reject_no_slash_prefix() {
195        assert_eq!(
196            RequestTarget::parse("foo").unwrap_err(),
197            RequestTargetError::NotOriginForm
198        );
199    }
200
201    #[test]
202    fn reject_absolute_uri() {
203        assert_eq!(
204            RequestTarget::parse("http://example.com/").unwrap_err(),
205            RequestTargetError::AbsoluteUri
206        );
207    }
208
209    #[test]
210    fn reject_authority_form() {
211        assert_eq!(
212            RequestTarget::parse("example.com:443").unwrap_err(),
213            RequestTargetError::AuthorityForm
214        );
215    }
216
217    #[test]
218    fn reject_asterisk_form() {
219        assert_eq!(
220            RequestTarget::parse("*").unwrap_err(),
221            RequestTargetError::AsteriskForm
222        );
223    }
224
225    #[test]
226    fn reject_whitespace() {
227        assert_eq!(
228            RequestTarget::parse("/foo bar").unwrap_err(),
229            RequestTargetError::ContainsWhitespace
230        );
231        assert_eq!(
232            RequestTarget::parse("/foo\tbar").unwrap_err(),
233            RequestTargetError::ContainsWhitespace
234        );
235    }
236
237    #[test]
238    fn path_and_query_combined() {
239        let t = RequestTarget::parse("/foo?bar").unwrap();
240        assert_eq!(t.path_and_query(), "/foo?bar");
241    }
242
243    #[test]
244    fn display() {
245        let t = RequestTarget::parse("/foo?bar").unwrap();
246        assert_eq!(format!("{t}"), "/foo?bar");
247    }
248
249    #[test]
250    fn error_display() {
251        assert!(!RequestTargetError::Empty.to_string().is_empty());
252        assert!(!RequestTargetError::NotOriginForm.to_string().is_empty());
253        assert!(!RequestTargetError::AbsoluteUri.to_string().is_empty());
254        assert!(!RequestTargetError::AuthorityForm.to_string().is_empty());
255        assert!(!RequestTargetError::AsteriskForm.to_string().is_empty());
256        assert!(!RequestTargetError::ContainsWhitespace
257            .to_string()
258            .is_empty());
259    }
260
261    #[test]
262    fn error_is_error() {
263        let err: &dyn std::error::Error = &RequestTargetError::Empty;
264        assert!(!err.to_string().is_empty());
265    }
266
267    #[test]
268    fn percent_encoded_path() {
269        let t = RequestTarget::parse("/foo%20bar").unwrap();
270        assert_eq!(t.raw(), "/foo%20bar");
271        assert_eq!(t.path(), "/foo%20bar");
272        assert!(t.query().is_none());
273    }
274
275    #[test]
276    fn percent_encoded_slash() {
277        let t = RequestTarget::parse("/foo%2Fbar").unwrap();
278        assert_eq!(t.raw(), "/foo%2Fbar");
279        assert_eq!(t.path(), "/foo%2Fbar");
280        assert!(t.query().is_none());
281    }
282
283    #[test]
284    fn dot_segment_paths() {
285        let t = RequestTarget::parse("/./foo").unwrap();
286        assert_eq!(t.path(), "/./foo");
287        assert!(t.query().is_none());
288    }
289
290    #[test]
291    fn dot_segment_parent() {
292        let t = RequestTarget::parse("/foo/../bar").unwrap();
293        assert_eq!(t.path(), "/foo/../bar");
294        assert!(t.query().is_none());
295    }
296
297    #[test]
298    fn backslash_in_path() {
299        let t = RequestTarget::parse("/foo\\bar").unwrap();
300        assert_eq!(t.path(), "/foo\\bar");
301        assert!(t.query().is_none());
302    }
303
304    #[test]
305    fn non_ascii_bytes() {
306        let t = RequestTarget::parse("/foo\u{00E9}\u{00FF}").unwrap();
307        assert_eq!(t.raw(), "/foo\u{00E9}\u{00FF}");
308        assert_eq!(t.path(), "/foo\u{00E9}\u{00FF}");
309        assert!(t.query().is_none());
310    }
311
312    #[test]
313    fn origin_form_only_enforced() {
314        assert_eq!(
315            RequestTarget::parse("foo").unwrap_err(),
316            RequestTargetError::NotOriginForm
317        );
318    }
319
320    #[test]
321    fn query_with_equals() {
322        let t = RequestTarget::parse("/path?key=val=ue").unwrap();
323        assert_eq!(t.path(), "/path");
324        assert_eq!(t.query(), Some("key=val=ue"));
325    }
326
327    #[test]
328    fn query_with_encoded_chars() {
329        let t = RequestTarget::parse("/path?key=hello%20world").unwrap();
330        assert_eq!(t.path(), "/path");
331        assert_eq!(t.query(), Some("key=hello%20world"));
332    }
333
334    #[test]
335    fn multiple_question_marks() {
336        let t = RequestTarget::parse("/path?a=1?b=2").unwrap();
337        assert_eq!(t.path(), "/path");
338        assert_eq!(t.query(), Some("a=1?b=2"));
339    }
340}